Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
788 changes: 572 additions & 216 deletions apps/agentacct/Sources/agentacct/DashboardPane.swift

Large diffs are not rendered by default.

138 changes: 118 additions & 20 deletions apps/agentacct/Sources/agentacct/DashboardSnapshotHarness.swift
Original file line number Diff line number Diff line change
Expand Up @@ -136,21 +136,27 @@ struct DashboardSnapshotConfiguration {
let colorScheme: ColorScheme
let workState: SnapshotWorkStoreState
let glanceState: DashboardSnapshotGlanceState
let dynamicTypeSize: DynamicTypeSize
let capturesFullContent: Bool

init(
viewport: String,
width: CGFloat,
height: CGFloat,
colorScheme: ColorScheme,
workState: SnapshotWorkStoreState,
glanceState: DashboardSnapshotGlanceState = .fixture
glanceState: DashboardSnapshotGlanceState = .fixture,
dynamicTypeSize: DynamicTypeSize = .medium,
capturesFullContent: Bool = false
) {
self.viewport = viewport
self.width = width
self.height = height
self.colorScheme = colorScheme
self.workState = workState
self.glanceState = glanceState
self.dynamicTypeSize = dynamicTypeSize
self.capturesFullContent = capturesFullContent
}

var filename: String {
Expand All @@ -170,6 +176,57 @@ struct DashboardSnapshotConfiguration {
Self(viewport: "trust-unavailable", width: 1120, height: 800, colorScheme: .dark, workState: .shiftBriefUnavailable),
Self(viewport: "old-daemon-statusless", width: 1120, height: 800, colorScheme: .light, workState: .oldDaemonUnavailable, glanceState: .statuslessUsage),
Self(viewport: "old-daemon-statusless", width: 1120, height: 800, colorScheme: .dark, workState: .oldDaemonUnavailable, glanceState: .statuslessUsage),
// Exercise both sides of the layout transition at minimum width. One
// appearance is sufficient here because the assertion is geometric;
// the maximum-size pair below still covers both color schemes.
Self(
viewport: "xlarge-minimum",
width: 960,
height: 800,
colorScheme: .light,
workState: .populated,
dynamicTypeSize: .xLarge
),
Self(
viewport: "xxlarge-minimum",
width: 960,
height: 2050,
colorScheme: .light,
workState: .populated,
dynamicTypeSize: .xxLarge,
capturesFullContent: true
),
// Full-content renders prove that reflow remains readable under the
// tightest real window without silently accepting a clipped canvas.
Self(
viewport: "accessibility5-minimum",
width: 960,
height: 2700,
colorScheme: .light,
workState: .populated,
dynamicTypeSize: .accessibility5,
capturesFullContent: true
),
Self(
viewport: "accessibility5-minimum",
width: 960,
height: 2700,
colorScheme: .dark,
workState: .populated,
dynamicTypeSize: .accessibility5,
capturesFullContent: true
),
// A realistic retained-cache failure proves that a multi-line alert
// participates in layout instead of obscuring the final scroll rows.
Self(
viewport: "accessibility5-error-minimum",
width: 960,
height: 2900,
colorScheme: .light,
workState: .retainedLongListError,
dynamicTypeSize: .accessibility5,
capturesFullContent: true
),
]
}

Expand Down Expand Up @@ -211,10 +268,10 @@ enum DashboardSnapshotGlanceState {
}

enum DashboardSnapshotRenderer {
private static let snapshotLocale = Locale(identifier: "en_US_POSIX")
private static let snapshotTimeZone = TimeZone(secondsFromGMT: 0)!
fileprivate static let snapshotLocale = Locale(identifier: "en_US_POSIX")
fileprivate static let snapshotTimeZone = TimeZone(secondsFromGMT: 0)!

private static var snapshotCalendar: Calendar {
fileprivate static var snapshotCalendar: Calendar {
var calendar = Calendar(identifier: .gregorian)
calendar.locale = snapshotLocale
calendar.timeZone = snapshotTimeZone
Expand Down Expand Up @@ -258,31 +315,45 @@ enum DashboardSnapshotRenderer {
)
let selection = AppSelection()
selection.pane = .dashboard
let environment = DashboardSnapshotEnvironment(
configuration: configuration,
glance: glance,
dashboard: dashboard,
selection: selection
)
if configuration.capturesFullContent {
SnapshotMode.boundsScrollContentToViewport = false
let intrinsicScene = VStack(spacing: 0) {
TopBar(canSetUp: true)
Rectangle().fill(Theme.rule).frame(height: 1)
DashboardPane(usesScrollViewport: false)
}
.modifier(environment)
.frame(width: configuration.width, alignment: .topLeading)
.fixedSize(horizontal: false, vertical: true)
let requiredSize = try SnapshotImageWriter.renderedSize(
intrinsicScene,
proposedSize: ProposedViewSize(width: configuration.width, height: nil)
)
SnapshotMode.boundsScrollContentToViewport = true
guard requiredSize.height <= configuration.height else {
throw SnapshotError.snapshotContentExceedsCanvas(
filename: configuration.filename,
requiredHeight: Int(ceil(requiredSize.height)),
availableHeight: Int(configuration.height)
)
}
}
// A packaged app consistently offers setup here. Injecting that
// state keeps SwiftPM and packaged-build snapshots identical.
let view = MainWindow(canSetUpOverride: true)
.environmentObject(glance)
.environmentObject(dashboard)
.environmentObject(selection)
.modifier(environment)
.frame(
width: configuration.width,
height: configuration.height,
alignment: .top
)
.clipped()
.environment(\.colorScheme, configuration.colorScheme)
.environment(\.locale, snapshotLocale)
.environment(\.calendar, snapshotCalendar)
.environment(\.timeZone, snapshotTimeZone)
.environment(\.displayScale, 2)
.environment(\.layoutDirection, .leftToRight)
.environment(\.dynamicTypeSize, .medium)
.environment(\.controlSize, .regular)
.environment(\.legibilityWeight, nil)
.environment(\.appearsActive, true)
.transaction { transaction in
transaction.disablesAnimations = true
}
let outputURL = outputDirectory.appendingPathComponent(configuration.filename)
try SnapshotImageWriter.render(
view,
Expand All @@ -294,6 +365,33 @@ enum DashboardSnapshotRenderer {
}
}

private struct DashboardSnapshotEnvironment: ViewModifier {
let configuration: DashboardSnapshotConfiguration
let glance: GlanceState
let dashboard: DashboardStore
let selection: AppSelection

func body(content: Content) -> some View {
content
.environmentObject(glance)
.environmentObject(dashboard)
.environmentObject(selection)
.environment(\.colorScheme, configuration.colorScheme)
.environment(\.locale, DashboardSnapshotRenderer.snapshotLocale)
.environment(\.calendar, DashboardSnapshotRenderer.snapshotCalendar)
.environment(\.timeZone, DashboardSnapshotRenderer.snapshotTimeZone)
.environment(\.displayScale, 2)
.environment(\.layoutDirection, .leftToRight)
.environment(\.dynamicTypeSize, configuration.dynamicTypeSize)
.environment(\.controlSize, .regular)
.environment(\.legibilityWeight, nil)
.environment(\.appearsActive, true)
.transaction { transaction in
transaction.disablesAnimations = true
}
}
}

enum SnapshotImageWriter {
@MainActor
static func renderedSize(
Expand Down
6 changes: 6 additions & 0 deletions apps/agentacct/Sources/agentacct/DashboardStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ enum SnapshotWorkStoreState {
case empty
case listError
case retainedListError
case retainedLongListError
case shiftBriefUnavailable
case oldDaemonUnavailable
case attentionOverflow
Expand Down Expand Up @@ -128,6 +129,11 @@ final class DashboardStore: ObservableObject {
receiptTasks = fixture.tasks.tasks
totalReceiptTasks = fixture.tasks.total
receiptListError = "receipts fetch failed: synthetic review error"
case .retainedLongListError:
hasLoadedReceiptTasks = true
receiptTasks = fixture.tasks.tasks
totalReceiptTasks = fixture.tasks.total
receiptListError = "receipts fetch failed: The local service returned an incomplete response while refreshing cached work. Existing items remain visible and may be stale."
case .shiftBriefUnavailable:
hasLoadedReceiptTasks = true
receiptTasks = fixture.tasks.tasks
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,37 @@
import AppKit
import Foundation
import SwiftUI
import XCTest
@testable import agentacct

final class DashboardInteractionTests: XCTestCase {
@MainActor
func testDashboardAccessibilityTextIsMateriallyLargerInTheNativeHost() throws {
func renderedSize(_ dynamicTypeSize: DynamicTypeSize) throws -> CGSize {
try SnapshotImageWriter.renderedSize(
Text("Readable dashboard")
.dashboardFont(.body)
.environment(\.dynamicTypeSize, dynamicTypeSize)
)
}

let medium = try renderedSize(.medium)
let accessibility5 = try renderedSize(.accessibility5)

XCTAssertGreaterThan(accessibility5.width, medium.width * 1.7)
XCTAssertGreaterThan(accessibility5.height, medium.height * 1.7)
}

func testDashboardAccessibleLayoutStartsBeforeFixedGeometryBecomesUnsafe() {
XCTAssertFalse(dashboardUsesAccessibilityLayout(.medium))
XCTAssertFalse(dashboardUsesAccessibilityLayout(.xLarge))
XCTAssertTrue(dashboardUsesAccessibilityLayout(.xxLarge))
XCTAssertTrue(dashboardUsesAccessibilityLayout(.xxxLarge))
XCTAssertTrue(dashboardUsesAccessibilityLayout(.accessibility1))
XCTAssertTrue(dashboardUsesAccessibilityLayout(.accessibility3))
XCTAssertTrue(dashboardUsesAccessibilityLayout(.accessibility5))
}

func testAttentionPayloadPreservesCompleteCountsAndRecordedReason() throws {
let payload = try decode(
V1AttentionPayload.self,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import AppKit
import SwiftUI
import XCTest
@testable import agentacct

Expand All @@ -20,6 +21,11 @@ final class DashboardSnapshotHarnessTests: XCTestCase {
ExpectedArtifact(filename: "dashboard-trust-unavailable-dark.png", pixelsWide: 2240, pixelsHigh: 1600),
ExpectedArtifact(filename: "dashboard-old-daemon-statusless-light.png", pixelsWide: 2240, pixelsHigh: 1600),
ExpectedArtifact(filename: "dashboard-old-daemon-statusless-dark.png", pixelsWide: 2240, pixelsHigh: 1600),
ExpectedArtifact(filename: "dashboard-xlarge-minimum-light.png", pixelsWide: 1920, pixelsHigh: 1600),
ExpectedArtifact(filename: "dashboard-xxlarge-minimum-light.png", pixelsWide: 1920, pixelsHigh: 4100),
ExpectedArtifact(filename: "dashboard-accessibility5-minimum-light.png", pixelsWide: 1920, pixelsHigh: 5400),
ExpectedArtifact(filename: "dashboard-accessibility5-minimum-dark.png", pixelsWide: 1920, pixelsHigh: 5400),
ExpectedArtifact(filename: "dashboard-accessibility5-error-minimum-light.png", pixelsWide: 1920, pixelsHigh: 5800),
]

@MainActor
Expand All @@ -46,6 +52,84 @@ final class DashboardSnapshotHarnessTests: XCTestCase {
XCTAssertNotNil(fixture.glance.usage.windows.first { $0.label == "today" })
}

@MainActor
func testFullContentConfigurationsRejectClippedCanvases() throws {
let fixture = try DashboardSnapshotFixture.load(from: dashboardFixtureURL())
let outputDirectory = FileManager.default.temporaryDirectory
.appendingPathComponent("agentacct-dashboard-clipping-\(UUID().uuidString)")
defer { try? FileManager.default.removeItem(at: outputDirectory) }
let cases: [(String, DynamicTypeSize)] = [
("xxlarge-too-short", .xxLarge),
("accessibility5-too-short", .accessibility5),
]

for (viewport, dynamicTypeSize) in cases {
let configuration = DashboardSnapshotConfiguration(
viewport: viewport,
width: 960,
height: 560,
colorScheme: .light,
workState: .populated,
dynamicTypeSize: dynamicTypeSize,
capturesFullContent: true
)

XCTAssertThrowsError(
try DashboardSnapshotRenderer.render(
fixture: fixture,
outputDirectory: outputDirectory,
configurations: [configuration]
)
) { error in
guard case SnapshotError.snapshotContentExceedsCanvas(
let filename,
let requiredHeight,
let availableHeight
) = error else {
return XCTFail("Unexpected error: \(error)")
}
XCTAssertEqual(filename, "dashboard-\(viewport)-light.png")
XCTAssertGreaterThan(requiredHeight, availableHeight)
XCTAssertEqual(availableHeight, 560)
}
}
}

@MainActor
func testMaximumTextErrorAlertParticipatesInFullContentLayout() throws {
let fixture = try DashboardSnapshotFixture.load(from: dashboardFixtureURL())
let outputDirectory = FileManager.default.temporaryDirectory
.appendingPathComponent("agentacct-dashboard-error-layout-\(UUID().uuidString)")
defer { try? FileManager.default.removeItem(at: outputDirectory) }
let configuration = DashboardSnapshotConfiguration(
viewport: "accessibility5-error-too-short",
width: 960,
height: 2700,
colorScheme: .light,
workState: .retainedLongListError,
dynamicTypeSize: .accessibility5,
capturesFullContent: true
)

XCTAssertThrowsError(
try DashboardSnapshotRenderer.render(
fixture: fixture,
outputDirectory: outputDirectory,
configurations: [configuration]
)
) { error in
guard case SnapshotError.snapshotContentExceedsCanvas(
_,
let requiredHeight,
let availableHeight
) = error else {
return XCTFail("Unexpected error: \(error)")
}
XCTAssertGreaterThan(requiredHeight, availableHeight)
XCTAssertEqual(availableHeight, 2700)
}
}

@MainActor
func testUnavailableTrustStateRetainsSuccessOnlyToProveErrorPrecedence() throws {
let fixture = try DashboardSnapshotFixture.load(from: dashboardFixtureURL())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ final class DashboardVisualRegressionTests: XCTestCase {
"dashboard-trust-unavailable-dark.png",
"dashboard-old-daemon-statusless-light.png",
"dashboard-old-daemon-statusless-dark.png",
"dashboard-xlarge-minimum-light.png",
"dashboard-xxlarge-minimum-light.png",
"dashboard-accessibility5-minimum-light.png",
"dashboard-accessibility5-minimum-dark.png",
"dashboard-accessibility5-error-minimum-light.png",
]

@MainActor
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading