dsh-notch 0.3.1 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.en.md +58 -47
  2. package/README.md +60 -55
  3. package/cordis.patch.yml +3 -0
  4. package/desktop/README.md +29 -0
  5. package/desktop/notch-lifecycle.mjs +120 -0
  6. package/desktop/restart-probe.mjs +61 -0
  7. package/docs/desktop-017rc2.md +23 -0
  8. package/docs/elastic-edge-dock.md +127 -0
  9. package/docs/motion-audit.md +1 -1
  10. package/docs/releases/v0.3.2.md +38 -0
  11. package/docs/releases/v0.3.3.md +9 -0
  12. package/docs/subagent-compatibility.md +51 -0
  13. package/docs/verification/elastic-algorithm-timing.json +337 -0
  14. package/docs/verification/elastic-attention.json +44 -0
  15. package/docs/verification/elastic-frame-timing.json +240 -0
  16. package/docs/verification/elastic-tension.json +59 -0
  17. package/docs/verification/elastic-tip-taper.json +181 -0
  18. package/dshx.yml +2 -0
  19. package/lib/client.js +112 -0
  20. package/lib/client.js.map +1 -0
  21. package/lib/index.mjs +990 -0
  22. package/lib/index.mjs.map +1 -0
  23. package/package.json +66 -10
  24. package/src/board.ts +178 -35
  25. package/src/client/index.ts +104 -0
  26. package/src/dsh-notch.ts +19 -2
  27. package/src/http.ts +17 -2
  28. package/src/session-state.ts +15 -1
  29. package/src/store.ts +3 -3
  30. package/src/types.ts +3 -0
  31. package/BASELINE.md +0 -13
  32. package/DESIGN.md +0 -34
  33. package/cordis.yml +0 -5
  34. package/macos/Package.swift +0 -17
  35. package/macos/STATUS-MOTION.md +0 -49
  36. package/macos/Sources/Client.swift +0 -139
  37. package/macos/Sources/IdleRobot.swift +0 -423
  38. package/macos/Sources/NotchMarkdown.swift +0 -266
  39. package/macos/Sources/Panel.swift +0 -153
  40. package/macos/Sources/Resources/Idle/balance.json +0 -1
  41. package/macos/Sources/Resources/Idle/blink.json +0 -1
  42. package/macos/Sources/Resources/Idle/cube-in.json +0 -1
  43. package/macos/Sources/Resources/Idle/dance.json +0 -1
  44. package/macos/Sources/Resources/Idle/hop.json +0 -1
  45. package/macos/Sources/Resources/Idle/nod.json +0 -1
  46. package/macos/Sources/Resources/Idle/satellite-in.json +0 -1
  47. package/macos/Sources/Resources/Idle/satellite-out.json +0 -1
  48. package/macos/Sources/Resources/Idle/scan.json +0 -1
  49. package/macos/Sources/Resources/Idle/sleep.json +0 -1
  50. package/macos/Sources/Resources/Idle/sneeze.json +0 -1
  51. package/macos/Sources/Resources/Idle/stretch.json +0 -1
  52. package/macos/Sources/Resources/Idle/tilt.json +0 -1
  53. package/macos/Sources/ReviewStudio.swift +0 -337
  54. package/macos/Sources/RootView.swift +0 -1093
  55. package/macos/Sources/StatusOrbit.swift +0 -642
  56. package/macos/Sources/main.swift +0 -153
  57. package/macos/Tests/ExpandedHeightProbe.swift +0 -108
  58. package/macos/Tests/GeometryProbe.swift +0 -98
  59. package/macos/Tests/IdleProbe.swift +0 -294
  60. package/macos/Tests/MotionProbe.swift +0 -106
  61. package/macos/Tests/OutcomeProbe.swift +0 -133
  62. package/macos/Tests/ScrollbarProbe.swift +0 -91
  63. package/macos/Tests/expanded-height.sh +0 -13
  64. package/macos/Tests/geometry.sh +0 -14
  65. package/macos/Tests/idle.sh +0 -14
  66. package/macos/Tests/motion.sh +0 -14
  67. package/macos/Tests/outcome.sh +0 -14
  68. package/macos/Tests/scrollbar.sh +0 -13
  69. package/tests/answer-lifecycle.test.mjs +0 -23
  70. package/tests/board.test.mjs +0 -7
  71. package/tests/browse-sync.test.mjs +0 -20
  72. package/tests/mirror.test.mjs +0 -4
  73. package/tests/peer-range.test.mjs +0 -21
  74. package/tests/session-state.test.mjs +0 -54
  75. package/tests/unread-hold.test.mjs +0 -90
  76. package/tools/desktop-shell/README.md +0 -56
  77. package/tools/desktop-shell/child-log.mjs +0 -6
  78. package/tools/desktop-shell/child-log.test.mjs +0 -14
  79. package/tools/desktop-shell/package-recovery.py +0 -78
  80. package/tools/desktop-shell/recovery-probe.cjs +0 -50
  81. package/tools/desktop-shell/renderer-recovery.mjs +0 -95
  82. package/tools/desktop-shell/renderer-recovery.test.mjs +0 -24
  83. package/tools/diagnostics/completion-watch.py +0 -101
  84. package/tools/diagnostics/completion-watch.test.py +0 -17
  85. package/tools/diagnostics/renderer-diagnostics.mjs +0 -56
  86. package/tools/diagnostics/renderer-diagnostics.test.mjs +0 -32
  87. package/tools/idle/LICENSE.open-bot-motion +0 -21
  88. package/tools/idle/export.mjs +0 -40
  89. package/tools/idle/index.html +0 -4
  90. package/tools/idle/motions.js +0 -83
  91. package/tools/idle/original.js +0 -1673
  92. package/tools/idle/renderer.js +0 -1673
  93. package/tools/recording/Client.swift +0 -76
  94. package/tools/recording/DemoCatalog.swift +0 -174
  95. package/tools/recording/README.md +0 -23
  96. package/tools/recording/build.sh +0 -41
  97. package/tools/recording/main.swift +0 -100
  98. package/tools/review/ReviewStudio.swift +0 -236
  99. package/tools/review/audit.sh +0 -19
  100. package/tools/review/build.sh +0 -7
  101. package/tools/review/main.swift +0 -158
  102. package/tools/review/render-audit.swift +0 -32
