dsh-notch 0.3.1
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.
- package/BASELINE.md +13 -0
- package/DESIGN.md +34 -0
- package/LICENSE +21 -0
- package/README.en.md +136 -0
- package/README.md +145 -0
- package/cordis.yml +5 -0
- package/docs/motion-audit.md +65 -0
- package/docs/motion-continuity.md +77 -0
- package/docs/releases/v0.3.0.md +20 -0
- package/dshx.yml +14 -0
- package/macos/Package.swift +17 -0
- package/macos/STATUS-MOTION.md +49 -0
- package/macos/Sources/Client.swift +139 -0
- package/macos/Sources/IdleRobot.swift +423 -0
- package/macos/Sources/NotchMarkdown.swift +266 -0
- package/macos/Sources/Panel.swift +153 -0
- package/macos/Sources/Resources/Idle/balance.json +1 -0
- package/macos/Sources/Resources/Idle/blink.json +1 -0
- package/macos/Sources/Resources/Idle/cube-in.json +1 -0
- package/macos/Sources/Resources/Idle/dance.json +1 -0
- package/macos/Sources/Resources/Idle/hop.json +1 -0
- package/macos/Sources/Resources/Idle/nod.json +1 -0
- package/macos/Sources/Resources/Idle/satellite-in.json +1 -0
- package/macos/Sources/Resources/Idle/satellite-out.json +1 -0
- package/macos/Sources/Resources/Idle/scan.json +1 -0
- package/macos/Sources/Resources/Idle/sleep.json +1 -0
- package/macos/Sources/Resources/Idle/sneeze.json +1 -0
- package/macos/Sources/Resources/Idle/stretch.json +1 -0
- package/macos/Sources/Resources/Idle/tilt.json +1 -0
- package/macos/Sources/ReviewStudio.swift +337 -0
- package/macos/Sources/RootView.swift +1093 -0
- package/macos/Sources/StatusOrbit.swift +642 -0
- package/macos/Sources/main.swift +153 -0
- package/macos/Tests/ExpandedHeightProbe.swift +108 -0
- package/macos/Tests/GeometryProbe.swift +98 -0
- package/macos/Tests/IdleProbe.swift +294 -0
- package/macos/Tests/MotionProbe.swift +106 -0
- package/macos/Tests/OutcomeProbe.swift +133 -0
- package/macos/Tests/ScrollbarProbe.swift +91 -0
- package/macos/Tests/expanded-height.sh +13 -0
- package/macos/Tests/geometry.sh +14 -0
- package/macos/Tests/idle.sh +14 -0
- package/macos/Tests/motion.sh +14 -0
- package/macos/Tests/outcome.sh +14 -0
- package/macos/Tests/scrollbar.sh +13 -0
- package/package.json +46 -0
- package/src/board.ts +299 -0
- package/src/browse-sync.ts +59 -0
- package/src/dsh-notch.ts +60 -0
- package/src/http.ts +215 -0
- package/src/session-state.ts +31 -0
- package/src/store.ts +49 -0
- package/src/types.ts +65 -0
- package/tests/answer-lifecycle.test.mjs +23 -0
- package/tests/board.test.mjs +7 -0
- package/tests/browse-sync.test.mjs +20 -0
- package/tests/mirror.test.mjs +4 -0
- package/tests/peer-range.test.mjs +21 -0
- package/tests/session-state.test.mjs +54 -0
- package/tests/unread-hold.test.mjs +90 -0
- package/tools/desktop-shell/README.md +56 -0
- package/tools/desktop-shell/child-log.mjs +6 -0
- package/tools/desktop-shell/child-log.test.mjs +14 -0
- package/tools/desktop-shell/package-recovery.py +78 -0
- package/tools/desktop-shell/recovery-probe.cjs +50 -0
- package/tools/desktop-shell/renderer-recovery.mjs +95 -0
- package/tools/desktop-shell/renderer-recovery.test.mjs +24 -0
- package/tools/diagnostics/completion-watch.py +101 -0
- package/tools/diagnostics/completion-watch.test.py +17 -0
- package/tools/diagnostics/renderer-diagnostics.mjs +56 -0
- package/tools/diagnostics/renderer-diagnostics.test.mjs +32 -0
- package/tools/idle/LICENSE.open-bot-motion +21 -0
- package/tools/idle/export.mjs +40 -0
- package/tools/idle/index.html +4 -0
- package/tools/idle/motions.js +83 -0
- package/tools/idle/original.js +1673 -0
- package/tools/idle/renderer.js +1673 -0
- package/tools/recording/Client.swift +76 -0
- package/tools/recording/DemoCatalog.swift +174 -0
- package/tools/recording/README.md +23 -0
- package/tools/recording/build.sh +41 -0
- package/tools/recording/main.swift +100 -0
- package/tools/review/ReviewStudio.swift +236 -0
- package/tools/review/audit.sh +19 -0
- package/tools/review/build.sh +7 -0
- package/tools/review/main.swift +158 -0
- package/tools/review/render-audit.swift +32 -0
|
@@ -0,0 +1,153 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
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
|
+
}
|