## What changed - add App and PetCore portable Agent Pet Maker skill management, with fd-relative ownership and race-safe install/uninstall - improve Pi no-model failure projection, session bubble message selection, child-session filtering, onboarding rendering, and related App behavior - delete verified PR source branches with exact leases after merge while dispatching exact-main CI first - document direct/train multi-agent delivery, explicit add/commit/clean/push checks, post-merge worktree audits, and exact-SHA release proof ## Validation - full `cargo test -p petcore` - Swift package suite: 734 tests - Pi connector privacy tests - promotion/release Python tests and actionlint - `./script/validate_pre_push.sh` - independent specification and safety reviews: no remaining blockers This PR changes the protected CI control plane, so it is intended for full CI followed by trusted exact-head squash merge. --------- Co-authored-by: gd-ops <gd-ops@xmfunny.com>
2401 lines
92 KiB
Swift
2401 lines
92 KiB
Swift
import AppKit
|
|
import AgentPetCompanionCore
|
|
import Testing
|
|
@testable import AgentPetCompanion
|
|
|
|
@Suite
|
|
struct OverlayGeometryTests {
|
|
@Test
|
|
func testPetMenuOpensOnlyForEnabledRightClick() {
|
|
#expect(!OverlayPetMenuPolicy.shouldOpen(buttonNumber: 0, isEnabled: true))
|
|
#expect(OverlayPetMenuPolicy.shouldOpen(buttonNumber: 1, isEnabled: true))
|
|
#expect(!OverlayPetMenuPolicy.shouldOpen(buttonNumber: 1, isEnabled: false))
|
|
#expect(!OverlayPetMenuPolicy.shouldOpen(buttonNumber: 2, isEnabled: true))
|
|
}
|
|
|
|
@Test
|
|
func testCompactOverlayMetricsKeepTwoLineMessagesAndAccessibleControls() {
|
|
#expect(OverlayGeometry.bubbleDetailLineLimit == 2)
|
|
#expect(OverlayGeometry.bubbleStandaloneSummaryLineLimit == 2)
|
|
#expect(OverlayGeometry.bubbleWidth == 344)
|
|
#expect(OverlayGeometry.bubbleGap <= 3)
|
|
#expect(OverlayGeometry.menuHitSize.width >= 38)
|
|
#expect(OverlayGeometry.menuHitSize.height >= 38)
|
|
#expect(OverlayGeometry.menuVisualSize.width == OverlayGeometry.menuVisualSize.height)
|
|
#expect(OverlayGeometry.menuHitSize.width == OverlayGeometry.menuHitSize.height)
|
|
#expect(OverlayGeometry.menuVisualSize.width < OverlayGeometry.menuHitSize.width)
|
|
}
|
|
|
|
@Test
|
|
func standaloneBubbleHeightAlwaysReservesItsTwoLineMaximum() {
|
|
let sessions = [
|
|
OverlaySessionContent(
|
|
id: "empty",
|
|
source: .codex,
|
|
sessionID: "empty",
|
|
eventType: .thinking,
|
|
sessionTitle: "Investigate bubble",
|
|
messageText: "",
|
|
statusText: ""
|
|
),
|
|
OverlaySessionContent(
|
|
id: "one-line",
|
|
source: .codex,
|
|
sessionID: "one-line",
|
|
eventType: .thinking,
|
|
sessionTitle: "Investigate bubble",
|
|
messageText: "Thinking",
|
|
statusText: "Thinking"
|
|
),
|
|
OverlaySessionContent(
|
|
id: "two-lines",
|
|
source: .codex,
|
|
sessionID: "two-lines",
|
|
eventType: .tool,
|
|
sessionTitle: "Investigate bubble",
|
|
messageText: String(repeating: "Bounded two-line session detail ", count: 8),
|
|
statusText: "Running"
|
|
),
|
|
]
|
|
|
|
for fontScale in BubbleFontScale.allCases {
|
|
let heights = sessions.map { session in
|
|
OverlayGeometry.resolvedBubbleSize(
|
|
in: CGSize(width: 1_512, height: 934),
|
|
content: OverlayBubbleContent(
|
|
id: "standalone-\(session.id)",
|
|
source: .codex,
|
|
agentName: "Codex",
|
|
sessions: [session],
|
|
isStandaloneSessionCard: true
|
|
),
|
|
fontScale: fontScale
|
|
).height
|
|
}
|
|
#expect(heights.dropFirst().allSatisfy { $0 == heights[0] })
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func displayWidthContractUsesExactRangeAspectAndDefault() {
|
|
#expect(OverlayGeometry.minimumDisplayWidthPt == 100)
|
|
#expect(OverlayGeometry.defaultDisplayWidthPt == 112)
|
|
#expect(OverlayGeometry.maximumDisplayWidthPt == 300)
|
|
let size = OverlayGeometry.petVisibleSize(displayWidthPt: 112)
|
|
#expect(size.width == 112)
|
|
#expect(abs(size.height - 112 * OverlayGeometry.displayAspectHeightRatio) < 0.001)
|
|
}
|
|
|
|
@Test
|
|
func bubbleToggleUsesAnAnchorAwareChevronForEverySessionCount() {
|
|
#expect(OverlayBubbleTogglePresentation.content(
|
|
sessionCount: 0,
|
|
revealsMoreContent: true
|
|
) == nil)
|
|
#expect(OverlayBubbleTogglePresentation.content(
|
|
sessionCount: 1,
|
|
revealsMoreContent: true
|
|
) == .chevron(systemImage: "chevron.up"))
|
|
#expect(OverlayBubbleTogglePresentation.content(
|
|
sessionCount: 1,
|
|
revealsMoreContent: false
|
|
) == .chevron(systemImage: "chevron.down"))
|
|
#expect(OverlayBubbleTogglePresentation.content(
|
|
sessionCount: 2,
|
|
revealsMoreContent: true
|
|
) == .chevron(systemImage: "chevron.up"))
|
|
#expect(OverlayBubbleTogglePresentation.content(
|
|
sessionCount: 12,
|
|
revealsMoreContent: false
|
|
) == .chevron(systemImage: "chevron.down"))
|
|
#expect(OverlayBubbleTogglePresentation.content(
|
|
sessionCount: 2,
|
|
revealsMoreContent: true,
|
|
anchorDirection: .below
|
|
) == .chevron(systemImage: "chevron.down"))
|
|
#expect(OverlayBubbleTogglePresentation.content(
|
|
sessionCount: 2,
|
|
revealsMoreContent: false,
|
|
anchorDirection: .below
|
|
) == .chevron(systemImage: "chevron.up"))
|
|
}
|
|
|
|
@Test
|
|
func bubbleToggleMovesOneLevelThroughTheFlatTrayInEitherDirection() {
|
|
#expect(OverlayBubbleDisclosureAction.resolve(
|
|
groupSessionsByAgent: false,
|
|
sessionCount: 2,
|
|
bubbleDismissed: false,
|
|
standaloneStackExpanded: true,
|
|
standaloneStackDirection: .expanding
|
|
) == .collapseStandaloneStack)
|
|
#expect(OverlayBubbleDisclosureAction.resolve(
|
|
groupSessionsByAgent: false,
|
|
sessionCount: 2,
|
|
bubbleDismissed: false,
|
|
standaloneStackExpanded: false,
|
|
standaloneStackDirection: .collapsing
|
|
) == .dismissBubble)
|
|
#expect(OverlayBubbleDisclosureAction.resolve(
|
|
groupSessionsByAgent: false,
|
|
sessionCount: 2,
|
|
bubbleDismissed: true,
|
|
standaloneStackExpanded: false,
|
|
standaloneStackDirection: .collapsing
|
|
) == .revealCollapsedStandaloneStack)
|
|
#expect(OverlayBubbleDisclosureAction.resolve(
|
|
groupSessionsByAgent: false,
|
|
sessionCount: 2,
|
|
bubbleDismissed: false,
|
|
standaloneStackExpanded: false,
|
|
standaloneStackDirection: .expanding
|
|
) == .expandStandaloneStack)
|
|
#expect(OverlayBubbleDisclosureAction.resolve(
|
|
groupSessionsByAgent: false,
|
|
sessionCount: 1,
|
|
bubbleDismissed: false,
|
|
standaloneStackExpanded: true,
|
|
standaloneStackDirection: .expanding
|
|
) == .dismissBubble)
|
|
#expect(OverlayBubbleDisclosureAction.resolve(
|
|
groupSessionsByAgent: true,
|
|
sessionCount: 2,
|
|
bubbleDismissed: true,
|
|
standaloneStackExpanded: true,
|
|
standaloneStackDirection: .collapsing
|
|
) == .revealBubble)
|
|
}
|
|
|
|
@Test
|
|
func flatCardsKeepOnePriorityFirstReadingOrderOnEitherSide() {
|
|
func card(_ id: String) -> OverlayBubbleContent {
|
|
OverlayBubbleContent(
|
|
id: id,
|
|
source: .codex,
|
|
agentName: "Codex",
|
|
sessions: [OverlaySessionContent(
|
|
id: id,
|
|
source: .codex,
|
|
sessionID: id,
|
|
eventType: .tool,
|
|
sessionTitle: id,
|
|
messageText: "Summary",
|
|
statusText: "Running"
|
|
)],
|
|
isStandaloneSessionCard: true
|
|
)
|
|
}
|
|
|
|
let contents = [card("priority"), card("older")]
|
|
#expect(OverlayGeometry.visuallyOrderedBubbleContents(
|
|
contents,
|
|
anchorDirection: .above
|
|
).map(\.id) == ["priority", "older"])
|
|
#expect(OverlayGeometry.visuallyOrderedBubbleContents(
|
|
contents,
|
|
anchorDirection: .below
|
|
).map(\.id) == ["priority", "older"])
|
|
|
|
let grouped = OverlayBubbleContent(
|
|
id: "agent-codex",
|
|
source: .codex,
|
|
agentName: "Codex",
|
|
sessions: contents.flatMap(\.sessions)
|
|
)
|
|
#expect(OverlayGeometry.visuallyOrderedBubbleContents(
|
|
[grouped],
|
|
anchorDirection: .below
|
|
) == [grouped])
|
|
}
|
|
|
|
@Test
|
|
func menuTracksTheRightEdgeAtEverySupportedDisplayWidth() {
|
|
let petCenter = CGPoint(x: 420, y: 360)
|
|
|
|
for displayWidthPt in [
|
|
OverlayGeometry.minimumDisplayWidthPt,
|
|
OverlayGeometry.defaultDisplayWidthPt,
|
|
OverlayGeometry.maximumDisplayWidthPt,
|
|
] {
|
|
let menuCenter = OverlayGeometry.menuCenter(
|
|
petCenter: petCenter,
|
|
displayWidthPt: displayWidthPt
|
|
)
|
|
let petRect = OverlayGeometry.rect(
|
|
center: petCenter,
|
|
size: OverlayGeometry.petVisibleSize(displayWidthPt: displayWidthPt)
|
|
)
|
|
|
|
#expect(menuCenter.x > petRect.maxX)
|
|
#expect(abs(menuCenter.x - petRect.maxX - 8) < 0.001)
|
|
|
|
let screenMenuCenter = OverlayGeometry.menuScreenCenter(
|
|
petScreenCenter: petCenter,
|
|
displayWidthPt: displayWidthPt
|
|
)
|
|
#expect(abs(screenMenuCenter.x - menuCenter.x) < 0.001)
|
|
#expect(abs((screenMenuCenter.y - petCenter.y)
|
|
+ (menuCenter.y - petCenter.y)) < 0.001)
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func testCompactControlsStayClearOfBubblePanel() {
|
|
let displayWidthPt: CGFloat = 112
|
|
let petCenter = CGPoint(x: 900, y: 420)
|
|
let visibleFrame = CGRect(x: 0, y: 0, width: 1512, height: 934)
|
|
let bubbleSize = CGSize(width: OverlayGeometry.bubbleWidth, height: 76)
|
|
let bubbleCenter = OverlayGeometry.bubbleScreenCenter(
|
|
bubbleSize: bubbleSize,
|
|
displayWidthPt: displayWidthPt,
|
|
petScreenCenter: petCenter,
|
|
screenFrame: visibleFrame
|
|
)
|
|
let bubbleRect = OverlayGeometry.rect(center: bubbleCenter, size: bubbleSize)
|
|
let menuRect = OverlayGeometry.rect(
|
|
center: OverlayGeometry.menuScreenCenter(
|
|
petScreenCenter: petCenter,
|
|
displayWidthPt: displayWidthPt
|
|
),
|
|
size: OverlayGeometry.menuHitSize
|
|
)
|
|
#expect(!bubbleRect.intersects(menuRect))
|
|
|
|
let petTop = petCenter.y + OverlayGeometry.petVisualVerticalOffsets(
|
|
displayWidthPt: displayWidthPt,
|
|
envelope: nil
|
|
).top
|
|
#expect(abs(bubbleRect.minY - (petTop + OverlayGeometry.bubbleGap)) < 0.001)
|
|
}
|
|
|
|
@Test
|
|
func controlVisibilityUsesOnlyVisiblePetAndMenuRegions() {
|
|
let displayWidthPt: CGFloat = 112
|
|
let petCenter = CGPoint(x: 900, y: 420)
|
|
let outsidePoint = CGPoint(x: petCenter.x - 100, y: petCenter.y + 100)
|
|
|
|
#expect(!OverlayGeometry.shouldShowControls(
|
|
at: outsidePoint,
|
|
displayWidthPt: displayWidthPt,
|
|
petScreenCenter: petCenter,
|
|
clickMenuEnabled: true
|
|
))
|
|
#expect(OverlayGeometry.shouldShowControls(
|
|
at: petCenter,
|
|
displayWidthPt: displayWidthPt,
|
|
petScreenCenter: petCenter,
|
|
clickMenuEnabled: true
|
|
))
|
|
#expect(OverlayGeometry.shouldShowControls(
|
|
at: OverlayGeometry.menuScreenCenter(
|
|
petScreenCenter: petCenter,
|
|
displayWidthPt: displayWidthPt
|
|
),
|
|
displayWidthPt: displayWidthPt,
|
|
petScreenCenter: petCenter,
|
|
clickMenuEnabled: true
|
|
))
|
|
}
|
|
|
|
@Test
|
|
func testPointerRegionsTrackTheActualPetEnvelope() {
|
|
let displayWidthPt: CGFloat = 112
|
|
let petCenter = CGPoint(x: 500, y: 400)
|
|
let envelope = OverlayPetVisualEnvelope(
|
|
canvasSize: CGSize(width: 1_000, height: 1_000),
|
|
visibleBounds: CGRect(x: 700, y: 200, width: 120, height: 500)
|
|
)
|
|
let visualRect = OverlayGeometry.petVisualScreenRect(
|
|
displayWidthPt: displayWidthPt,
|
|
petScreenCenter: petCenter,
|
|
petVisualEnvelope: envelope
|
|
)
|
|
let menuCenter = OverlayGeometry.menuScreenCenter(
|
|
petScreenCenter: petCenter,
|
|
displayWidthPt: displayWidthPt,
|
|
petVisualEnvelope: envelope
|
|
)
|
|
#expect(visualRect.midX > petCenter.x)
|
|
#expect(menuCenter.x > visualRect.maxX)
|
|
#expect(OverlayGeometry.shouldShowControls(
|
|
at: CGPoint(x: visualRect.midX, y: visualRect.midY),
|
|
displayWidthPt: displayWidthPt,
|
|
petScreenCenter: petCenter,
|
|
clickMenuEnabled: true,
|
|
petVisualEnvelope: envelope
|
|
))
|
|
}
|
|
|
|
@Test
|
|
func transparentPixelsInsideThePetDragRegionPassThroughWhileOpaquePixelsHandle() throws {
|
|
let mask = try #require(OverlayPetAlphaMask(
|
|
pixelWidth: 3,
|
|
pixelHeight: 3,
|
|
alphaValuesTopToBottom: [
|
|
255, 255, 255,
|
|
255, 0, 255,
|
|
255, 255, 255,
|
|
]
|
|
))
|
|
let hitTest = OverlayPetFrameHitTest(
|
|
canvasSize: CGSize(width: 3, height: 3),
|
|
alphaMask: mask
|
|
)
|
|
let containerSize = CGSize(width: 800, height: 600)
|
|
let petCenter = CGPoint(x: 400, y: 300)
|
|
let panelFrame = CGRect(origin: .zero, size: containerSize)
|
|
|
|
func handles(_ point: CGPoint, hitTest candidate: OverlayPetFrameHitTest?) -> Bool {
|
|
OverlayGeometry.shouldHandleMouse(
|
|
atTopLeftPoint: point,
|
|
in: containerSize,
|
|
displayWidthPt: 112,
|
|
petCenter: petCenter,
|
|
bubbleVisible: false,
|
|
clickMenuEnabled: true,
|
|
panelFrame: panelFrame,
|
|
screenFrame: panelFrame,
|
|
includeBubble: false,
|
|
petFrameHitTest: candidate
|
|
)
|
|
}
|
|
|
|
#expect(!handles(petCenter, hitTest: hitTest))
|
|
#expect(handles(CGPoint(x: petCenter.x - 36, y: petCenter.y), hitTest: hitTest))
|
|
|
|
// A frame mask is briefly unavailable during launch and state changes.
|
|
// Keep the geometric pet region interactive until pixel data arrives.
|
|
#expect(handles(petCenter, hitTest: nil))
|
|
#expect(handles(
|
|
OverlayGeometry.menuCenter(petCenter: petCenter, displayWidthPt: 112),
|
|
hitTest: nil
|
|
))
|
|
}
|
|
|
|
@Test
|
|
func pointerOwnershipPolicyHasOneExplicitPriorityOrder() {
|
|
let interactionID = UUID()
|
|
let base = OverlayPointerOwnershipInput(
|
|
overlayVisible: true,
|
|
activeInteractionID: nil,
|
|
maskState: .valid,
|
|
validMaskPixelIsOpaque: false,
|
|
pointerInBubble: false,
|
|
pointerInMenu: false,
|
|
pointerInGeometricPetRegion: true
|
|
)
|
|
#expect(OverlayPointerOwnershipPolicy.resolve(base) == .passthrough)
|
|
|
|
var opaque = base
|
|
opaque.validMaskPixelIsOpaque = true
|
|
#expect(OverlayPointerOwnershipPolicy.resolve(opaque) == .pet)
|
|
|
|
for fallback in [OverlayPointerMaskState.missing, .stale] {
|
|
var input = base
|
|
input.maskState = fallback
|
|
#expect(OverlayPointerOwnershipPolicy.resolve(input) == .pet)
|
|
}
|
|
|
|
var auxiliary = base
|
|
auxiliary.pointerInGeometricPetRegion = false
|
|
auxiliary.pointerInBubble = true
|
|
#expect(
|
|
OverlayPointerOwnershipPolicy.resolve(auxiliary)
|
|
== .auxiliarySurface
|
|
)
|
|
|
|
var active = base
|
|
active.activeInteractionID = interactionID
|
|
#expect(
|
|
OverlayPointerOwnershipPolicy.resolve(active)
|
|
== .activeLease(interactionID)
|
|
)
|
|
active.overlayVisible = false
|
|
#expect(
|
|
OverlayPointerOwnershipPolicy.resolve(active) == .passthrough
|
|
)
|
|
}
|
|
|
|
@Test
|
|
func pointerMonitorUsesOnlyPermissionFreeLocalEventsAndPolling() throws {
|
|
let sourceDirectory = URL(fileURLWithPath: #filePath)
|
|
.deletingLastPathComponent()
|
|
.deletingLastPathComponent()
|
|
.deletingLastPathComponent()
|
|
.appendingPathComponent("Sources/AgentPetCompanion", isDirectory: true)
|
|
let controllerSource = try String(
|
|
contentsOf: sourceDirectory.appendingPathComponent(
|
|
"Overlay/PetOverlayController.swift"
|
|
),
|
|
encoding: .utf8
|
|
)
|
|
let appStoreSource = try String(
|
|
contentsOf: sourceDirectory.appendingPathComponent(
|
|
"App/AppStore.swift"
|
|
),
|
|
encoding: .utf8
|
|
)
|
|
let rootViewSource = try String(
|
|
contentsOf: sourceDirectory.appendingPathComponent(
|
|
"Overlay/OverlayRootView.swift"
|
|
),
|
|
encoding: .utf8
|
|
)
|
|
let behaviorSettingsSource = try String(
|
|
contentsOf: sourceDirectory.appendingPathComponent(
|
|
"Views/BehaviorSettingsView.swift"
|
|
),
|
|
encoding: .utf8
|
|
)
|
|
let monitorStart = try #require(controllerSource.range(
|
|
of: "final class OverlayPointerEventMonitor"
|
|
))
|
|
let monitorEnd = try #require(controllerSource.range(
|
|
of: "private final class PassthroughOverlayHostingView",
|
|
range: monitorStart.upperBound..<controllerSource.endIndex
|
|
))
|
|
let monitorSource = controllerSource[monitorStart.lowerBound..<monitorEnd.lowerBound]
|
|
#expect(monitorSource.contains("Timer(timeInterval: Self.pollingInterval"))
|
|
#expect(monitorSource.contains("NSEvent.addLocalMonitorForEvents"))
|
|
#expect(!monitorSource.contains("CGEvent.tapCreate"))
|
|
#expect(!monitorSource.contains("NSEvent.addGlobalMonitorForEvents"))
|
|
#expect(!monitorSource.contains(".keyDown"))
|
|
#expect(!monitorSource.contains(".keyUp"))
|
|
#expect(!monitorSource.contains(".flagsChanged"))
|
|
#expect(monitorSource.contains(".leftMouseDown"))
|
|
#expect(!monitorSource.contains("Task { @MainActor"))
|
|
#expect(!controllerSource.contains("NSEvent.pressedMouseButtons"))
|
|
#expect(!appStoreSource.contains("NSEvent.pressedMouseButtons"))
|
|
#expect(!controllerSource.contains("NSEvent.mouseLocation"))
|
|
#expect(!rootViewSource.contains("NSEvent.mouseLocation"))
|
|
#expect(!behaviorSettingsSource.contains("NSEvent.modifierFlags"))
|
|
#expect(controllerSource.contains("mouseLocationOutsideOfEventStream"))
|
|
#expect(controllerSource.contains(
|
|
"observeOverlayPrimaryButton(isDown: false)"
|
|
))
|
|
#expect(!controllerSource.contains("pointerNearPetScreenRect"))
|
|
#expect(!controllerSource.contains("guard !isKeyWindow"))
|
|
#expect(!controllerSource.contains("behavior.mousePassthrough"))
|
|
}
|
|
|
|
@Test
|
|
func fiveHundredFirstPressAndMaskTransitionSequencesNeverLoseActiveLease() {
|
|
for index in 0 ..< 500 {
|
|
let interactionID = UUID()
|
|
let initialMask: OverlayPointerMaskState = switch index % 3 {
|
|
case 0: .missing
|
|
case 1: .stale
|
|
default: .valid
|
|
}
|
|
let first = OverlayPointerOwnershipPolicy.resolve(
|
|
OverlayPointerOwnershipInput(
|
|
overlayVisible: true,
|
|
activeInteractionID: nil,
|
|
maskState: initialMask,
|
|
validMaskPixelIsOpaque: initialMask == .valid,
|
|
pointerInBubble: false,
|
|
pointerInMenu: false,
|
|
pointerInGeometricPetRegion: true
|
|
)
|
|
)
|
|
#expect(first.isOwnedByOverlay)
|
|
|
|
let transitioned = OverlayPointerOwnershipPolicy.resolve(
|
|
OverlayPointerOwnershipInput(
|
|
overlayVisible: true,
|
|
activeInteractionID: interactionID,
|
|
maskState: index.isMultiple(of: 2) ? .valid : .stale,
|
|
validMaskPixelIsOpaque: false,
|
|
pointerInBubble: false,
|
|
pointerInMenu: false,
|
|
pointerInGeometricPetRegion: false
|
|
)
|
|
)
|
|
#expect(transitioned == .activeLease(interactionID))
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func alphaHitTestingUsesMetalAspectFitScaleAndTopLeftViewConversion() throws {
|
|
let mask = try #require(OverlayPetAlphaMask(
|
|
pixelWidth: 2,
|
|
pixelHeight: 2,
|
|
alphaValuesTopToBottom: [
|
|
255, 0,
|
|
0, 0,
|
|
]
|
|
))
|
|
let hitTest = OverlayPetFrameHitTest(
|
|
// Exercise the renderer's horizontal centering for a frame that
|
|
// is narrower than the animation's stable canvas.
|
|
canvasSize: CGSize(width: 4, height: 2),
|
|
alphaMask: mask
|
|
)
|
|
let petCenter = CGPoint(x: 360, y: 280)
|
|
let viewHeight: CGFloat = 700
|
|
|
|
func topLeftPointForPixel(
|
|
x: Int,
|
|
topRow: Int,
|
|
displayWidthPt: CGFloat
|
|
) -> CGPoint {
|
|
let drawableSize = OverlayGeometry.petVisibleSize(
|
|
displayWidthPt: displayWidthPt
|
|
)
|
|
let fittedScale = min(drawableSize.width / 4, drawableSize.height / 2)
|
|
let fittedOrigin = CGPoint(
|
|
x: (drawableSize.width - 4 * fittedScale) / 2,
|
|
y: (drawableSize.height - 2 * fittedScale) / 2
|
|
)
|
|
let bottomRow = 1 - topRow
|
|
let localBottomLeft = CGPoint(
|
|
x: fittedOrigin.x + (1 + CGFloat(x) + 0.5) * fittedScale,
|
|
y: fittedOrigin.y + (CGFloat(bottomRow) + 0.5) * fittedScale
|
|
)
|
|
return CGPoint(
|
|
x: petCenter.x - drawableSize.width / 2 + localBottomLeft.x,
|
|
y: petCenter.y + drawableSize.height / 2 - localBottomLeft.y
|
|
)
|
|
}
|
|
|
|
for displayWidthPt in [CGFloat(100), CGFloat(300)] {
|
|
let opaqueTopLeft = topLeftPointForPixel(
|
|
x: 0,
|
|
topRow: 0,
|
|
displayWidthPt: displayWidthPt
|
|
)
|
|
let transparentBottomLeft = topLeftPointForPixel(
|
|
x: 0,
|
|
topRow: 1,
|
|
displayWidthPt: displayWidthPt
|
|
)
|
|
|
|
#expect(OverlayGeometry.petFrameContainsOpaquePixel(
|
|
atTopLeftPoint: opaqueTopLeft,
|
|
displayWidthPt: displayWidthPt,
|
|
petCenter: petCenter,
|
|
frameHitTest: hitTest
|
|
))
|
|
#expect(!OverlayGeometry.petFrameContainsOpaquePixel(
|
|
atTopLeftPoint: transparentBottomLeft,
|
|
displayWidthPt: displayWidthPt,
|
|
petCenter: petCenter,
|
|
frameHitTest: hitTest
|
|
))
|
|
|
|
let flippedPoint = OverlayGeometry.topLeftPoint(
|
|
forViewPoint: opaqueTopLeft,
|
|
in: viewHeight,
|
|
isFlipped: true
|
|
)
|
|
let unflippedPoint = OverlayGeometry.topLeftPoint(
|
|
forViewPoint: CGPoint(
|
|
x: opaqueTopLeft.x,
|
|
y: viewHeight - opaqueTopLeft.y
|
|
),
|
|
in: viewHeight,
|
|
isFlipped: false
|
|
)
|
|
#expect(flippedPoint == opaqueTopLeft)
|
|
#expect(unflippedPoint == opaqueTopLeft)
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func transparentPetPixelsAlwaysPassThrough() throws {
|
|
let transparentMask = try #require(OverlayPetAlphaMask(
|
|
pixelWidth: 1,
|
|
pixelHeight: 1,
|
|
alphaValuesTopToBottom: [0]
|
|
))
|
|
let containerSize = CGSize(width: 800, height: 600)
|
|
let panelFrame = CGRect(origin: .zero, size: containerSize)
|
|
|
|
#expect(!OverlayGeometry.shouldHandleMouse(
|
|
atTopLeftPoint: CGPoint(x: 40, y: 40),
|
|
in: containerSize,
|
|
displayWidthPt: 112,
|
|
petCenter: CGPoint(x: 400, y: 300),
|
|
bubbleVisible: false,
|
|
clickMenuEnabled: false,
|
|
panelFrame: panelFrame,
|
|
screenFrame: panelFrame,
|
|
includeBubble: false,
|
|
petFrameHitTest: OverlayPetFrameHitTest(
|
|
canvasSize: CGSize(width: 1, height: 1),
|
|
alphaMask: transparentMask
|
|
)
|
|
))
|
|
}
|
|
|
|
@Test
|
|
func testBubbleTracksSizedVisiblePetTopForDifferentActionEnvelopes() {
|
|
let petCenter = CGPoint(x: 900, y: 420)
|
|
let visibleFrame = CGRect(x: 0, y: 0, width: 1512, height: 934)
|
|
let bubbleSize = CGSize(width: 344, height: 76)
|
|
let shortAction = OverlayPetVisualEnvelope(
|
|
canvasSize: CGSize(width: 384, height: 416),
|
|
visibleBounds: CGRect(x: 60, y: 30, width: 264, height: 270)
|
|
)
|
|
let tallAction = OverlayPetVisualEnvelope(
|
|
canvasSize: CGSize(width: 384, height: 416),
|
|
visibleBounds: CGRect(x: 48, y: 24, width: 288, height: 360)
|
|
)
|
|
|
|
func bubbleBottom(
|
|
displayWidthPt: CGFloat,
|
|
envelope: OverlayPetVisualEnvelope
|
|
) -> CGFloat {
|
|
let center = OverlayGeometry.bubbleScreenCenter(
|
|
bubbleSize: bubbleSize,
|
|
displayWidthPt: displayWidthPt,
|
|
petScreenCenter: petCenter,
|
|
screenFrame: visibleFrame,
|
|
petVisualEnvelope: envelope
|
|
)
|
|
return center.y - bubbleSize.height / 2
|
|
}
|
|
|
|
for displayWidthPt: CGFloat in [100, 112, 160] {
|
|
let offsets = OverlayGeometry.petVisualVerticalOffsets(
|
|
displayWidthPt: displayWidthPt,
|
|
envelope: tallAction
|
|
)
|
|
let bubbleCenter = OverlayGeometry.bubbleScreenCenter(
|
|
bubbleSize: bubbleSize,
|
|
displayWidthPt: displayWidthPt,
|
|
petScreenCenter: petCenter,
|
|
screenFrame: visibleFrame,
|
|
petVisualEnvelope: tallAction
|
|
)
|
|
let bubbleRect = OverlayGeometry.rect(center: bubbleCenter, size: bubbleSize)
|
|
let menuRect = OverlayGeometry.rect(
|
|
center: OverlayGeometry.menuScreenCenter(
|
|
petScreenCenter: petCenter,
|
|
displayWidthPt: displayWidthPt,
|
|
petVisualEnvelope: tallAction
|
|
),
|
|
size: OverlayGeometry.menuHitSize
|
|
)
|
|
#expect(abs(
|
|
bubbleBottom(displayWidthPt: displayWidthPt, envelope: tallAction)
|
|
- (petCenter.y + offsets.top + OverlayGeometry.bubbleGap)
|
|
) < 0.001)
|
|
#expect(!bubbleRect.intersects(menuRect))
|
|
#expect(bubbleRect.minY - menuRect.maxY >= OverlayGeometry.bubbleGap)
|
|
#expect(
|
|
bubbleBottom(displayWidthPt: displayWidthPt, envelope: tallAction)
|
|
> bubbleBottom(displayWidthPt: displayWidthPt, envelope: shortAction)
|
|
)
|
|
}
|
|
|
|
let smallOffset = bubbleBottom(displayWidthPt: 100, envelope: tallAction)
|
|
- petCenter.y
|
|
let largeOffset = bubbleBottom(displayWidthPt: 300, envelope: tallAction)
|
|
- petCenter.y
|
|
#expect(largeOffset > smallOffset)
|
|
}
|
|
|
|
@Test
|
|
func testBubblePrefersPetTopEvenWhenBothVerticalSidesFit() {
|
|
let visibleFrame = CGRect(x: 0, y: 0, width: 1512, height: 934)
|
|
let petCenter = CGPoint(x: 900, y: 650)
|
|
let bubbleSize = CGSize(width: 344, height: 117)
|
|
let envelope = OverlayPetVisualEnvelope(
|
|
canvasSize: CGSize(width: 192, height: 208),
|
|
visibleBounds: CGRect(x: 14, y: 31, width: 174, height: 142)
|
|
)
|
|
let petTop = petCenter.y + OverlayGeometry.petVisualVerticalOffsets(
|
|
displayWidthPt: 300,
|
|
envelope: envelope
|
|
).top
|
|
let bubbleCenter = OverlayGeometry.bubbleScreenCenter(
|
|
bubbleSize: bubbleSize,
|
|
displayWidthPt: 300,
|
|
petScreenCenter: petCenter,
|
|
screenFrame: visibleFrame,
|
|
petVisualEnvelope: envelope
|
|
)
|
|
let bubbleRect = OverlayGeometry.rect(center: bubbleCenter, size: bubbleSize)
|
|
|
|
#expect(abs(bubbleRect.minY - (petTop + OverlayGeometry.bubbleGap)) < 0.001)
|
|
}
|
|
|
|
@Test
|
|
func bubbleWidthAndAnchorStayBoundedAcrossPetAndSessionMatrix() {
|
|
func content(sessionCount: Int, language: String) -> OverlayBubbleContent {
|
|
let sessions = (0 ..< sessionCount).map { index in
|
|
OverlaySessionContent(
|
|
id: "\(language)-\(index)",
|
|
source: .codex,
|
|
sessionID: "session-\(index)",
|
|
eventType: index.isMultiple(of: 3) ? .waiting : .tool,
|
|
sessionTitle: language == "zh"
|
|
? String(repeating: "长标题", count: 20)
|
|
: String(repeating: "Long session title ", count: 10),
|
|
messageText: language == "zh"
|
|
? String(repeating: "等待用户输入并继续处理。", count: 20)
|
|
: String(repeating: "Waiting for bounded user input. ", count: 20),
|
|
statusText: "",
|
|
navigation: AgentSessionNavigation()
|
|
)
|
|
}
|
|
return OverlayBubbleContent(
|
|
id: "content-\(language)-\(sessionCount)",
|
|
source: .codex,
|
|
agentName: "Codex",
|
|
sessions: sessions,
|
|
isExpanded: true
|
|
)
|
|
}
|
|
|
|
let ordinary = OverlayGeometry.resolvedBubbleSize(
|
|
in: CGSize(width: 1_200, height: 800)
|
|
)
|
|
#expect(ordinary.width == 344)
|
|
#expect(OverlayGeometry.resolvedBubbleSize(
|
|
in: CGSize(width: 352, height: 800)
|
|
).width == 320)
|
|
#expect(OverlayGeometry.resolvedBubbleSize(
|
|
in: CGSize(width: 240, height: 800)
|
|
).width == 208)
|
|
|
|
let visibleFrame = CGRect(x: -400, y: 24, width: 1_200, height: 760)
|
|
let centers = [
|
|
CGPoint(x: visibleFrame.midX, y: visibleFrame.midY),
|
|
CGPoint(x: visibleFrame.minX + 80, y: visibleFrame.midY),
|
|
CGPoint(x: visibleFrame.maxX - 80, y: visibleFrame.midY),
|
|
CGPoint(x: visibleFrame.midX, y: visibleFrame.maxY - 80),
|
|
CGPoint(x: visibleFrame.midX, y: visibleFrame.minY + 80),
|
|
]
|
|
for petWidth: CGFloat in [100, 112, 300] {
|
|
for sessionCount in [1, 2, 8] {
|
|
for language in ["zh", "en"] {
|
|
for center in centers {
|
|
let layout = OverlayGeometry.bubblePanelLayout(
|
|
displayWidthPt: petWidth,
|
|
petScreenCenter: center,
|
|
visibleFrame: visibleFrame,
|
|
contents: [content(
|
|
sessionCount: sessionCount,
|
|
language: language
|
|
)],
|
|
previousAnchor: nil
|
|
)
|
|
// Horizontal placement is the part that adapts to an
|
|
// edge, so the bubble is always within the horizontal
|
|
// span. Vertically it keeps its authored distance from
|
|
// the pet, so it is fully on screen exactly when one
|
|
// side of the pet has room for it.
|
|
#expect(layout.frame.minX >= visibleFrame.minX)
|
|
#expect(layout.frame.maxX <= visibleFrame.maxX)
|
|
let offsets = OverlayGeometry.petVisualVerticalOffsets(
|
|
displayWidthPt: petWidth,
|
|
envelope: nil
|
|
)
|
|
let safeFrame = visibleFrame.insetBy(dx: 8, dy: 8)
|
|
let roomAbove = safeFrame.maxY
|
|
- (center.y + offsets.top + OverlayGeometry.bubbleGap)
|
|
let roomBelow = (center.y + offsets.bottom
|
|
- OverlayGeometry.bubbleGap) - safeFrame.minY
|
|
if max(roomAbove, roomBelow) >= layout.frame.height {
|
|
#expect(layout.frame.minY >= visibleFrame.minY)
|
|
#expect(layout.frame.maxY <= visibleFrame.maxY)
|
|
}
|
|
#expect(layout.frame.width >= 304)
|
|
#expect(layout.frame.width <= 360)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func previousLegalBubbleAnchorIsStableUntilItNoLongerFits() {
|
|
let visibleFrame = CGRect(x: 0, y: 0, width: 1_200, height: 800)
|
|
let bubbleSize = CGSize(width: 344, height: 120)
|
|
for direction in OverlayBubbleAnchorDirection.allCases {
|
|
let stable = OverlayGeometry.bubblePlacement(
|
|
bubbleSize: bubbleSize,
|
|
displayWidthPt: 112,
|
|
petScreenCenter: CGPoint(x: 600, y: 400),
|
|
screenFrame: visibleFrame,
|
|
previousAnchor: OverlayBubbleAnchor(
|
|
direction: direction,
|
|
alignsLeft: false
|
|
)
|
|
)
|
|
#expect(stable.anchor.direction == direction)
|
|
}
|
|
|
|
// A pet pinned under the top edge has no room above it, so the bubble
|
|
// moves to the other side of the pet rather than closing the gap.
|
|
let moved = OverlayGeometry.bubblePlacement(
|
|
bubbleSize: bubbleSize,
|
|
displayWidthPt: 112,
|
|
petScreenCenter: CGPoint(x: 600, y: 760),
|
|
screenFrame: visibleFrame,
|
|
previousAnchor: OverlayBubbleAnchor(
|
|
direction: .above,
|
|
alignsLeft: false
|
|
)
|
|
)
|
|
#expect(moved.anchor.direction == .below)
|
|
#expect(visibleFrame.contains(OverlayGeometry.rect(
|
|
center: moved.center,
|
|
size: bubbleSize
|
|
)))
|
|
}
|
|
|
|
@Test
|
|
func testSideControlsTrackVisiblePetRightEdgeInsteadOfTransparentCanvas() {
|
|
let petCenter = CGPoint(x: 700, y: 420)
|
|
let envelope = OverlayPetVisualEnvelope(
|
|
canvasSize: CGSize(width: 384, height: 416),
|
|
visibleBounds: CGRect(x: 54, y: 20, width: 252, height: 370)
|
|
)
|
|
|
|
for displayWidthPt: CGFloat in [100, 112, 300] {
|
|
let fallbackMenu = OverlayGeometry.menuScreenCenter(
|
|
petScreenCenter: petCenter,
|
|
displayWidthPt: displayWidthPt
|
|
)
|
|
let fittedMenu = OverlayGeometry.menuScreenCenter(
|
|
petScreenCenter: petCenter,
|
|
displayWidthPt: displayWidthPt,
|
|
petVisualEnvelope: envelope
|
|
)
|
|
|
|
#expect(fittedMenu.x < fallbackMenu.x)
|
|
}
|
|
|
|
let smallInset = OverlayGeometry.menuScreenCenter(
|
|
petScreenCenter: petCenter,
|
|
displayWidthPt: 100
|
|
).x - OverlayGeometry.menuScreenCenter(
|
|
petScreenCenter: petCenter,
|
|
displayWidthPt: 100,
|
|
petVisualEnvelope: envelope
|
|
).x
|
|
let largeInset = OverlayGeometry.menuScreenCenter(
|
|
petScreenCenter: petCenter,
|
|
displayWidthPt: 300
|
|
).x - OverlayGeometry.menuScreenCenter(
|
|
petScreenCenter: petCenter,
|
|
displayWidthPt: 300,
|
|
petVisualEnvelope: envelope
|
|
).x
|
|
#expect(largeInset > smallInset)
|
|
}
|
|
|
|
@Test
|
|
func testPetAndControlsStayInsideEachMovementEdgeAtEverySupportedSize() {
|
|
let displays = [
|
|
CGRect(x: 0.001, y: 25.002, width: 1511.997, height: 919.995),
|
|
CGRect(x: -1279.999, y: 0.003, width: 1279.998, height: 759.994)
|
|
]
|
|
let displayWidths: [CGFloat] = [100, 112, 300]
|
|
|
|
for visibleFrame in displays {
|
|
for displayWidthPt in displayWidths {
|
|
let proposals = [
|
|
CGPoint(x: visibleFrame.minX, y: visibleFrame.minY),
|
|
CGPoint(x: visibleFrame.maxX, y: visibleFrame.minY),
|
|
CGPoint(x: visibleFrame.minX, y: visibleFrame.maxY),
|
|
CGPoint(x: visibleFrame.maxX, y: visibleFrame.maxY)
|
|
]
|
|
|
|
for proposal in proposals {
|
|
let center = OverlayGeometry.clampedPetScreenCenter(
|
|
proposal,
|
|
displayWidthPt: displayWidthPt,
|
|
visibleFrame: visibleFrame,
|
|
clickMenuEnabled: true
|
|
)
|
|
let bounds = OverlayGeometry.petMovementScreenBounds(
|
|
displayWidthPt: displayWidthPt,
|
|
petScreenCenter: center,
|
|
clickMenuEnabled: true
|
|
)
|
|
#expect(
|
|
visibleFrame.insetBy(dx: -0.5, dy: -0.5).contains(bounds),
|
|
"movement bounds \(bounds) escaped \(visibleFrame) at \(displayWidthPt) pt"
|
|
)
|
|
#expect(center.x * 256 == (center.x * 256).rounded())
|
|
#expect(center.y * 256 == (center.y * 256).rounded())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func testClampIncludesVisiblePetAndMenuHitTarget() {
|
|
let visibleFrame = CGRect(x: 0, y: 0, width: 800, height: 600)
|
|
let center = OverlayGeometry.clampedPetScreenCenter(
|
|
CGPoint(x: visibleFrame.maxX, y: visibleFrame.minY),
|
|
displayWidthPt: 112,
|
|
visibleFrame: visibleFrame,
|
|
clickMenuEnabled: true
|
|
)
|
|
|
|
let completeBounds = OverlayGeometry.petMovementScreenBounds(
|
|
displayWidthPt: 112,
|
|
petScreenCenter: center,
|
|
clickMenuEnabled: true
|
|
)
|
|
let menuBounds = OverlayGeometry.rect(
|
|
center: OverlayGeometry.menuScreenCenter(
|
|
petScreenCenter: center,
|
|
displayWidthPt: 112
|
|
),
|
|
size: OverlayGeometry.menuHitSize
|
|
)
|
|
|
|
#expect(completeBounds.contains(menuBounds))
|
|
#expect(visibleFrame.insetBy(dx: -0.5, dy: -0.5).contains(completeBounds))
|
|
}
|
|
|
|
@Test
|
|
func testMovementFrameAllowsDockAreaButProtectsMenuBar() {
|
|
let screenFrame = CGRect(x: 0, y: 0, width: 1728, height: 1117)
|
|
let visibleFrame = CGRect(x: 0, y: 60, width: 1728, height: 1024)
|
|
|
|
let movementFrame = OverlayGeometry.petMovementFrame(
|
|
screenFrame: screenFrame,
|
|
visibleFrame: visibleFrame
|
|
)
|
|
|
|
#expect(movementFrame.minX == screenFrame.minX)
|
|
#expect(movementFrame.maxX == screenFrame.maxX)
|
|
#expect(movementFrame.minY == screenFrame.minY)
|
|
#expect(movementFrame.maxY == visibleFrame.maxY)
|
|
}
|
|
|
|
@Test
|
|
func testBottomClampUsesActualPetPixelsInsteadOfTransparentCanvas() {
|
|
let screenFrame = CGRect(x: 0, y: 0, width: 1728, height: 1117)
|
|
let visibleFrame = CGRect(x: 0, y: 60, width: 1728, height: 1024)
|
|
let movementFrame = OverlayGeometry.petMovementFrame(
|
|
screenFrame: screenFrame,
|
|
visibleFrame: visibleFrame
|
|
)
|
|
let envelope = OverlayPetVisualEnvelope(
|
|
canvasSize: CGSize(width: 384, height: 416),
|
|
visibleBounds: CGRect(x: 54, y: 74, width: 270, height: 286)
|
|
)
|
|
|
|
let center = OverlayGeometry.clampedPetScreenCenter(
|
|
CGPoint(x: 900, y: -1_000),
|
|
displayWidthPt: 136,
|
|
visibleFrame: movementFrame,
|
|
clickMenuEnabled: true,
|
|
petVisualEnvelope: envelope
|
|
)
|
|
let movementBounds = OverlayGeometry.petMovementScreenBounds(
|
|
displayWidthPt: 136,
|
|
petScreenCenter: center,
|
|
clickMenuEnabled: true,
|
|
petVisualEnvelope: envelope
|
|
)
|
|
let oldVisibleFrameCenter = OverlayGeometry.clampedPetScreenCenter(
|
|
CGPoint(x: 900, y: -1_000),
|
|
displayWidthPt: 136,
|
|
visibleFrame: visibleFrame,
|
|
clickMenuEnabled: true
|
|
)
|
|
|
|
#expect(movementBounds.minY >= 1)
|
|
#expect(
|
|
movementBounds.minY - 1
|
|
< CGFloat(OverlayPlacementCanonicalization.quantumPt)
|
|
)
|
|
#expect(center.y < oldVisibleFrameCenter.y - 50)
|
|
}
|
|
|
|
@Test
|
|
func testActualPetEnvelopeClampsEveryEdgeAcrossDisplayLayouts() {
|
|
let displays = [
|
|
OverlayDisplayGeometry(
|
|
frame: CGRect(x: 0, y: 0, width: 1728, height: 1117),
|
|
visibleFrame: CGRect(x: 0, y: 60, width: 1728, height: 1024),
|
|
backingScaleFactor: 2
|
|
),
|
|
OverlayDisplayGeometry(
|
|
frame: CGRect(x: -1440, y: 0, width: 1440, height: 900),
|
|
visibleFrame: CGRect(x: -1370, y: 0, width: 1370, height: 875),
|
|
backingScaleFactor: 1
|
|
)
|
|
]
|
|
let envelope = OverlayPetVisualEnvelope(
|
|
canvasSize: CGSize(width: 384, height: 416),
|
|
visibleBounds: CGRect(x: 42, y: 58, width: 286, height: 318)
|
|
)
|
|
|
|
for display in displays {
|
|
let movementFrame = OverlayGeometry.petMovementFrame(
|
|
screenFrame: display.frame,
|
|
visibleFrame: display.visibleFrame
|
|
)
|
|
// Bottom and side Dock reservations are traversable; only the
|
|
// menu-bar strip at the top remains protected.
|
|
#expect(movementFrame.minX == display.frame.minX)
|
|
#expect(movementFrame.maxX == display.frame.maxX)
|
|
#expect(movementFrame.minY == display.frame.minY)
|
|
#expect(movementFrame.maxY == display.visibleFrame.maxY)
|
|
|
|
for displayWidthPt in [
|
|
OverlayGeometry.minimumDisplayWidthPt,
|
|
OverlayGeometry.defaultDisplayWidthPt,
|
|
OverlayGeometry.maximumDisplayWidthPt,
|
|
] {
|
|
let proposals = [
|
|
CGPoint(x: movementFrame.minX - 2_000, y: movementFrame.midY),
|
|
CGPoint(x: movementFrame.maxX + 2_000, y: movementFrame.midY),
|
|
CGPoint(x: movementFrame.midX, y: movementFrame.minY - 2_000),
|
|
CGPoint(x: movementFrame.midX, y: movementFrame.maxY + 2_000),
|
|
CGPoint(x: movementFrame.minX - 2_000, y: movementFrame.minY - 2_000),
|
|
CGPoint(x: movementFrame.maxX + 2_000, y: movementFrame.maxY + 2_000)
|
|
]
|
|
|
|
for proposal in proposals {
|
|
let center = OverlayGeometry.clampedPetScreenCenter(
|
|
proposal,
|
|
displayWidthPt: displayWidthPt,
|
|
visibleFrame: movementFrame,
|
|
clickMenuEnabled: true,
|
|
petVisualEnvelope: envelope
|
|
)
|
|
let bounds = OverlayGeometry.petMovementScreenBounds(
|
|
displayWidthPt: displayWidthPt,
|
|
petScreenCenter: center,
|
|
clickMenuEnabled: true,
|
|
petVisualEnvelope: envelope
|
|
)
|
|
#expect(
|
|
movementFrame.insetBy(dx: -0.5, dy: -0.5).contains(bounds),
|
|
"actual pet/control bounds \(bounds) escaped \(movementFrame) at \(displayWidthPt) pt"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func testDragCanCrossBetweenDisplaysUsingCurrentPointer() {
|
|
let primary = OverlayDisplayGeometry(
|
|
frame: CGRect(x: 0, y: 0, width: 1440, height: 900),
|
|
visibleFrame: CGRect(x: 0, y: 25, width: 1440, height: 875),
|
|
backingScaleFactor: 2
|
|
)
|
|
let secondary = OverlayDisplayGeometry(
|
|
frame: CGRect(x: -1280, y: 0, width: 1280, height: 800),
|
|
visibleFrame: CGRect(x: -1280, y: 0, width: 1280, height: 775),
|
|
backingScaleFactor: 1
|
|
)
|
|
|
|
let target = OverlayGeometry.dragTargetDisplay(
|
|
pointer: CGPoint(x: -640, y: 400),
|
|
proposedPetCenter: CGPoint(x: 20, y: 400),
|
|
displays: [primary, secondary],
|
|
fallback: primary
|
|
)
|
|
|
|
#expect(target == secondary)
|
|
}
|
|
|
|
@Test
|
|
func displayWidthClampsToTheContract() {
|
|
#expect(OverlayGeometry.clampedDisplayWidthPt(20)
|
|
== OverlayGeometry.minimumDisplayWidthPt)
|
|
#expect(OverlayGeometry.clampedDisplayWidthPt(400)
|
|
== OverlayGeometry.maximumDisplayWidthPt)
|
|
#expect(OverlayGeometry.clampedDisplayWidthPt(80) == 100)
|
|
#expect(OverlayGeometry.clampedDisplayWidthPt(100) == 100)
|
|
#expect(OverlayGeometry.clampedDisplayWidthPt(300) == 300)
|
|
#expect(OverlayGeometry.clampedDisplayWidthPt(301) == 300)
|
|
#expect(OverlayGeometry.clampedDisplayWidthPt(.nan)
|
|
== OverlayGeometry.defaultDisplayWidthPt)
|
|
}
|
|
|
|
@Test
|
|
func overlayControlVisibilityTracksHoverDragAndKeyboardFocus() {
|
|
#expect(!OverlayControlVisibility.isVisible(
|
|
pointerNearPet: false,
|
|
petDragInProgress: false
|
|
))
|
|
#expect(OverlayControlVisibility.isVisible(
|
|
pointerNearPet: true,
|
|
petDragInProgress: false
|
|
))
|
|
#expect(OverlayControlVisibility.isVisible(
|
|
pointerNearPet: false,
|
|
petDragInProgress: true
|
|
))
|
|
#expect(OverlayControlVisibility.isVisible(
|
|
pointerNearPet: false,
|
|
petDragInProgress: false,
|
|
keyboardFocusActive: true
|
|
))
|
|
}
|
|
|
|
@Test
|
|
func calibratedWidthOnlyAppliesToNeverPositionedPlacement() {
|
|
#expect(
|
|
OverlayGeometry.resolvedInitialDisplayWidthPt(
|
|
persistedDisplayWidthPt: 80,
|
|
hasPersistedPosition: false
|
|
) == 112
|
|
)
|
|
#expect(
|
|
OverlayGeometry.resolvedInitialDisplayWidthPt(
|
|
persistedDisplayWidthPt: 80,
|
|
hasPersistedPosition: true
|
|
) == 100
|
|
)
|
|
#expect(
|
|
OverlayGeometry.resolvedInitialDisplayWidthPt(
|
|
persistedDisplayWidthPt: 180,
|
|
hasPersistedPosition: true
|
|
) == 180
|
|
)
|
|
}
|
|
|
|
@Test
|
|
func sizeChangesKeepTheBottomCenterAnchorStable() {
|
|
let initialCenter = CGPoint(x: 640, y: 180)
|
|
let resizedCenter = OverlayGeometry.bottomAnchoredCenter(
|
|
from: initialCenter,
|
|
currentDisplayWidthPt: 112,
|
|
proposedDisplayWidthPt: 300
|
|
)
|
|
let initialBottom = initialCenter.y
|
|
- OverlayGeometry.petVisibleSize(displayWidthPt: 112).height / 2
|
|
let resizedBottom = resizedCenter.y
|
|
- OverlayGeometry.petVisibleSize(displayWidthPt: 300).height / 2
|
|
|
|
#expect(resizedCenter.x == initialCenter.x)
|
|
#expect(abs(resizedBottom - initialBottom) < 0.001)
|
|
}
|
|
|
|
@Test
|
|
func petClickAndDragUseOneExclusiveThreshold() {
|
|
#expect(!OverlayPetPointerGesture.exceedsDragThreshold(
|
|
from: CGPoint(x: 10, y: 10),
|
|
to: CGPoint(x: 13.99, y: 10)
|
|
))
|
|
#expect(OverlayPetPointerGesture.exceedsDragThreshold(
|
|
from: CGPoint(x: 10, y: 10),
|
|
to: CGPoint(x: 14, y: 10)
|
|
))
|
|
}
|
|
|
|
@Test
|
|
func gesturePhaseAndCompareAndFinishAreExactlyOnceAcrossFiveHundredOrders() {
|
|
for index in 0 ..< 500 {
|
|
let interactionID = UUID()
|
|
var session = OverlayDragSession(
|
|
interactionID: interactionID,
|
|
startPointerScreen: .zero,
|
|
startAnchorScreen: CGPoint(x: 400, y: 300),
|
|
startDisplayID: "main"
|
|
)
|
|
#expect(session.phase == .pressed)
|
|
let distance: CGFloat = switch index % 3 {
|
|
case 0: 3.999
|
|
case 1: 4
|
|
default: 120
|
|
}
|
|
session.updatePointer(CGPoint(x: distance, y: 0))
|
|
#expect(
|
|
session.phase == (distance >= 4 ? .dragging : .pressed)
|
|
)
|
|
let staleID = UUID()
|
|
let staleFinalized = session.compareAndFinalize(
|
|
interactionID: staleID
|
|
)
|
|
#expect(!staleFinalized)
|
|
let finalized = session.compareAndFinalize(
|
|
interactionID: interactionID
|
|
)
|
|
#expect(finalized)
|
|
#expect(session.phase == .finalized)
|
|
let duplicateFinalized = session.compareAndFinalize(
|
|
interactionID: interactionID
|
|
)
|
|
#expect(!duplicateFinalized)
|
|
let commitCount = session.hasCrossedThreshold ? 1 : 0
|
|
#expect(commitCount == (distance >= 4 ? 1 : 0))
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func auxiliaryAttachmentPolicyIsIdempotentAndCycleSafe() {
|
|
#expect(OverlayAuxiliaryPanelAttachmentPolicy.plan(
|
|
currentParentMatchesTarget: true,
|
|
hasCurrentParent: true,
|
|
wouldCreateCycle: false
|
|
) == .none)
|
|
#expect(OverlayAuxiliaryPanelAttachmentPolicy.plan(
|
|
currentParentMatchesTarget: false,
|
|
hasCurrentParent: false,
|
|
wouldCreateCycle: false
|
|
) == .attach)
|
|
#expect(OverlayAuxiliaryPanelAttachmentPolicy.plan(
|
|
currentParentMatchesTarget: false,
|
|
hasCurrentParent: true,
|
|
wouldCreateCycle: false
|
|
) == .reparent)
|
|
#expect(OverlayAuxiliaryPanelAttachmentPolicy.plan(
|
|
currentParentMatchesTarget: false,
|
|
hasCurrentParent: true,
|
|
wouldCreateCycle: true
|
|
) == .rejectCycle)
|
|
}
|
|
|
|
@MainActor
|
|
@Test
|
|
func concreteAuxiliaryWindowReattachesAndRemainsIdempotent() {
|
|
let target = NSPanel(
|
|
contentRect: CGRect(x: 0, y: 0, width: 120, height: 130),
|
|
styleMask: [.borderless, .nonactivatingPanel],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
let wrongParent = NSPanel(
|
|
contentRect: CGRect(x: 300, y: 0, width: 120, height: 130),
|
|
styleMask: [.borderless, .nonactivatingPanel],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
let auxiliary = NSPanel(
|
|
contentRect: CGRect(x: 0, y: 140, width: 304, height: 180),
|
|
styleMask: [.borderless, .nonactivatingPanel],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
wrongParent.addChildWindow(auxiliary, ordered: .above)
|
|
|
|
OverlayAuxiliaryPanelAttachment.ensure(
|
|
parent: target,
|
|
auxiliary: auxiliary
|
|
)
|
|
#expect(auxiliary.parent === target)
|
|
#expect(wrongParent.childWindows?.contains { $0 === auxiliary } != true)
|
|
#expect(target.childWindows?.filter { $0 === auxiliary }.count == 1)
|
|
|
|
OverlayAuxiliaryPanelAttachment.ensure(
|
|
parent: target,
|
|
auxiliary: auxiliary
|
|
)
|
|
#expect(auxiliary.parent === target)
|
|
#expect(target.childWindows?.filter { $0 === auxiliary }.count == 1)
|
|
|
|
target.removeChildWindow(auxiliary)
|
|
#expect(auxiliary.parent == nil)
|
|
OverlayAuxiliaryPanelAttachment.ensure(
|
|
parent: target,
|
|
auxiliary: auxiliary
|
|
)
|
|
#expect(auxiliary.parent === target)
|
|
}
|
|
|
|
@Test
|
|
func petPrimaryClickRunsOnceForSingleOrDoubleClickAndNeverAfterDrag() {
|
|
#expect(OverlayPetPointerGesture.shouldPerformPrimaryClick(
|
|
clickCount: 1,
|
|
didDrag: false
|
|
))
|
|
#expect(!OverlayPetPointerGesture.shouldPerformPrimaryClick(
|
|
clickCount: 2,
|
|
didDrag: false
|
|
))
|
|
#expect(!OverlayPetPointerGesture.shouldPerformPrimaryClick(
|
|
clickCount: 3,
|
|
didDrag: false
|
|
))
|
|
#expect(!OverlayPetPointerGesture.shouldPerformPrimaryClick(
|
|
clickCount: 1,
|
|
didDrag: true
|
|
))
|
|
}
|
|
|
|
@Test
|
|
func dragSessionUsesAbsolutePointerDeltaWithoutAccumulatedDrift() {
|
|
let startPointer = CGPoint(x: 120, y: 240)
|
|
let startAnchor = CGPoint(x: 600, y: 400)
|
|
var session = OverlayDragSession(
|
|
startPointerScreen: startPointer,
|
|
startAnchorScreen: startAnchor,
|
|
startDisplayID: "main"
|
|
)
|
|
|
|
for sample in 1 ... 500 {
|
|
session.updatePointer(CGPoint(
|
|
x: startPointer.x + CGFloat(sample) * 1.25,
|
|
y: startPointer.y - CGFloat(sample) * 0.75
|
|
))
|
|
}
|
|
|
|
#expect(session.hasCrossedThreshold)
|
|
#expect(session.proposedAnchorScreen == CGPoint(
|
|
x: startAnchor.x + 625,
|
|
y: startAnchor.y - 375
|
|
))
|
|
}
|
|
|
|
@Test
|
|
func globalEventLocationsSurviveEveryScreenArrangement() {
|
|
let arrangements = [
|
|
CGRect(x: 0, y: 0, width: 1_920, height: 1_080),
|
|
CGRect(x: 0, y: 0, width: 1_440, height: 900),
|
|
CGRect(x: 0, y: 0, width: 3_456, height: 2_234),
|
|
]
|
|
for zeroOrigin in arrangements {
|
|
for sample in 0 ... 8 {
|
|
let progress = CGFloat(sample) / 8
|
|
let global = CGPoint(
|
|
x: zeroOrigin.width * progress,
|
|
y: zeroOrigin.height * progress
|
|
)
|
|
let screenPoint = OverlayPointerCoordinateSpace.screenPoint(
|
|
forGlobalEventLocation: global,
|
|
zeroOriginScreenFrame: zeroOrigin
|
|
)
|
|
#expect(screenPoint.x == global.x)
|
|
#expect(screenPoint.y == zeroOrigin.height - global.y)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The defect this pins: the drag translates its own host window on every
|
|
/// display tick, so a pointer sample read through that window's frame is
|
|
/// short by however far the window moved since the sample was created. Fast
|
|
/// drags queue several samples per frame and the error accumulates as the
|
|
/// grab point sliding across the pet.
|
|
@Test(arguments: [1.0, 9.0, 47.0])
|
|
func fastDragHoldsTheGrabOffsetWhileTheHostWindowMovesEverySample(
|
|
pointsPerSample: CGFloat
|
|
) {
|
|
let zeroOrigin = CGRect(x: 0, y: 0, width: 1_920, height: 1_080)
|
|
let startGlobal = CGPoint(x: 300, y: 700)
|
|
let startPointer = OverlayPointerCoordinateSpace.screenPoint(
|
|
forGlobalEventLocation: startGlobal,
|
|
zeroOriginScreenFrame: zeroOrigin
|
|
)
|
|
// The pointer grabs the pet off-center, which is what makes a drift
|
|
// visible to the user at all.
|
|
let startAnchor = CGPoint(
|
|
x: startPointer.x - 23,
|
|
y: startPointer.y + 17
|
|
)
|
|
let grabOffset = CGPoint(
|
|
x: startAnchor.x - startPointer.x,
|
|
y: startAnchor.y - startPointer.y
|
|
)
|
|
var session = OverlayDragSession(
|
|
startPointerScreen: startPointer,
|
|
startAnchorScreen: startAnchor,
|
|
startDisplayID: "zero-origin"
|
|
)
|
|
var hostWindowFrame = OverlayGeometry.rect(
|
|
center: startAnchor,
|
|
size: CGSize(width: 704, height: 640)
|
|
)
|
|
|
|
for sample in 1 ... 500 {
|
|
let global = CGPoint(
|
|
x: startGlobal.x + CGFloat(sample) * pointsPerSample,
|
|
y: startGlobal.y - CGFloat(sample) * pointsPerSample * 0.5
|
|
)
|
|
let pointer = OverlayPointerCoordinateSpace.screenPoint(
|
|
forGlobalEventLocation: global,
|
|
zeroOriginScreenFrame: zeroOrigin
|
|
)
|
|
session.updatePointer(pointer)
|
|
let presented = session.proposedAnchorScreen
|
|
#expect(presented.x - pointer.x == grabOffset.x)
|
|
#expect(presented.y - pointer.y == grabOffset.y)
|
|
// Production moves this window to follow each presented center, so
|
|
// the next sample is created against a frame that has already
|
|
// shifted underneath the pointer.
|
|
hostWindowFrame = OverlayGeometry.rect(
|
|
center: presented,
|
|
size: hostWindowFrame.size
|
|
)
|
|
}
|
|
|
|
#expect(hostWindowFrame.midX == session.proposedAnchorScreen.x)
|
|
}
|
|
|
|
@Test
|
|
func bubbleKeepsItsAttachedEdgeWhileADragCrossesTheScreenMidline() {
|
|
let visibleFrame = CGRect(x: 0, y: 0, width: 1_920, height: 1_055)
|
|
let bubbleSize = CGSize(width: 344, height: 120)
|
|
var anchor: OverlayBubbleAnchor?
|
|
var previousCenterX: CGFloat?
|
|
var largestStep: CGFloat = 0
|
|
|
|
// Sweep a drag straight through the midline. Deciding the attached edge
|
|
// from the midline alone would jump the bubble by its own width minus
|
|
// the pet's the moment the pet crosses it.
|
|
for sample in 0 ... 400 {
|
|
let petCenter = CGPoint(
|
|
x: 660 + CGFloat(sample) * 1.5,
|
|
y: 520
|
|
)
|
|
let placement = OverlayGeometry.bubblePlacement(
|
|
bubbleSize: bubbleSize,
|
|
displayWidthPt: 112,
|
|
petScreenCenter: petCenter,
|
|
screenFrame: visibleFrame,
|
|
previousAnchor: anchor
|
|
)
|
|
anchor = placement.anchor
|
|
if let previousCenterX {
|
|
largestStep = max(
|
|
largestStep,
|
|
abs(placement.center.x - previousCenterX)
|
|
)
|
|
}
|
|
previousCenterX = placement.center.x
|
|
#expect(visibleFrame.contains(OverlayGeometry.rect(
|
|
center: placement.center,
|
|
size: bubbleSize
|
|
)))
|
|
}
|
|
|
|
// The bubble may only travel as far as the pet did between samples.
|
|
#expect(largestStep <= 1.5 + 0.001)
|
|
}
|
|
|
|
@MainActor
|
|
@Test
|
|
func sustainedDeliveryKeepsTheTickSourceArmedForTheWholeGesture() throws {
|
|
let factory = FakeOverlayDisplayTickFactory()
|
|
let driver = OverlayDisplayLinkCoalescer<Int> { _, onTick in
|
|
factory.makeSource(onTick: onTick)
|
|
}
|
|
let cadence = OverlayDisplayRefreshCadence(
|
|
displayID: "display",
|
|
framesPerSecond: 120
|
|
)
|
|
var delivered: [Int] = []
|
|
|
|
driver.beginSustainedDelivery()
|
|
for sample in 1 ... 4 {
|
|
driver.submit(
|
|
sample,
|
|
targetDisplayID: cadence.displayID,
|
|
screen: nil,
|
|
fallbackCadence: cadence,
|
|
deliver: { delivered.append($0) }
|
|
)
|
|
let source = try #require(factory.sources.last)
|
|
source.fire()
|
|
// Re-arming a paused link per sample can miss the next vsync, so
|
|
// the source stays live until the gesture finishes.
|
|
#expect(!source.isPaused)
|
|
}
|
|
#expect(delivered == [1, 2, 3, 4])
|
|
|
|
driver.endSustainedDelivery()
|
|
#expect(try #require(factory.sources.last).isPaused)
|
|
}
|
|
|
|
@Test(arguments: [
|
|
(500, 50.0),
|
|
(500, 60.0),
|
|
(500, 120.0),
|
|
(1_000, 50.0),
|
|
(1_000, 60.0),
|
|
(1_000, 120.0),
|
|
])
|
|
func directManipulationCoalescesToTheTargetDisplayRefreshRate(
|
|
sampleCount: Int,
|
|
framesPerSecond: Double
|
|
) throws {
|
|
let cadence = OverlayDisplayRefreshCadence(
|
|
displayID: "test-display",
|
|
framesPerSecond: framesPerSecond
|
|
)
|
|
var coalescer = OverlayDisplayRefreshCoalescer<Int>()
|
|
var delivered: [Int] = []
|
|
var lastDeliveryTime = 0.0
|
|
|
|
for sample in 0 ..< sampleCount {
|
|
let sampleTime = Double(sample) / Double(sampleCount)
|
|
if let deadline = coalescer.scheduledDeadline,
|
|
deadline <= sampleTime,
|
|
let value = coalescer.takePending(deliveredAt: deadline) {
|
|
delivered.append(value)
|
|
lastDeliveryTime = deadline
|
|
}
|
|
_ = coalescer.submit(
|
|
sample,
|
|
at: sampleTime,
|
|
cadence: cadence
|
|
)
|
|
}
|
|
if let deadline = coalescer.scheduledDeadline,
|
|
let value = coalescer.takePending(deliveredAt: deadline) {
|
|
delivered.append(value)
|
|
lastDeliveryTime = deadline
|
|
}
|
|
|
|
let refreshCycles = Int(ceil(
|
|
lastDeliveryTime / cadence.intervalSeconds
|
|
))
|
|
#expect(delivered.count <= refreshCycles)
|
|
#expect(try #require(delivered.last) == sampleCount - 1)
|
|
}
|
|
|
|
@Test
|
|
func directManipulationReschedulesWhenTheTargetDisplayChanges() throws {
|
|
let slow = OverlayDisplayRefreshCadence(
|
|
displayID: "slow",
|
|
framesPerSecond: 50
|
|
)
|
|
let fast = OverlayDisplayRefreshCadence(
|
|
displayID: "fast",
|
|
framesPerSecond: 120
|
|
)
|
|
var coalescer = OverlayDisplayRefreshCoalescer<Int>()
|
|
|
|
let slowDeadline = coalescer.submit(1, at: 0, cadence: slow)
|
|
#expect(abs(slowDeadline - 0.02) < 0.000_001)
|
|
let fastDeadline = coalescer.submit(2, at: 0.005, cadence: fast)
|
|
#expect(fastDeadline < slowDeadline)
|
|
#expect(abs(fastDeadline - (0.005 + 1.0 / 120.0)) < 0.000_001)
|
|
#expect(coalescer.takePending(deliveredAt: fastDeadline) == 2)
|
|
|
|
let nextSlowDeadline = coalescer.submit(
|
|
3,
|
|
at: fastDeadline + 0.001,
|
|
cadence: slow
|
|
)
|
|
#expect(abs(nextSlowDeadline - (fastDeadline + 0.02)) < 0.000_001)
|
|
#expect(coalescer.takePending(deliveredAt: nextSlowDeadline) == 3)
|
|
}
|
|
|
|
@MainActor
|
|
@Test(arguments: [
|
|
(500, 50),
|
|
(500, 60),
|
|
(500, 120),
|
|
(1_000, 50),
|
|
(1_000, 60),
|
|
(1_000, 120),
|
|
])
|
|
func displayLinkDriverDeliversAtMostOncePerInjectedTick(
|
|
sampleCount: Int,
|
|
tickCount: Int
|
|
) throws {
|
|
let factory = FakeOverlayDisplayTickFactory()
|
|
let driver = OverlayDisplayLinkCoalescer<Int> { _, onTick in
|
|
factory.makeSource(onTick: onTick)
|
|
}
|
|
let cadence = OverlayDisplayRefreshCadence(
|
|
displayID: "display",
|
|
framesPerSecond: Double(tickCount)
|
|
)
|
|
var delivered: [Int] = []
|
|
|
|
for tick in 0 ..< tickCount {
|
|
let lower = tick * sampleCount / tickCount
|
|
let upper = (tick + 1) * sampleCount / tickCount
|
|
for sample in lower ..< upper {
|
|
driver.submit(
|
|
sample,
|
|
targetDisplayID: cadence.displayID,
|
|
screen: nil,
|
|
fallbackCadence: cadence,
|
|
deliver: { delivered.append($0) }
|
|
)
|
|
}
|
|
try #require(factory.sources.last).fire()
|
|
}
|
|
|
|
#expect(delivered.count <= tickCount)
|
|
#expect(try #require(delivered.last) == sampleCount - 1)
|
|
#expect(try #require(factory.sources.last).isPaused)
|
|
}
|
|
|
|
@MainActor
|
|
@Test
|
|
func displayLinkDriverRebindsAcrossScreensAndReleaseFlushesLatest() throws {
|
|
let factory = FakeOverlayDisplayTickFactory()
|
|
let driver = OverlayDisplayLinkCoalescer<Int> { _, onTick in
|
|
factory.makeSource(onTick: onTick)
|
|
}
|
|
let cadence = OverlayDisplayRefreshCadence(
|
|
displayID: "fallback",
|
|
framesPerSecond: 60
|
|
)
|
|
var delivered: [Int] = []
|
|
|
|
driver.submit(
|
|
1,
|
|
targetDisplayID: "left",
|
|
screen: nil,
|
|
fallbackCadence: cadence,
|
|
deliver: { delivered.append($0) }
|
|
)
|
|
driver.submit(
|
|
2,
|
|
targetDisplayID: "left",
|
|
screen: nil,
|
|
fallbackCadence: cadence,
|
|
deliver: { delivered.append($0) }
|
|
)
|
|
let left = try #require(factory.sources.first)
|
|
#expect(factory.sources.count == 1)
|
|
|
|
driver.submit(
|
|
3,
|
|
targetDisplayID: "right",
|
|
screen: nil,
|
|
fallbackCadence: cadence,
|
|
deliver: { delivered.append($0) }
|
|
)
|
|
let right = try #require(factory.sources.last)
|
|
#expect(factory.sources.count == 2)
|
|
#expect(left.invalidated)
|
|
left.fire()
|
|
#expect(delivered.isEmpty)
|
|
|
|
right.fire()
|
|
#expect(delivered == [3])
|
|
#expect(right.isPaused)
|
|
|
|
driver.submit(
|
|
4,
|
|
targetDisplayID: "right",
|
|
screen: nil,
|
|
fallbackCadence: cadence,
|
|
deliver: { delivered.append($0) }
|
|
)
|
|
driver.submit(
|
|
5,
|
|
targetDisplayID: "right",
|
|
screen: nil,
|
|
fallbackCadence: cadence,
|
|
deliver: { delivered.append($0) }
|
|
)
|
|
driver.flushNow()
|
|
#expect(delivered == [3, 5])
|
|
#expect(right.isPaused)
|
|
|
|
driver.submit(
|
|
6,
|
|
targetDisplayID: "right",
|
|
screen: nil,
|
|
fallbackCadence: cadence,
|
|
deliver: { delivered.append($0) }
|
|
)
|
|
driver.invalidate()
|
|
#expect(right.invalidated)
|
|
#expect(!driver.hasPending)
|
|
right.fire()
|
|
#expect(delivered == [3, 5])
|
|
}
|
|
|
|
@MainActor
|
|
@Test(arguments: [500, 1_000])
|
|
func displayLinkDeliveriesMoveTheCompositionAtMostOncePerTick(
|
|
sampleCount: Int
|
|
) throws {
|
|
let tickCount = 60
|
|
let factory = FakeOverlayDisplayTickFactory()
|
|
let driver = OverlayDisplayLinkCoalescer<CGPoint> { _, onTick in
|
|
factory.makeSource(onTick: onTick)
|
|
}
|
|
let cadence = OverlayDisplayRefreshCadence(
|
|
displayID: "display",
|
|
framesPerSecond: Double(tickCount)
|
|
)
|
|
let initialFrame = CGRect(x: 300, y: 200, width: 704, height: 640)
|
|
let startCenter = CGPoint(x: 600, y: 400)
|
|
var parentFrame = initialFrame
|
|
let anchor = OverlayDirectManipulationAnchor(
|
|
panelFrame: initialFrame,
|
|
petScreenCenter: startCenter
|
|
)
|
|
var frameSetOperations: [OverlayInteractionWindowRole] = []
|
|
var bubbleAnchor: OverlayBubbleAnchor?
|
|
var presentedFrames: [OverlayInteractionWindowMove] = []
|
|
|
|
for tick in 0 ..< tickCount {
|
|
let lower = tick * sampleCount / tickCount
|
|
let upper = (tick + 1) * sampleCount / tickCount
|
|
for sample in lower ..< upper {
|
|
let center = CGPoint(
|
|
x: startCenter.x + CGFloat(sample + 1) * 0.5,
|
|
y: startCenter.y + CGFloat(sample + 1) * 0.25
|
|
)
|
|
driver.submit(
|
|
center,
|
|
targetDisplayID: cadence.displayID,
|
|
screen: nil,
|
|
fallbackCadence: cadence
|
|
) { center in
|
|
let plan = OverlayDirectManipulationMovePlan.plan(
|
|
anchor: anchor,
|
|
parentFrame: parentFrame,
|
|
presentedPetCenter: center,
|
|
auxiliary: OverlayDirectManipulationAuxiliaryInput(
|
|
displayWidthPt: 112,
|
|
visibleFrame: CGRect(
|
|
x: 0,
|
|
y: 0,
|
|
width: 1_920,
|
|
height: 1_055
|
|
),
|
|
petVisualEnvelope: nil,
|
|
bubbleSize: CGSize(width: 344, height: 120),
|
|
previousBubbleAnchor: bubbleAnchor,
|
|
menuVisible: true
|
|
)
|
|
)
|
|
for move in plan.moves {
|
|
frameSetOperations.append(move.role)
|
|
if move.role == .parentPetPanel {
|
|
parentFrame = move.frame
|
|
}
|
|
}
|
|
bubbleAnchor = plan.bubbleAnchor
|
|
presentedFrames = plan.moves
|
|
}
|
|
}
|
|
try #require(factory.sources.last).fire()
|
|
}
|
|
|
|
let finalCenter = CGPoint(
|
|
x: startCenter.x + CGFloat(sampleCount) * 0.5,
|
|
y: startCenter.y + CGFloat(sampleCount) * 0.25
|
|
)
|
|
let expectedFrame = initialFrame.offsetBy(
|
|
dx: finalCenter.x - startCenter.x,
|
|
dy: finalCenter.y - startCenter.y
|
|
)
|
|
// Every sample still collapses to at most one presentation per tick,
|
|
// and one presentation now carries the whole composition instead of
|
|
// deferring the auxiliary panels to a post-release correction.
|
|
let parentMoves = frameSetOperations.filter { $0 == .parentPetPanel }
|
|
#expect(parentMoves.count <= tickCount)
|
|
#expect(frameSetOperations.contains(.bubbleChildPanel))
|
|
#expect(frameSetOperations.contains(.menuChildPanel))
|
|
#expect(parentFrame == expectedFrame)
|
|
|
|
// The bubble tracks the final pet center within the same delivery, so
|
|
// no auxiliary reconciliation is left over for the release.
|
|
let bubbleFrame = try #require(
|
|
presentedFrames.first { $0.role == .bubbleChildPanel }?.frame
|
|
)
|
|
let expectedBubbleCenter = OverlayGeometry.bubblePlacement(
|
|
bubbleSize: CGSize(width: 344, height: 120),
|
|
displayWidthPt: 112,
|
|
petScreenCenter: finalCenter,
|
|
screenFrame: CGRect(x: 0, y: 0, width: 1_920, height: 1_055),
|
|
previousAnchor: bubbleAnchor
|
|
).center
|
|
#expect(hypot(
|
|
bubbleFrame.midX - expectedBubbleCenter.x,
|
|
bubbleFrame.midY - expectedBubbleCenter.y
|
|
) <= 0.001)
|
|
}
|
|
|
|
@Test
|
|
func directManipulationFreezesAuxiliaryRelativeFramesForOneHundredSamples() {
|
|
let parent = CGRect(x: 400, y: 240, width: 140, height: 160)
|
|
let bubble = CGRect(x: 210, y: 410, width: 344, height: 120)
|
|
let menu = CGRect(x: 548, y: 330, width: 38, height: 38)
|
|
let frozen = OverlayAuxiliaryRelativeFrameSnapshot.capture(
|
|
parentFrame: parent,
|
|
bubbleFrame: bubble,
|
|
menuFrame: menu
|
|
)
|
|
for sample in 0 ..< 100 {
|
|
let movedParent = parent.offsetBy(
|
|
dx: CGFloat(sample) * 3.25,
|
|
dy: CGFloat(sample) * -1.75
|
|
)
|
|
let movedBubble = bubble.offsetBy(
|
|
dx: movedParent.minX - parent.minX,
|
|
dy: movedParent.minY - parent.minY
|
|
)
|
|
let movedMenu = menu.offsetBy(
|
|
dx: movedParent.minX - parent.minX,
|
|
dy: movedParent.minY - parent.minY
|
|
)
|
|
let current = OverlayAuxiliaryRelativeFrameSnapshot.capture(
|
|
parentFrame: movedParent,
|
|
bubbleFrame: movedBubble,
|
|
menuFrame: movedMenu
|
|
)
|
|
#expect(current == frozen)
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func crossDisplayDragPreservesTheAbsoluteStartAnchorWithinOnePoint() throws {
|
|
let left = OverlayDragDisplay(
|
|
id: "left",
|
|
frame: CGRect(x: 0, y: 0, width: 1_440, height: 900),
|
|
visibleFrame: CGRect(x: 0, y: 0, width: 1_440, height: 875)
|
|
)
|
|
let right = OverlayDragDisplay(
|
|
id: "right",
|
|
frame: CGRect(x: 1_440, y: -120, width: 1_920, height: 1_080),
|
|
visibleFrame: CGRect(x: 1_440, y: -120, width: 1_920, height: 1_055)
|
|
)
|
|
let startPointer = CGPoint(x: 1_400, y: 400)
|
|
let startAnchor = CGPoint(x: 1_360, y: 360)
|
|
var session = OverlayDragSession(
|
|
startPointerScreen: startPointer,
|
|
startAnchorScreen: startAnchor,
|
|
startDisplayID: left.id
|
|
)
|
|
|
|
for sample in 1 ... 1_000 {
|
|
let progress = CGFloat(sample) / 1_000
|
|
session.updatePointer(CGPoint(
|
|
x: startPointer.x + 600 * progress,
|
|
y: startPointer.y + 180 * progress
|
|
))
|
|
}
|
|
|
|
let expected = CGPoint(
|
|
x: startAnchor.x + 600,
|
|
y: startAnchor.y + 180
|
|
)
|
|
let display = try #require(OverlayDragScreenResolver.resolve(
|
|
pointer: session.latestPointerScreen,
|
|
proposedPetCenter: session.proposedAnchorScreen,
|
|
displays: [left, right],
|
|
fallbackDisplayID: session.startDisplayID
|
|
))
|
|
let movementFrame = OverlayGeometry.petMovementFrame(
|
|
screenFrame: display.frame,
|
|
visibleFrame: display.visibleFrame
|
|
)
|
|
let presented = OverlayPetDragGeometry.clampedCenter(
|
|
session.proposedAnchorScreen,
|
|
displayWidthPt: 112,
|
|
visibleFrame: movementFrame
|
|
)
|
|
|
|
#expect(display.id == right.id)
|
|
#expect(hypot(
|
|
session.proposedAnchorScreen.x - expected.x,
|
|
session.proposedAnchorScreen.y - expected.y
|
|
) <= 1)
|
|
#expect(hypot(presented.x - expected.x, presented.y - expected.y) <= 1)
|
|
}
|
|
|
|
@Test
|
|
func petDragUsesOnlyTheHardBoundaryWithNoOvershootOrProjection() {
|
|
let movementFrame = CGRect(x: 0, y: 0, width: 1_200, height: 800)
|
|
let displayWidthPt: CGFloat = 112
|
|
let inside = CGPoint(x: 600, y: 400)
|
|
#expect(OverlayPetDragGeometry.clampedCenter(
|
|
inside,
|
|
displayWidthPt: displayWidthPt,
|
|
visibleFrame: movementFrame
|
|
) == inside)
|
|
|
|
let proposed = CGPoint(x: -600, y: 1_200)
|
|
let hard = OverlayGeometry.clampedPetScreenCenter(
|
|
proposed,
|
|
displayWidthPt: displayWidthPt,
|
|
visibleFrame: movementFrame
|
|
)
|
|
let presentation = OverlayPetDragGeometry.clampedCenter(
|
|
proposed,
|
|
displayWidthPt: displayWidthPt,
|
|
visibleFrame: movementFrame
|
|
)
|
|
|
|
#expect(presentation == hard)
|
|
#expect(OverlayPetDragGeometry.clampedCenter(
|
|
presentation,
|
|
displayWidthPt: displayWidthPt,
|
|
visibleFrame: movementFrame
|
|
) == presentation)
|
|
}
|
|
|
|
@MainActor
|
|
@Test
|
|
func petDragViewAccessibilityPressUsesThePrimaryClickAction() {
|
|
let view = WindowDragRegion.DragView(frame: CGRect(x: 0, y: 0, width: 80, height: 80))
|
|
var primaryClickCount = 0
|
|
view.onPrimaryClick = { primaryClickCount += 1 }
|
|
|
|
#expect(view.accessibilityRole() == .button)
|
|
#expect(view.accessibilityPerformPress())
|
|
#expect(primaryClickCount == 1)
|
|
}
|
|
|
|
@Test
|
|
func expiredCanonicalStateCannotDrivePetWithoutAProjectedSession() throws {
|
|
let ghost = try JSONDecoder().decode(
|
|
ActiveAgentState.self,
|
|
from: Data(
|
|
#"{"state":"tool","official_status":"running","source":"codex","session_id":"ghost-session","session_active":true,"source_session_sequence":2,"priority":300,"event":{"id":"ghost-tool","source":"codex","session_id":"ghost-session","event_type":"tool","title":"执行工具","created_at":"2026-07-29T00:00:00Z"}}"#.utf8
|
|
)
|
|
)
|
|
|
|
#expect(OverlayPresentedAgentState.resolve(
|
|
canonicalState: ghost,
|
|
activeSessions: [],
|
|
dismissedSessionIDs: []
|
|
) == nil)
|
|
#expect(OverlayPresentedAgentState.resolve(
|
|
canonicalState: ghost,
|
|
activeSessions: [ghost],
|
|
dismissedSessionIDs: []
|
|
)?.event.id == "ghost-tool")
|
|
}
|
|
|
|
@Test
|
|
func overlayControlsFadeInImmediatelyAndDelayOnlyPointerExit() {
|
|
#expect(OverlayControlVisibility.transitionDelay(showing: true, forced: false)
|
|
== .zero)
|
|
#expect(OverlayControlVisibility.transitionDelay(showing: false, forced: false)
|
|
== .milliseconds(300))
|
|
#expect(OverlayControlVisibility.transitionDelay(showing: true, forced: true) == .zero)
|
|
#expect(OverlayControlVisibility.isVisible(
|
|
pointerNearPet: false,
|
|
petDragInProgress: false,
|
|
keyboardFocusActive: true
|
|
))
|
|
#expect((0.12 ... 0.16).contains(OverlayMotion.controlFadeDuration))
|
|
#expect(OverlayMotion.controlFadeDelay == .milliseconds(140))
|
|
#expect((0.14 ... 0.18).contains(OverlayMotion.bubbleLayoutDuration))
|
|
#expect(OverlayMotion.bubbleLayoutDelay == .milliseconds(160))
|
|
#expect(OverlayMotion.reducedMotionCrossfadeDuration > 0)
|
|
#expect(OverlayMotion.reducedMotionCrossfadeDuration <= 0.22)
|
|
}
|
|
|
|
@Test
|
|
func overlayContentPrefersAuthoritativeDisplayFieldsOverEmbeddedEventCopies() throws {
|
|
let displayTitle = "修复宠物消息气泡"
|
|
let displayReply = "气泡已经恢复显示最新回复。"
|
|
let displayActivity = "正在运行已校验的构建工具"
|
|
let rawPrompt = "RAW_EVENT_PROMPT_DO_NOT_RENDER sk-live-secret /Users/alice/private.txt"
|
|
let rawCommand = "COMMAND_DO_NOT_RENDER /bin/sh -c curl-secret"
|
|
let json = """
|
|
{
|
|
"state":"tool",
|
|
"official_status":"running",
|
|
"source":"codex",
|
|
"session_id":"safe-session",
|
|
"session_active":true,
|
|
"source_session_sequence":3,
|
|
"priority":300,
|
|
"lease_seconds":null,
|
|
"expires_at":null,
|
|
"event":{
|
|
"id":"safe-event",
|
|
"source":"codex",
|
|
"session_id":"safe-session",
|
|
"event_type":"tool",
|
|
"title":"执行工具",
|
|
"detail":null,
|
|
"payload_json":{
|
|
"message_role":"assistant",
|
|
"message_content":"\(rawPrompt)",
|
|
"activity_kind":"command",
|
|
"activity_content":"\(rawCommand)",
|
|
"project_label":"/Users/alice/private"
|
|
},
|
|
"created_at":"2026-07-21T00:00:00Z"
|
|
},
|
|
"latest_message":{
|
|
"id":"raw-message",
|
|
"source":"codex",
|
|
"session_id":"safe-session",
|
|
"event_type":"done",
|
|
"title":"已完成",
|
|
"detail":null,
|
|
"payload_json":{"message_role":"assistant","message_content":"\(rawPrompt)"},
|
|
"created_at":"2026-07-21T00:00:00Z"
|
|
},
|
|
"session_title":"\(displayTitle)",
|
|
"session_user_message":{"role":"user","content":"\(displayTitle)"},
|
|
"session_message":{"role":"assistant","content":"\(displayReply)"},
|
|
"session_activity":{"kind":"command","content":"\(displayActivity)"},
|
|
"overlay_display":{
|
|
"summary_kind":"command",
|
|
"navigation":{
|
|
"session_open":true,
|
|
"surface":"cli_terminal",
|
|
"terminal_app":"terminal",
|
|
"open_url":null
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
let state = try JSONDecoder().decode(
|
|
ActiveAgentState.self,
|
|
from: Data(json.utf8)
|
|
)
|
|
let content = OverlaySessionContent(state: state)
|
|
|
|
#expect(content.sessionID == "safe-session")
|
|
#expect(content.sessionTitle == displayTitle)
|
|
#expect(content.activityText == displayActivity)
|
|
#expect(content.messageText == displayReply)
|
|
#expect(content.primaryDetailText == displayReply)
|
|
#expect(content.detailText == displayReply)
|
|
#expect(!content.detailText.contains(displayActivity))
|
|
#expect(content.detailText.contains(displayReply))
|
|
#expect(content.navigation.sessionOpen == true)
|
|
#expect(!content.sessionTitle.contains(rawPrompt))
|
|
#expect(!content.detailText.contains(rawPrompt))
|
|
#expect(!content.detailText.contains(rawCommand))
|
|
}
|
|
|
|
@Test
|
|
func overlayDoesNotInventToolActivityWhenNoDetailExists() throws {
|
|
let state = try JSONDecoder().decode(
|
|
ActiveAgentState.self,
|
|
from: Data(
|
|
#"{"state":"tool","official_status":"running","source":"codex","session_id":"tool-session","session_active":true,"source_session_sequence":2,"priority":300,"event":{"id":"tool-event","source":"codex","session_id":"tool-session","event_type":"tool","title":"执行工具","detail":null,"payload_json":{"tool_name":"Bash","activity_kind":"command","activity_content":"{\"command\":\"RAW_DO_NOT_RENDER\"}"},"created_at":"2026-07-29T00:00:00Z"},"session_title":"构建 App","session_message":{"role":"assistant","content":"正在检查构建结果。"},"session_activity":{"kind":"command","content":null},"overlay_display":{"summary_kind":"command","navigation":{"capability":"unavailable"}}}"#.utf8
|
|
)
|
|
)
|
|
let content = OverlaySessionContent(state: state)
|
|
|
|
#expect(content.activityText.isEmpty)
|
|
#expect(content.messageText == "正在检查构建结果。")
|
|
#expect(content.primaryDetailText == "正在检查构建结果。")
|
|
#expect(!content.detailText.contains("{"))
|
|
#expect(!content.detailText.contains("RAW_DO_NOT_RENDER"))
|
|
#expect(content.statusText == APCLocalizedPresentation.overlayEventTitle(.command))
|
|
}
|
|
|
|
@Test
|
|
func rawEventActivityIsNeverPromotedIntoDisplayContent() throws {
|
|
let state = try JSONDecoder().decode(
|
|
ActiveAgentState.self,
|
|
from: Data(#"{"state":"done","official_status":"ready","source":"pi","session_id":"completed","session_active":false,"source_session_sequence":1,"priority":400,"lease_seconds":30,"expires_at":null,"event":{"id":"completed-event","source":"pi","session_id":"completed","event_type":"done","title":"已完成","detail":null,"payload_json":{"message_role":"assistant","message_content":"PRIVATE_RESULT_DO_NOT_RENDER","activity_kind":"plan","activity_content":"原始活动详情现在可以显示"},"created_at":"2026-07-21T00:00:00Z"}}"#.utf8)
|
|
)
|
|
let content = OverlaySessionContent(state: state)
|
|
|
|
#expect(content.activityText.isEmpty)
|
|
#expect(content.messageText.isEmpty)
|
|
#expect(content.sessionTitle == APCLocalization.format(.overlaySessionTitleFormat, "Pi"))
|
|
#expect(content.accessibilityReadingOrder.contains(
|
|
APCLocalization.text(.overlayDetailCompleted)
|
|
))
|
|
#expect(!content.sessionTitle.contains("PRIVATE"))
|
|
#expect(!content.detailText.contains("PRIVATE"))
|
|
#expect(!content.accessibilityLabel.contains("PRIVATE"))
|
|
#expect(!content.detailText.contains("原始活动详情"))
|
|
#expect(!content.accessibilityLabel.contains("原始活动详情"))
|
|
}
|
|
|
|
@Test
|
|
func voiceOverUsesSafeLocalizedFallbackWhenProjectedCopyIsAbsent() {
|
|
let fixtures: [(AgentEventKind, APCLocalizationKey)] = [
|
|
(.waiting, .overlayDetailNeedsInput),
|
|
(.done, .overlayDetailCompleted),
|
|
(.failed, .overlayDetailBlocked),
|
|
]
|
|
|
|
for (eventType, detailKey) in fixtures {
|
|
let session = OverlaySessionContent(
|
|
id: "fallback-\(eventType.rawValue)",
|
|
source: .codex,
|
|
sessionID: "fallback-\(eventType.rawValue)",
|
|
eventType: eventType,
|
|
sessionTitle: "Codex session",
|
|
messageText: "",
|
|
statusText: APCLocalizedPresentation.eventTitle(eventType),
|
|
navigation: AgentSessionNavigation(capability: .unavailable)
|
|
)
|
|
|
|
#expect(session.primaryDetailText.isEmpty)
|
|
#expect(session.accessibilityReadingOrder == [
|
|
"Codex",
|
|
"Codex session",
|
|
APCLocalizedPresentation.eventTitle(eventType),
|
|
APCLocalization.text(detailKey),
|
|
APCLocalizedPresentation.navigationUnavailableTitle(),
|
|
])
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func omittedSessionSummaryIsBoundedAndOpensTheControlCenterPath() {
|
|
let content = OverlayBubbleContent.omittedSummary(count: 5)
|
|
let accessibility = OverlayBubbleAccessibilityModel(content: content, locale: "en")
|
|
|
|
#expect(content.sessions.count == 1)
|
|
#expect(content.representedSessionCount == 5)
|
|
#expect(content.isOmittedSummary)
|
|
#expect(!content.canDismiss)
|
|
#expect(content.sessions[0].source == nil)
|
|
#expect(content.sessions[0].canOpen)
|
|
#expect(content.sessions[0].messageText == APCLocalization.format(
|
|
.overlayMoreSessionsDetailFormat,
|
|
5
|
|
))
|
|
#expect(accessibility.sessionActionLabels == ["Open"])
|
|
#expect(accessibility.sessionCloseActionLabels == [nil])
|
|
#expect(accessibility.closeActionLabel == nil)
|
|
}
|
|
|
|
@Test
|
|
func bubbleAccessibilityModelOffersLocalizedSessionCloseAndGroupActions() {
|
|
let sessionA = OverlaySessionContent(
|
|
id: "a",
|
|
source: .codex,
|
|
sessionID: "a",
|
|
eventType: .tool,
|
|
sessionTitle: "A",
|
|
messageText: "A",
|
|
statusText: "",
|
|
navigation: AgentSessionNavigation(
|
|
capability: .agentHost,
|
|
sessionOpen: true,
|
|
surface: "chatgpt_app"
|
|
)
|
|
)
|
|
let sessionB = OverlaySessionContent(
|
|
id: "b",
|
|
source: .codex,
|
|
sessionID: "b",
|
|
eventType: .done,
|
|
sessionTitle: "B",
|
|
messageText: "B",
|
|
statusText: "",
|
|
navigation: AgentSessionNavigation(
|
|
capability: .agentHost,
|
|
sessionOpen: true,
|
|
surface: "chatgpt_app"
|
|
)
|
|
)
|
|
let content = OverlayBubbleContent(
|
|
id: "codex",
|
|
source: .codex,
|
|
agentName: "Codex",
|
|
sessions: [sessionA, sessionB],
|
|
isExpanded: true
|
|
)
|
|
let english = OverlayBubbleAccessibilityModel(content: content, locale: "en")
|
|
let chinese = OverlayBubbleAccessibilityModel(content: content, locale: "zh-Hans")
|
|
|
|
#expect(english.sessionActionLabels == ["Open ChatGPT", "Open ChatGPT"])
|
|
#expect(english.sessionCloseActionLabels == ["Hide This Session", "Hide This Session"])
|
|
#expect(english.closeActionLabel == "Close session bubble")
|
|
#expect(english.groupActionLabel == "Collapse 2 sessions")
|
|
#expect(chinese.sessionActionLabels == ["打开 ChatGPT", "打开 ChatGPT"])
|
|
#expect(chinese.sessionCloseActionLabels == ["收起此会话", "收起此会话"])
|
|
#expect(chinese.closeActionLabel == "关闭会话气泡")
|
|
#expect(chinese.groupActionLabel == "收起 2 个会话")
|
|
}
|
|
|
|
@Test
|
|
func voiceOverReadingOrderUsesOnlyTheVisibleRetainedBodyMessage() {
|
|
let fixtures = [
|
|
(
|
|
session: "A longer session title that still identifies the active work",
|
|
status: "Needs You",
|
|
activity: "Return to the agent to approve, answer, or decide.",
|
|
message: "The Agent reply remains visible."
|
|
),
|
|
(
|
|
session: "一个用于确认较长中文内容仍保持语义顺序的会话标题",
|
|
status: "等你处理",
|
|
activity: "请回到 Agent 完成确认、回答或决策。",
|
|
message: "Agent 回复仍然可见。"
|
|
),
|
|
]
|
|
|
|
for fixture in fixtures {
|
|
let session = OverlaySessionContent(
|
|
id: "voiceover-order",
|
|
source: .codex,
|
|
sessionID: "voiceover-order",
|
|
eventType: .waiting,
|
|
sessionTitle: fixture.session,
|
|
activityText: fixture.activity,
|
|
messageText: fixture.message,
|
|
statusText: fixture.status,
|
|
navigation: AgentSessionNavigation(
|
|
capability: .agentHost,
|
|
sessionOpen: true,
|
|
surface: "chatgpt_app"
|
|
)
|
|
)
|
|
|
|
#expect(session.accessibilityReadingOrder == [
|
|
"Codex",
|
|
session.surfaceLabel,
|
|
fixture.session,
|
|
fixture.status,
|
|
fixture.message,
|
|
session.actionLabel,
|
|
])
|
|
#expect(
|
|
session.accessibilityLabel
|
|
== session.accessibilityReadingOrder.joined(separator: ", ")
|
|
)
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func collapsedBubbleLayoutKeepsOneStableRowAcrossAttentionChanges() {
|
|
let first = OverlaySessionContent(
|
|
id: "first",
|
|
source: .codex,
|
|
sessionID: "first",
|
|
eventType: .tool,
|
|
sessionTitle: "Codex session 1",
|
|
messageText: "Working",
|
|
statusText: "Working"
|
|
)
|
|
let secondRunning = OverlaySessionContent(
|
|
id: "second",
|
|
source: .codex,
|
|
sessionID: "second",
|
|
eventType: .tool,
|
|
sessionTitle: "Codex session 2",
|
|
messageText: "Working",
|
|
statusText: "Working"
|
|
)
|
|
var secondAttention = secondRunning
|
|
secondAttention.eventType = .waiting
|
|
let collapsedRunning = OverlayBubbleContent(
|
|
id: "agent-codex",
|
|
source: .codex,
|
|
agentName: "Codex",
|
|
sessions: [first, secondRunning],
|
|
isExpanded: false
|
|
)
|
|
let collapsedAttention = OverlayBubbleContent(
|
|
id: "agent-codex",
|
|
source: .codex,
|
|
agentName: "Codex",
|
|
sessions: [first, secondAttention],
|
|
isExpanded: false
|
|
)
|
|
|
|
let running = OverlayBubbleLayoutSignature(
|
|
contents: [collapsedRunning],
|
|
bubbleDismissed: false
|
|
)
|
|
let attention = OverlayBubbleLayoutSignature(
|
|
contents: [collapsedAttention],
|
|
bubbleDismissed: false
|
|
)
|
|
let dismissed = OverlayBubbleLayoutSignature(contents: [], bubbleDismissed: true)
|
|
|
|
#expect(running.groups[0].visibleSessionCount == 1)
|
|
#expect(attention.groups[0].visibleSessionCount == 1)
|
|
#expect(running == attention)
|
|
#expect(dismissed != attention)
|
|
}
|
|
|
|
@MainActor
|
|
@Test
|
|
func sharedControlPresentationStartsHoverImmediatelyAndDelaysExit() async throws {
|
|
var requestedDelays: [Duration] = []
|
|
let presentation = OverlayControlPresentationState { delay in
|
|
requestedDelays.append(delay)
|
|
}
|
|
|
|
presentation.setHovered(.pet, true)
|
|
#expect(presentation.isVisible)
|
|
|
|
presentation.setHovered(.pet, false)
|
|
#expect(presentation.isVisible)
|
|
for _ in 0 ..< 10 where presentation.isVisible {
|
|
await Task.yield()
|
|
}
|
|
#expect(requestedDelays == [OverlayControlVisibility.hoverHideDelay])
|
|
#expect(!presentation.isVisible)
|
|
|
|
presentation.setActive(.menu, true)
|
|
#expect(presentation.isVisible)
|
|
presentation.setActive(.menu, false)
|
|
#expect(presentation.isVisible)
|
|
|
|
presentation.setFocused(.bubble, true)
|
|
#expect(presentation.keyboardNavigationActive)
|
|
#expect(presentation.isVisible)
|
|
presentation.setFocused(.bubble, false)
|
|
#expect(!presentation.keyboardNavigationActive)
|
|
}
|
|
|
|
@MainActor
|
|
@Test
|
|
func localPetInteractionsRespectReducedMotionPressAndDragPriority() {
|
|
let presentation = OverlayInteractionPresentationState()
|
|
let interactionID = UUID()
|
|
|
|
presentation.beginPressFeedback(enabled: false, reduceMotion: false)
|
|
#expect(!presentation.pressFeedbackActive)
|
|
presentation.beginPressFeedback(enabled: true, reduceMotion: true)
|
|
#expect(!presentation.pressFeedbackActive)
|
|
presentation.beginPressFeedback(enabled: true, reduceMotion: false)
|
|
#expect(presentation.pressFeedbackActive)
|
|
|
|
presentation.beginDrag(interactionID: interactionID, center: .zero)
|
|
presentation.updateDrag(
|
|
interactionID: interactionID,
|
|
center: CGPoint(x: -8, y: 0)
|
|
)
|
|
#expect(!presentation.pressFeedbackActive)
|
|
#expect(presentation.petInteraction?.stateName == "drag_left")
|
|
|
|
presentation.updateDrag(
|
|
interactionID: interactionID,
|
|
center: CGPoint(x: 8, y: 0)
|
|
)
|
|
#expect(presentation.petInteraction?.stateName == "drag_right")
|
|
presentation.endDrag(interactionID: interactionID)
|
|
#expect(presentation.petInteraction == nil)
|
|
|
|
presentation.acknowledge(reduceMotion: true)
|
|
#expect(presentation.petInteraction == nil)
|
|
presentation.acknowledge(reduceMotion: false)
|
|
#expect(presentation.petInteraction?.stateName == "acknowledge")
|
|
}
|
|
|
|
}
|
|
|
|
@MainActor
|
|
private final class FakeOverlayDisplayTickFactory {
|
|
private(set) var sources: [FakeOverlayDisplayTickSource] = []
|
|
|
|
func makeSource(
|
|
onTick: @escaping @MainActor () -> Void
|
|
) -> FakeOverlayDisplayTickSource {
|
|
let source = FakeOverlayDisplayTickSource(onTick: onTick)
|
|
sources.append(source)
|
|
return source
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private final class FakeOverlayDisplayTickSource: OverlayDisplayRefreshTickSource {
|
|
var isPaused = true
|
|
private(set) var invalidated = false
|
|
private let onTick: @MainActor () -> Void
|
|
|
|
init(onTick: @escaping @MainActor () -> Void) {
|
|
self.onTick = onTick
|
|
}
|
|
|
|
func fire() {
|
|
guard !invalidated, !isPaused else { return }
|
|
onTick()
|
|
}
|
|
|
|
func invalidate() {
|
|
invalidated = true
|
|
isPaused = true
|
|
}
|
|
}
|