@@ -1,153 +0,0 @@
1
- import AppKit
2
- import Combine
3
- import SwiftUI
4
-
5
- @main
6
- enum DshNotchMain {
7
- static func main() {
8
- if CommandLine.arguments.contains("--verify-idle-resources") {
9
- let ids = IdleDirector.basics + ["dance"]
10
- let missing = ids.filter { IdleLibrary.shared.clip($0) == nil }
11
- print("IDLE_RESOURCES=\(ids.count - missing.count)/\(ids.count)")
12
- exit(missing.isEmpty ? 0 : 1)
13
- }
14
- let app = NSApplication.shared
15
- if CommandLine.arguments.contains("--demo") {
16
- app.setActivationPolicy(.regular)
17
- let delegate = DemoStudioDelegate()
18
- DemoStudioDelegate.hold = delegate
19
- app.delegate = delegate
20
- delegate.show()
21
- app.run()
22
- return
23
- }
24
- app.setActivationPolicy(.accessory)
25
- let delegate = AppDelegate()
26
- app.delegate = delegate
27
- app.run()
28
- }
29
- }
30
-
31
- @MainActor
32
- final class AppDelegate: NSObject, NSApplicationDelegate {
33
- private let panelW: CGFloat = 480
34
- private let restW: CGFloat = 32
35
- private let restH: CGFloat = 110
36
- private let topOffset: CGFloat = 100
37
- private let model = BoardModel()
38
- private var panel: NotchPanel?
39
- private var hosting: NotchHostingView<RootView>?
40
- private var cursorTimer: Timer?
41
- private var foldWork: DispatchWorkItem?
42
- private var enteredIsland = false
43
- private var cancellables = Set<AnyCancellable>()
44
-
45
- func applicationDidFinishLaunching(_ notification: Notification) {
46
- ProcessInfo.processInfo.disableAutomaticTermination("dsh-notch")
47
- ProcessInfo.processInfo.disableSuddenTermination()
48
- let panel = NotchPanel(size: NSSize(width: panelW, height: restH))
49
- let root = RootView(
50
- model: model,
51
- panelSize: CGSize(width: panelW, height: restH),
52
- restSize: CGSize(width: restW, height: restH)
53
- )
54
- let hosting = NotchHostingView(rootView: root)
55
- hosting.sizingOptions = []
56
- hosting.wantsLayer = true
57
- hosting.layer?.isOpaque = false
58
- hosting.layer?.backgroundColor = NSColor.clear.cgColor
59
- panel.embedHost(hosting)
60
- panel.ignoresMouseEvents = false
61
- self.panel = panel
62
- self.hosting = hosting
63
- pinToScreen()
64
- updateHits()
65
- panel.orderFrontRegardless()
66
- model.start()
67
- Publishers.CombineLatest3(model.$expanded, model.$currentIslandWidth, model.$currentIslandHeight)
68
- .receive(on: DispatchQueue.main)
69
- .sink { [weak self] _ in self?.updateHits() }
70
- .store(in: &cancellables)
71
- cursorTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
72
- Task { @MainActor in
73
- self?.tickPointer()
74
- }
75
- }
76
- NotificationCenter.default.addObserver(
77
- self,
78
- selector: #selector(pinToScreen),
79
- name: NSApplication.didChangeScreenParametersNotification,
80
- object: nil
81
- )
82
- }
83
-
84
- @objc private func pinToScreen() {
85
- guard let panel else { return }
86
- panel.cancelResize()
87
- let mouse = NSEvent.mouseLocation
88
- let screen = NSScreen.screens.first { NSMouseInRect(mouse, $0.frame, false) }
89
- ?? NSScreen.main
90
- ?? NSScreen.screens.first
91
- guard let screen else { return }
92
- let visible = screen.visibleFrame
93
- let layout = NotchScreenLayout(availableHeight: visible.height, preferredInset: topOffset)
94
- model.maximumExpandedHeight = layout.maximumHeight
95
- // Anchor capsule to upper-right edge, ~100pt below top of usable screen.
96
- // Keep the test helper separate from the real Notch during walkthroughs.
97
- let demoInset: CGFloat = ProcessInfo.processInfo.environment["DSH_NOTCH_RUNTIME_FILE"] == nil ? 0 : 360
98
- let width = max(1, model.currentIslandWidth)
99
- let height = min(max(1, model.currentIslandHeight), model.maximumExpandedHeight)
100
- let frame = NSRect(
101
- x: visible.maxX - demoInset - width,
102
- y: visible.maxY - layout.edgeInset - height,
103
- width: width,
104
- height: height
105
- )
106
- panel.setFrame(frame, display: true)
107
- }
108
-
109
- private func pointerOverVisual() -> Bool {
110
- guard let panel else { return false }
111
- return panel.frame.contains(NSEvent.mouseLocation)
112
- }
113
-
114
- private func tickPointer() {
115
- guard panel != nil else { return }
116
- let hit = pointerOverVisual()
117
- if hit {
118
- enteredIsland = true
119
- foldWork?.cancel()
120
- foldWork = nil
121
- model.foldEnabled = true
122
- // Expand when hovering if there are items needing action or if user triggered expansion.
123
- if !model.expanded && (model.needsAction || model.allowExpandOnHover) {
124
- model.expanded = true
125
- updateHits()
126
- }
127
- return
128
- }
129
- // A guided local walkthrough stays visible until its test answer is sent.
130
- if ProcessInfo.processInfo.environment["DSH_NOTCH_RUNTIME_FILE"] != nil && model.needsAction { return }
131
- guard model.expanded, model.foldEnabled, enteredIsland, foldWork == nil else { return }
132
- let work = DispatchWorkItem { [weak self] in
133
- Task { @MainActor in
134
- guard let self else { return }
135
- self.foldWork = nil
136
- if self.pointerOverVisual() { return }
137
- self.enteredIsland = false
138
- self.model.expanded = false
139
- self.updateHits()
140
- self.panel?.orderFrontRegardless()
141
- }
142
- }
143
- foldWork = work
144
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.38, execute: work)
145
- }
146
-
147
- private func updateHits() {
148
- guard let panel else { return }
149
- let width = max(1, model.currentIslandWidth)
150
- let height = min(max(1, model.currentIslandHeight), model.maximumExpandedHeight)
151
- panel.resizeAnchored(to: NSSize(width: width, height: height), animated: true)
152
- }
153
- }
@@ -1,108 +0,0 @@
1
- import AppKit
2
- import SwiftUI
3
- import Combine
4
-
5
- @main enum ExpandedHeightProbe {
6
- @MainActor static func main() {
7
- let app = NSApplication.shared
8
- app.setActivationPolicy(.accessory)
9
- let model = BoardModel()
10
- model.previewMode = true
11
- model.maximumExpandedHeight = 800
12
- let panel = NotchPanel(size: NSSize(width: 480, height: 110))
13
- let host = NotchHostingView(rootView: RootView(model: model, panelSize: CGSize(width: 480, height: 110), restSize: CGSize(width: 32, height: 110)))
14
- host.sizingOptions = []
15
- panel.embedHost(host)
16
- panel.setFrame(NSRect(x: -2000, y: -2000, width: 38, height: 44), display: true)
17
- panel.orderFrontRegardless()
18
- var subscriptions = Set<AnyCancellable>()
19
- Publishers.CombineLatest3(model.$expanded, model.$currentIslandWidth, model.$currentIslandHeight)
20
- .receive(on: DispatchQueue.main).sink { _ in
21
- panel.resizeAnchored(to: NSSize(width: model.currentIslandWidth, height: min(model.currentIslandHeight, model.maximumExpandedHeight)))
22
- }.store(in: &subscriptions)
23
- @MainActor func scrollViews(_ view: NSView) -> [NSScrollView] {
24
- ((view as? NSScrollView).map { [$0] } ?? []) + view.subviews.flatMap(scrollViews)
25
- }
26
- Task { @MainActor in
27
- var failures = 0
28
- @MainActor func check(_ condition: Bool, _ message: String) {
29
- if !condition { failures += 1; print("FAIL \(message)") }
30
- }
31
- @MainActor func question(_ detail: String? = nil, descriptions: Int = 1) -> NotchQuestion {
32
- NotchQuestion(id: "question", question: "要在哪个分支继续修改?", detail: detail, options: [
33
- NotchOption(label: "在 #17 分支上直接改(推荐)", description: String(repeating: "接着 cursor/reader-transcendent-merge-47c2 提交并重建,热更新到你现在这个页面验证,不用重启。", count: descriptions)),
34
- NotchOption(label: "另开分支/PR 改", description: "#17 保持给作者 review,玻璃改动走新分支。"),
35
- NotchOption(label: "暂时不改", description: "保留当前版本。")
36
- ])
37
- }
38
- @MainActor func show(_ questions: [NotchQuestion]) {
39
- model.wizard = nil
40
- model.rows = [NotchRow(id: "offline", title: "Local height fixture", child: false, busy: false, unread: false, ask: NotchAsk(id: "ask", questions: questions))]
41
- model.expanded = true
42
- }
43
- @MainActor func inspect(_ tag: String, overflowing: Bool = false) async {
44
- try? await Task.sleep(for: .milliseconds(850))
45
- host.layoutSubtreeIfNeeded()
46
- let scrolls = scrollViews(host)
47
- let overflows = scrolls.map { ($0.documentView?.frame.height ?? 0) - $0.contentView.bounds.height }
48
- print("CASE=\(tag) PANEL=\(panel.frame.height) MEASURED=\(model.measuredContentHeight) CAP=\(model.maximumExpandedHeight) OVERFLOWS=\(overflows)")
49
- if overflowing {
50
- check(abs(panel.frame.height - model.maximumExpandedHeight) < 2, "\(tag): reaches screen cap before scrolling")
51
- check(overflows.contains { $0 > 20 }, "\(tag): fixture has real overflow")
52
- if let scroll = scrolls.first, let document = scroll.documentView {
53
- let bottom = max(0, document.frame.height - scroll.contentView.bounds.height)
54
- scroll.contentView.scroll(to: NSPoint(x: 0, y: bottom))
55
- scroll.reflectScrolledClipView(scroll.contentView)
56
- check(abs(scroll.contentView.bounds.origin.y - bottom) < 2, "\(tag): bottom controls remain reachable")
57
- }
58
- } else {
59
- check(overflows.allSatisfy { $0 < 2 }, "\(tag): content fits without premature scrolling")
60
- check(panel.frame.height < model.maximumExpandedHeight - 5, "\(tag): short content doesn't fill screen cap")
61
- }
62
- if let output = ProcessInfo.processInfo.environment["NOTCH_HEIGHT_PROBE_OUTPUT"],
63
- let bitmap = host.bitmapImageRepForCachingDisplay(in: host.bounds) {
64
- host.cacheDisplay(in: host.bounds, to: bitmap)
65
- if let png = bitmap.representation(using: .png, properties: [:]) {
66
- try? png.write(to: URL(fileURLWithPath: output).appendingPathComponent("\(tag).png"))
67
- }
68
- }
69
- }
70
- show([question()])
71
- await inspect("screenshot-options")
72
- model.expanded = false
73
- try? await Task.sleep(for: .milliseconds(40))
74
- model.rows = [NotchRow(id: "changed", title: "Changed during collapse", child: false, busy: false, unread: false, ask: NotchAsk(id: "changed-ask", questions: [question(descriptions: 15)]))]
75
- try? await Task.sleep(for: .milliseconds(80))
76
- model.expanded = true
77
- await inspect("new-content-during-collapse")
78
- show([question(String(repeating: "这是正文,需要完整展示。", count: 25))])
79
- await inspect("medium-markdown")
80
- show([question("第一行\n\n第二行\n\n第三行")])
81
- await inspect("short-markdown")
82
- show([question(descriptions: 80)])
83
- await inspect("long-options", overflowing: true)
84
- model.maximumExpandedHeight = 400
85
- await inspect("smaller-screen", overflowing: true)
86
- model.maximumExpandedHeight = 800
87
- show([question(String(repeating: "### 段落\n\n这是需要完整审核的计划正文。\n\n", count: 30))])
88
- await inspect("long-markdown", overflowing: true)
89
- show([question()])
90
- await inspect("shrink-back")
91
- model.rows = (0..<12).map { NotchRow(id: "task-\($0)", title: "任务 \($0)", child: false, busy: true, unread: false) }
92
- await inspect("twelve-tasks")
93
- show([question(), NotchQuestion(id: "other", question: "Another question", detail: String(repeating: "Long detail. ", count: 100))])
94
- await inspect("short-page-before-long-page")
95
- let ask = model.rows[0].ask!
96
- model.goQuestion(ask: ask, sessionId: "offline", delta: 1)
97
- await inspect("next-page")
98
- model.goQuestion(ask: ask, sessionId: "offline", delta: -1)
99
- await inspect("previous-page")
100
- model.toggleCustomField(ask: ask, sessionId: "offline")
101
- await inspect("custom-answer")
102
- print("FAILURES=\(failures)")
103
- exit(failures == 0 ? 0 : 1)
104
- }
105
- app.run()
106
- withExtendedLifetime(subscriptions) {}
107
- }
108
- }
@@ -1,98 +0,0 @@
1
- import AppKit
2
- import SwiftUI
3
- import Combine
4
- @main enum GeometryProbe {
5
- @MainActor static func main() {
6
- let app = NSApplication.shared
7
- app.setActivationPolicy(.accessory)
8
- let model = BoardModel()
9
- model.maximumExpandedHeight = 460
10
- let panel = NotchPanel(size: NSSize(width: 32,height:90))
11
- let host = NotchHostingView(rootView: RootView(model:model,panelSize:CGSize(width:320,height:460),restSize:CGSize(width:32,height:110)))
12
- host.sizingOptions = []
13
- panel.embedHost(host)
14
- panel.setFrame(NSRect(x:400,y:400,width:32,height:90),display:true)
15
- panel.orderFrontRegardless()
16
- var subscriptions = Set<AnyCancellable>()
17
- Publishers.CombineLatest(model.$currentIslandWidth,model.$currentIslandHeight).receive(on:DispatchQueue.main).sink { w,h in
18
- panel.resizeAnchored(to: NSSize(width:w,height:h))
19
- }.store(in:&subscriptions)
20
- var failures=0
21
- func sample(_ tag:String) {
22
- host.layoutSubtreeIfNeeded()
23
- guard let rep=host.bitmapImageRepForCachingDisplay(in:host.bounds) else { return }
24
- host.cacheDisplay(in:host.bounds,to:rep)
25
- let y=rep.pixelsHigh/2
26
- var blacks=[Int]()
27
- for x in 0..<rep.pixelsWide {
28
- if let c=rep.colorAt(x:x,y:y)?.usingColorSpace(.deviceRGB),c.alphaComponent>0.9,c.redComponent<0.05,c.greenComponent<0.05,c.blueComponent<0.05 { blacks.append(x) }
29
- }
30
- let gap=rep.pixelsWide-1-(blacks.last ?? -1)
31
- if abs(panel.frame.maxX - 432) > 0.01 { failures += 1 }
32
- if gap>1 { failures += 1 }
33
- if let corner=rep.colorAt(x:0,y:2), corner.alphaComponent > 0.1 { failures += 1; print("LEFT_CORNER_CLIPPED") }
34
- print("\(tag) width=\(rep.pixelsWide) black=\(blacks.first ?? -1)...\(blacks.last ?? -1) rightGap=\(gap)")
35
- }
36
- Task { @MainActor in
37
- try? await Task.sleep(for:.milliseconds(300));sample("rest")
38
- for hover in [true,false,true,false] {
39
- model.isPillHovered=hover
40
- model.currentIslandWidth=hover ? 42:38
41
- model.currentIslandHeight=hover ? 66:68
42
- for i in 0..<10 {try? await Task.sleep(for:.milliseconds(20));sample("hover-\(hover)-\(i)")}
43
- }
44
- for hover in [true,false,true,false,true,false] {
45
- model.isPillHovered=hover
46
- try? await Task.sleep(for:.milliseconds(35));sample("rapid-hover")
47
- }
48
- for expanded in [true,false,true,false] {
49
- model.expanded=expanded
50
- for i in 0..<15 {try? await Task.sleep(for:.milliseconds(20));sample("expanded-\(expanded)-\(i)")}
51
- }
52
- for available in [100.0, 768.0, 1080.0, 1600.0] {
53
- let layout = NotchScreenLayout(availableHeight: available)
54
- if abs(layout.maximumHeight + 2 * layout.edgeInset - available) > 0.01 { failures += 1 }
55
- if layout.maximumHeight <= 0 { failures += 1 }
56
- }
57
- @MainActor func setQuestion(description: String) {
58
- let row: [String: Any] = ["id":"height-test", "title":"height test", "child":false, "busy":false, "unread":false, "ask":["id":"height-ask", "questions":[["id":"height-q", "question":"Height regression", "options":[["label":"Test option", "description":description]]]]]]
59
- model.rows = [try! JSONDecoder().decode(NotchRow.self, from: JSONSerialization.data(withJSONObject:row))]
60
- }
61
- model.maximumExpandedHeight = 900
62
- setQuestion(description: String(repeating: "Long content for measuring the expanded panel. ", count:100))
63
- model.expanded = true
64
- try? await Task.sleep(for:.milliseconds(500))
65
- sample("long-content-900")
66
- if abs(panel.frame.height - 900) > 1 { failures += 1; print("LONG_HEIGHT=\(panel.frame.height) EXPECTED=900") }
67
- model.maximumExpandedHeight = 560
68
- try? await Task.sleep(for:.milliseconds(500))
69
- sample("screen-resized-560")
70
- if abs(panel.frame.height - 560) > 1 { failures += 1; print("SCREEN_HEIGHT=\(panel.frame.height) EXPECTED=560") }
71
- model.maximumExpandedHeight = 900
72
- setQuestion(description:"Short content.")
73
- try? await Task.sleep(for:.milliseconds(500))
74
- sample("short-content")
75
- if panel.frame.height >= 460 { failures += 1; print("SHORT_HEIGHT=\(panel.frame.height) EXPECTED<460") }
76
- model.expanded=false
77
- model.isPillHovered=false
78
- for (busy,done,failed,expected) in [(1,0,0,44.0),(1,1,0,72.0),(1,0,1,72.0),(1,1,1,100.0),(0,1,0,44.0)] {
79
- var rows: [[String:Any]] = []
80
- if busy>0 { rows.append(["id":"busy", "title":"Busy", "child":false,"busy":true,"unread":false]) }
81
- if done>0 { rows.append(["id":"done", "title":"Done", "child":false,"busy":false,"unread":true]) }
82
- if failed>0 { rows.append(["id":"failed", "title":"Failed", "child":false,"busy":false,"unread":true,"lastTurn":["at":0,"kind":"error","failed":true]]) }
83
- // Exercise the production snapshot entry point so lamp layout advances too.
84
- model.applySnapshot(NotchSnapshot(ok:true,generatedAt:0,origin:"offline",rows:try! JSONDecoder().decode([NotchRow].self,from:JSONSerialization.data(withJSONObject:rows))))
85
- try? await Task.sleep(for:.milliseconds(1500))
86
- sample("lamps-\(busy)-\(done)-\(failed)")
87
- print("LAMP_HEIGHT=\(panel.frame.height) EXPECTED=\(expected)")
88
- if abs(panel.frame.height-expected)>1 { failures += 1 }
89
- }
90
- print("HEIGHT=\(panel.frame.height)")
91
- print("FAILURES=\(failures)")
92
- exit(failures == 0 ? 0 : 1)
93
- }
94
- app.run()
95
- withExtendedLifetime(subscriptions) {}
96
- exit(failures == 0 ? 0 : 1)
97
- }
98
- }
@@ -1,294 +0,0 @@
1
- import AppKit
2
- import SwiftUI
3
- @main enum IdleProbe {
4
- @MainActor static func main() {
5
- let app=NSApplication.shared; app.setActivationPolicy(.accessory)
6
- let output=URL(fileURLWithPath:ProcessInfo.processInfo.environment["NOTCH_IDLE_OUTPUT"] ?? NSTemporaryDirectory()).appendingPathComponent("notch-idle-proof")
7
- try! FileManager.default.createDirectory(at:output,withIntermediateDirectories:true)
8
- var failures=0
9
- func check(_ ok:Bool,_ label:String){if !ok {failures += 1;print("FAIL \(label)")}}
10
- for id in IdleDirector.basics + ["dance"] {
11
- guard let clip=IdleLibrary.shared.clip(id) else {check(false,"resource \(id)");continue}
12
- check(clip.frames.count > 100,"frames \(id)")
13
- for frame in clip.frames {
14
- check(frame.points.count == 192,"path topology")
15
- check(frame.points.flatMap{$0}.allSatisfy{$0.isFinite},"finite geometry")
16
- check(frame.points.allSatisfy{abs($0[0])*40/280 < 15 && abs($0[1])*40/280 < 21},"fits native bounds \(id)")
17
- }
18
- // Every intermediate outline must preserve area: a shifted SVG start index
19
- // used to collapse the robot by joining unrelated boundary vertices.
20
- func area(_ p:[[Double]]) -> Double {
21
- abs(p.indices.reduce(0.0) { sum,i in
22
- let a=p[i], b=p[(i+1)%p.count]
23
- return sum + a[0]*b[1]-a[1]*b[0]
24
- })/2
25
- }
26
- for i in 1..<clip.frames.count {
27
- let a=clip.frames[i-1], b=clip.frames[i]
28
- let mid=IdleInterpolation.mix(a,b,0.5)
29
- check(area(mid.points) >= min(area(a.points),area(b.points))*0.98,"contour does not collapse \(id) frame \(i)")
30
- }
31
- // Interruption visuals from each action's non-neutral middle pose.
32
- for step in 0...15 {
33
- let visibility=step <= 10 ? 1-Double(step)/10 : 1
34
- let suspect=["tilt":33,"scan":37,"balance":129,"sneeze":88,"stretch":72,"nod":64,"dance":612][id] ?? 60
35
- let elapsed=step <= 10 ? 2.0 : (Double(suspect)+Double(step-13)*0.25)/clip.fps
36
- let rendered=IdleInterpolation.sample(clip,at:elapsed*clip.fps)
37
- let view=IdleRobotCanvas(clip:IdleClip(fps:1,duration:7,frames:[rendered]),elapsed:0,visibility:visibility,entering:false)
38
- .frame(width:30,height:42).scaleEffect(4).frame(width:180,height:190).background(Color.black)
39
- let hosting=NSHostingView(rootView:view);hosting.frame=NSRect(x:0,y:0,width:180,height:190)
40
- let panel=NSPanel(contentRect:hosting.frame,styleMask:[.borderless],backing:.buffered,defer:false);panel.contentView=hosting
41
- hosting.layoutSubtreeIfNeeded();hosting.displayIfNeeded()
42
- let rep=hosting.bitmapImageRepForCachingDisplay(in:hosting.bounds)!;hosting.cacheDisplay(in:hosting.bounds,to:rep)
43
- try! rep.representation(using:.png,properties:[:])!.write(to:output.appendingPathComponent("\(id)-\(step).png"));panel.close()
44
- }
45
- }
46
- for _ in 0..<100 {
47
- check((5...10).contains(IdleDirector.restDuration()), "rest lasts 5–10 seconds")
48
- let d=IdleDirector(),start=Date()
49
- d.play("dance",at:start)
50
- let duration=d.currentDuration
51
- check((3...5).contains(duration), "chameleon lasts 3–5 seconds")
52
- let end=d.frame(at:start.addingTimeInterval(duration))!
53
- let neutral=IdleLibrary.shared.clip("blink")!.frames[0]
54
- check(end.points == neutral.points && end.body == neutral.body, "chameleon returns continuously to neutral")
55
- d.tick(start.addingTimeInterval(duration+0.001))
56
- check(d.action == nil, "chameleon completes at sampled duration")
57
- d.stop()
58
- }
59
- let ids = IdleDirector.basics + ["dance"]
60
- for from in ids { for to in ids {
61
- let director = IdleDirector()
62
- let start = Date()
63
- director.play(from, at:start)
64
- let interrupt = start.addingTimeInterval(2.13)
65
- let before = director.frame(at:interrupt)!
66
- let previous=director.frame(at:interrupt.addingTimeInterval(-1.0/240))!
67
- director.play(to, at:interrupt)
68
- let after = director.frame(at:interrupt)!
69
- check(before.points == after.points && before.body == after.body, "switch continuity \(from) -> \(to)")
70
- check(zip(before.eyes,after.eyes).allSatisfy{$0.x == $1.x && $0.h == $1.h && $0.angle == $1.angle}, "eye continuity")
71
- let dt=0.0001
72
- let next=director.frame(at:interrupt.addingTimeInterval(dt))!
73
- let error=after.points.indices.map { i in
74
- hypot((next.points[i][0]-after.points[i][0])/dt-(before.points[i][0]-previous.points[i][0])*240,
75
- (next.points[i][1]-after.points[i][1])/dt-(before.points[i][1]-previous.points[i][1])*240)
76
- }.max()!
77
- check(error < 1,"switch carries velocity \(from) -> \(to)")
78
- director.stop()
79
- }}
80
- for entering in [false,true] {
81
- let id=entering ? "cube-in":"satellite-out"
82
- let clip=IdleLibrary.shared.clip(id)!
83
- let neutral=IdleLibrary.shared.clip("blink")!.frames[0]
84
- for i in 0...20 {
85
- let t=Double(i)/20
86
- let target=IdleInterpolation.sample(clip,at:t*clip.duration*clip.fps)
87
- let sample=entering ? target:IdleInterpolation.mix(neutral,target,IdleInterpolation.smooth(t*clip.duration/0.22))
88
- let visibility=entering ? IdleInterpolation.smooth(t/0.32):1-IdleInterpolation.smooth((t-0.65)/0.35)
89
- let view=IdleRobotCanvas(clip:IdleClip(fps:1,duration:7,frames:[sample]),elapsed:0,visibility:visibility,entering:entering)
90
- .frame(width:30,height:42).scaleEffect(4).frame(width:180,height:190).background(Color.black)
91
- let hosting=NSHostingView(rootView:view);hosting.frame=NSRect(x:0,y:0,width:180,height:190)
92
- let panel=NSPanel(contentRect:hosting.frame,styleMask:[.borderless],backing:.buffered,defer:false);panel.contentView=hosting
93
- hosting.layoutSubtreeIfNeeded();hosting.displayIfNeeded()
94
- let rep=hosting.bitmapImageRepForCachingDisplay(in:hosting.bounds)!;hosting.cacheDisplay(in:hosting.bounds,to:rep)
95
- try! rep.representation(using:.png,properties:[:])!.write(to:output.appendingPathComponent("\(id)-\(i).png"));panel.close()
96
- }
97
- }
98
- for i in 0...16 {
99
- let t=Double(i)/16,model=BoardModel()
100
- model.rows=[NotchRow(id:"done",title:"done",child:false,busy:false,unread:true),NotchRow(id:"failed",title:"failed",child:false,busy:false,unread:true,lastTurn:NotchLastTurn(at:0,kind:"error",failed:true))]
101
- let flight=StatusFlight(failed:false,startedAt:Date().addingTimeInterval(-t*StatusFlight.duration),busyBefore:1,destinationBefore:1,returnsToRunning:false)
102
- model.statusFlight=flight
103
- model.orbitLayout=OrbitLayout.flight(from:OrbitLayout(top:1,middle:1,bottom:1),to:OrbitLayout(top:1,middle:0,bottom:1),progress:t,flight:flight)
104
- let view=RootView(model:model,panelSize:CGSize(width:320,height:460),restSize:CGSize(width:38,height:44))
105
- .frame(width:38,height:model.orbitLayout.height+24).scaleEffect(3,anchor:.top)
106
- .frame(width:160,height:340,alignment:.top).background(Color.gray.opacity(0.3))
107
- let hosting=NSHostingView(rootView:view);hosting.frame=NSRect(x:0,y:0,width:160,height:340)
108
- let panel=NSPanel(contentRect:hosting.frame,styleMask:[.borderless],backing:.buffered,defer:false);panel.contentView=hosting
109
- hosting.layoutSubtreeIfNeeded();hosting.displayIfNeeded()
110
- let rep=hosting.bitmapImageRepForCachingDisplay(in:hosting.bounds)!;hosting.cacheDisplay(in:hosting.bounds,to:rep)
111
- try! rep.representation(using:.png,properties:[:])!.write(to:output.appendingPathComponent("layout-\(i).png"));panel.close()
112
- }
113
- for i in 0...100 {
114
- let t=Double(i)/100
115
- let q=OrbitLayout.mix(OrbitLayout(middle:1),OrbitLayout(decision:1),IdleInterpolation.smooth(t))
116
- check(abs(q.height-20)<0.00001,"single doing-to-decision keeps shell height")
117
- check(abs(q.middleY-10)<0.00001,"single doing-to-decision stays at same center")
118
- }
119
- for i in 0...100 {
120
- let q=OrbitLayout(top:1,middle:1,bottom:Double(i)/100)
121
- check(q.bottomY+10 <= q.height+0.00001,"fading last disk stays within shrinking shell")
122
- }
123
- for distance in stride(from:0.0,through:20.0,by:0.1) {
124
- check(StatusSeparation.opacity(weight:1,distance:distance)==0,"removed disk vanishes before contact")
125
- }
126
- check(StatusSeparation.opacity(weight:1,distance:28)==1,"separated disk starts fully visible")
127
- let sleepClip=IdleLibrary.shared.clip("sleep")!
128
- check(sleepClip.duration==9,"sleep includes four-second closed-eye plateau")
129
- for t in [2.0,3.0,4.0,5.0] {
130
- check(IdleInterpolation.sample(sleepClip,at:t*sleepClip.fps).eyes.allSatisfy{$0.h/$0.w<0.25},"sleep eyes remain closed for the hold")
131
- }
132
- let cube=IdleLibrary.shared.clip("cube-in")!
133
- check(cube.frames.allSatisfy{$0.points.count==192 && $0.eyes.count==2},"cube keeps contour and eye slots across back-facing poses")
134
- for i in 0...100 {
135
- let departure=RobotDeparture(progress:Double(i)/100)
136
- if departure.statusOpacity > 0 { check(departure.scale <= 0.061,"status cannot appear before robot reaches tiny point") }
137
- if Double(i)/100*RobotDeparture.duration <= 0.666 { check(departure.statusScale <= 0.0001,"status remains a tiny point until colour handoff finishes") }
138
- }
139
- for i in 0...20 {
140
- let t=Double(i)/20,departure=RobotDeparture(progress:t)
141
- let model=BoardModel()
142
- model.rows=[NotchRow(id:"ask",title:"local",child:false,busy:false,unread:false,ask:NotchAsk(id:"ask",questions:[]))]
143
- model.orbitLayout=OrbitLayout(decision:1)
144
- let clip=IdleLibrary.shared.clip("satellite-out")!
145
- let target=IdleInterpolation.sample(clip,at:t*RobotDeparture.duration*clip.fps)
146
- let neutral=IdleLibrary.shared.clip("blink")!.frames[0]
147
- let frame=IdleInterpolation.mix(neutral,target,IdleInterpolation.smooth(t*RobotDeparture.duration/0.22))
148
- let view=ZStack {
149
- StatusOrbitView(model:model,workReveal:departure.statusScale).opacity(departure.statusOpacity)
150
- IdleRobotCanvas(clip:IdleClip(fps:1,duration:7,frames:[frame]),elapsed:0)
151
- .frame(width:30,height:42).scaleEffect(departure.scale).opacity(departure.opacity)
152
- }.frame(width:38,height:44).background(Color.black).scaleEffect(4).frame(width:180,height:200).background(Color.gray.opacity(0.2))
153
- let hosting=NSHostingView(rootView:view);hosting.frame=NSRect(x:0,y:0,width:180,height:200)
154
- let panel=NSPanel(contentRect:hosting.frame,styleMask:[.borderless],backing:.buffered,defer:false);panel.contentView=hosting
155
- hosting.layoutSubtreeIfNeeded();hosting.displayIfNeeded()
156
- let rep=hosting.bitmapImageRepForCachingDisplay(in:hosting.bounds)!;hosting.cacheDisplay(in:hosting.bounds,to:rep)
157
- try! rep.representation(using:.png,properties:[:])!.write(to:output.appendingPathComponent("tiny-handoff-\(i).png"));panel.close()
158
- }
159
- check(DecisionMorph(amount:0).trim==0.7 && DecisionMorph(amount:0).fill==0,"blue starts hollow with gap")
160
- check(DecisionMorph(amount:1).trim==0 && DecisionMorph(amount:1).fill==1,"yellow ends closed and solid")
161
- for text in ["!","1","3","8","12","99","100"] {
162
- let bounds=CenteredStatusGlyph.path(text).boundingRect
163
- check(abs(bounds.midX)<0.00001 && (text == "!" || abs(bounds.midY)<0.00001),"visible glyph ink centered: \(text)")
164
- check(bounds.width<=12.00001,"multi-digit glyph fits the shared clipping width")
165
- }
166
- let step=0.000001
167
- let penEndSpeed=(DecisionMorph(amount:0).trim-DecisionMorph(amount:step).trim)*2 * Double.pi/(step*DecisionMorph.resumeDuration)
168
- check(abs(penEndSpeed-DecisionSpin.runningVelocity)<0.0001,"resume pen ends at running angular velocity")
169
- let blueEndSpeed=(StatusBirth(progress:1).blueDraw-StatusBirth(progress:1-step).blueDraw)*2 * Double.pi*0.7/(step*(RobotDeparture.duration-0.666))
170
- check(abs(blueEndSpeed-StatusBirth.bluePenVelocity)<0.0001,"birth pen hands its exact velocity to rotation")
171
- for i in 25...99 {
172
- let a=StatusBirth(progress:Double(i)/100).blueDraw,b=StatusBirth(progress:Double(i+1)/100).blueDraw
173
- check(abs((b-a)-0.01/0.76)<0.00001,"blue appearance draws at constant speed")
174
- }
175
- check(DecisionClosing(amount:0.5).trim >= DecisionClosing(amount:0).trim,"09 must close the blue ring instead of erasing it")
176
- for i in 0...100 {
177
- let q=Double(i)/100,c=DecisionClosing(amount:q)
178
- check(c.fill == 0 || c.trim > 0.99999,"09 fill waits for a closed ring")
179
- if i>0 {check(c.trim>=DecisionClosing(amount:q-0.01).trim,"09 arc advances monotonically")}
180
- }
181
- let phaseModel=BoardModel()
182
- var phaseRows=[NotchRow(id:"phase-a",title:"a",child:false,busy:true,unread:false),NotchRow(id:"phase-b",title:"b",child:false,busy:true,unread:false)]
183
- phaseModel.applySnapshot(NotchSnapshot(ok:true,generatedAt:0,origin:"test",rows:phaseRows))
184
- check(abs(phaseModel.decisionVelocity(at:Date())-DecisionSpin.runningVelocity)<0.0001,"cold snapshot starts at normal speed")
185
- phaseRows[0].busy=false;phaseRows[0].unread=true
186
- phaseModel.applySnapshot(NotchSnapshot(ok:true,generatedAt:0,origin:"test",rows:phaseRows))
187
- if let f=phaseModel.statusFlight {
188
- let end=f.startedAt.addingTimeInterval(StatusFlight.duration)
189
- let error=phaseModel.decisionAngle(at:end)-(f.angle+StatusFlight.duration*DecisionSpin.runningVelocity)
190
- check(abs(sin(error/2))<0.001,"02 flight return and running ring must share phase")
191
- } else { check(false,"completion repro starts flight") }
192
- let epoch=Date()
193
- let stop=DecisionSpin(began:epoch,angle:1.2,initialVelocity:DecisionSpin.runningVelocity,finalVelocity:0)
194
- let reversal=epoch.addingTimeInterval(0.24)
195
- let resume=DecisionSpin(began:reversal,angle:stop.position(at:reversal),initialVelocity:stop.velocity(at:reversal),finalVelocity:DecisionSpin.runningVelocity)
196
- check(abs(stop.position(at:reversal)-resume.position(at:reversal))<0.00001,"reverse preserves ring position")
197
- check(abs(stop.velocity(at:reversal)-resume.velocity(at:reversal))<0.00001,"reverse preserves ring velocity")
198
- for i in 1...100 {
199
- let a=epoch.addingTimeInterval(Double(i-1)/100),b=epoch.addingTimeInterval(Double(i)/100)
200
- check(stop.position(at:b)>=stop.position(at:a),"ring never reverses while stopping")
201
- check(resume.velocity(at:b)>=0,"ring resumes forward")
202
- }
203
- check(DecisionMorph(amount:0.64).flip < 0.00001,"number resolves before most of the brush is drawn")
204
- check(DecisionMorph(amount:0.99).fill == 1,"disk supports the early symbol flip")
205
- for i in 0...100 {
206
- let m=DecisionMorph(amount:Double(i)/100)
207
- check(m.draw == 0 || m.fill < 0.00001,"blue pen starts only after yellow fill clears")
208
- let birth=StatusBirth(progress:Double(i)/100)
209
- check(birth.draw == 0 || birth.travel > 0.99999,"pen reaches rim before drawing")
210
- check(birth.fill == 0 || birth.draw > 0.99999,"yellow ring closes before filling")
211
- check(birth.text == 0 || birth.draw > 0.99999,"text follows completed stroke")
212
- }
213
- for i in 0...12 {
214
- let t=Double(i)/12
215
- let view=WorkingDecisionGlyph(amount:t,number:3,angle:-Double.pi/2).scaleEffect(6).frame(width:160,height:160).background(Color.black)
216
- let hosting=NSHostingView(rootView:view);hosting.frame=NSRect(x:0,y:0,width:160,height:160)
217
- let panel=NSPanel(contentRect:hosting.frame,styleMask:[.borderless],backing:.buffered,defer:false);panel.contentView=hosting
218
- hosting.layoutSubtreeIfNeeded();hosting.displayIfNeeded()
219
- let rep=hosting.bitmapImageRepForCachingDisplay(in:hosting.bounds)!;hosting.cacheDisplay(in:hosting.bounds,to:rep)
220
- try! rep.representation(using:.png,properties:[:])!.write(to:output.appendingPathComponent("decision-flip-\(i).png"));panel.close()
221
- }
222
- for decision in [false,true] {
223
- for i in 0...12 {
224
- let view=StatusBirthGlyph(progress:Double(i)/12,decision:decision,number:3,angle:-Double.pi/2).scaleEffect(6).frame(width:160,height:160).background(Color.black)
225
- let hosting=NSHostingView(rootView:view);hosting.frame=NSRect(x:0,y:0,width:160,height:160)
226
- let panel=NSPanel(contentRect:hosting.frame,styleMask:[.borderless],backing:.buffered,defer:false);panel.contentView=hosting
227
- hosting.layoutSubtreeIfNeeded();hosting.displayIfNeeded()
228
- let rep=hosting.bitmapImageRepForCachingDisplay(in:hosting.bounds)!;hosting.cacheDisplay(in:hosting.bounds,to:rep)
229
- try! rep.representation(using:.png,properties:[:])!.write(to:output.appendingPathComponent("birth-\(decision ? "yellow":"blue")-\(i).png"));panel.close()
230
- }
231
- }
232
- for scale in [1.0,2.0,3.0] {
233
- for kind in ["blue","green","red","yellow"] {
234
- let model=BoardModel()
235
- let ask=kind == "yellow" ? NotchAsk(id:"q",questions:[]):nil
236
- let failed=kind == "red" ? NotchLastTurn(at:0,kind:"error",failed:true):nil
237
- model.rows=[NotchRow(id:"a",title:"local",child:false,busy:kind == "blue",unread:kind == "green" || kind == "red",lastTurn:failed,ask:ask)]
238
- model.orbitLayout=OrbitLayout(top:kind == "green" ? 1:0,middle:kind == "blue" ? 1:0,bottom:kind == "red" ? 1:0,decision:kind == "yellow" ? 1:0)
239
- let view=StatusOrbitView(model:model,reduceMotionOverride:true).frame(width:60,height:120).scaleEffect(scale).frame(width:60*scale,height:120*scale).background(Color.black)
240
- let host=NSHostingView(rootView:view);host.frame=NSRect(x:0,y:0,width:60*scale,height:120*scale)
241
- let panel=NSPanel(contentRect:host.frame,styleMask:[.borderless],backing:.buffered,defer:false);panel.contentView=host
242
- host.layoutSubtreeIfNeeded();host.displayIfNeeded()
243
- let rep=host.bitmapImageRepForCachingDisplay(in:host.bounds)!;host.cacheDisplay(in:host.bounds,to:rep)
244
- try! rep.representation(using:.png,properties:[:])!.write(to:output.appendingPathComponent("alignment-\(kind)-\(Int(scale)).png"));panel.close()
245
- }
246
- }
247
- let tourDirector=IdleDirector(),tourStart=Date()
248
- tourDirector.play("dance",at:tourStart)
249
- let end=tourDirector.currentDuration
250
- for (i,t) in [0.0,0.10,0.25,0.45,2.0,end-0.45,end-0.20,end,end+0.15,end+0.37].enumerated() {
251
- let now=tourStart.addingTimeInterval(t)
252
- if t >= end {tourDirector.tick(now)}
253
- let frame=tourDirector.displayFrame(at:now)!
254
- let view=IdleRobotCanvas(clip:IdleClip(fps:1,duration:7,frames:[frame]),elapsed:0).frame(width:30,height:42).scaleEffect(4).frame(width:180,height:190).background(Color.black)
255
- let host=NSHostingView(rootView:view);host.frame=NSRect(x:0,y:0,width:180,height:190)
256
- let panel=NSPanel(contentRect:host.frame,styleMask:[.borderless],backing:.buffered,defer:false);panel.contentView=host
257
- host.layoutSubtreeIfNeeded();host.displayIfNeeded()
258
- let rep=host.bitmapImageRepForCachingDisplay(in:host.bounds)!;host.cacheDisplay(in:host.bounds,to:rep)
259
- try! rep.representation(using:.png,properties:[:])!.write(to:output.appendingPathComponent("tour-dance-\(i).png"));panel.close()
260
- }
261
- tourDirector.stop()
262
- var closures=0
263
- for i in 1..<3000 {
264
- if IdleDirector.blinkClosure(at:Double(i)/100)>0.9 && IdleDirector.blinkClosure(at:Double(i-1)/100)<=0.9 { closures+=1 }
265
- }
266
- check(closures >= 5 && closures <= 9,"natural blinks recur independently of gestures")
267
- for failed in [false,true] {
268
- let flight=StatusFlight(failed:failed,startedAt:Date(),busyBefore:1,destinationBefore:1,returnsToRunning:false)
269
- let initial=OrbitLayout(top:1,middle:1,bottom:1),final=OrbitLayout(top:1,middle:0,bottom:1)
270
- var previous=initial
271
- for i in 0...160 {
272
- let layout=OrbitLayout.flight(from:initial,to:final,progress:Double(i)/160,flight:flight)
273
- check(abs((layout.bottomY-previous.bottomY)-(layout.height-previous.height)) < 0.00001,"red disk and shell share displacement")
274
- check(abs(layout.bottomY-previous.bottomY)<2,"no last-task layout snap")
275
- previous=layout
276
- }
277
- check(previous==final,"last task settles into exact two-light layout")
278
- }
279
- let director=IdleDirector(),presence=IdlePresence()
280
- presence.set(true,director:director,animated:false)
281
- director.play("hop")
282
- presence.set(false,director:director)
283
- check(director.action == nil,"work cancels idle immediately")
284
- let frozen=presence.frozenID
285
- RunLoop.current.run(until:Date().addingTimeInterval(0.1))
286
- let before=presence.visibility
287
- presence.set(true,director:director)
288
- check(presence.visibility==before && presence.frozenID==frozen,"reverse retains geometry")
289
- RunLoop.current.run(until:Date().addingTimeInterval(1.2))
290
- check(presence.visibility == 1,"reverse finishes idle")
291
- presence.stop();director.stop()
292
- print("FAILURES=\(failures)");exit(failures==0 ? 0:1)
293
- }
294
- }