Ben-reported: picker opened on every activation. AppModel.init set region = nil and never read UserDefaults back; saving worked, every launch forgot it. init now loads via loadPersistedRegion() (decode + isStillValid). Selftest phase 2 writes a known region, reloads through the same path, asserts the rect, restores the user's stored value. Coordinator ran it: PICKER-SELFTEST PASS + REGION-PERSIST PASS, 90/90 tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
216 lines
7.9 KiB
Swift
216 lines
7.9 KiB
Swift
import AppKit
|
|
import Darwin
|
|
import Foundation
|
|
import ShotdeckCore
|
|
|
|
/// In-process self-test for the region picker, driven by `SHOTDECK_PICKER_SELFTEST`.
|
|
/// Posts synthetic mouse events through `NSApp.postEvent` only — never CGEventPost/taps.
|
|
@MainActor
|
|
enum PickerSelfTest {
|
|
private static let pointA = NSPoint(x: 200, y: 300)
|
|
private static let pointB = NSPoint(x: 600, y: 600)
|
|
private static let dragSteps = 6
|
|
|
|
/// Called from `main.swift` before `ShotdeckApp.main()`. Returns immediately when the
|
|
/// env var is unset; otherwise waits for launch, drives the production picker, and `exit`s.
|
|
static func runIfRequested() {
|
|
guard let raw = ProcessInfo.processInfo.environment["SHOTDECK_PICKER_SELFTEST"],
|
|
!raw.isEmpty
|
|
else { return }
|
|
|
|
let output = URL(fileURLWithPath: raw, isDirectory: true)
|
|
// Hop onto a plain main-queue turn after NSApp starts. Nested run loops
|
|
// from a Swift Task do not drain NSApp's event queue.
|
|
DispatchQueue.main.async {
|
|
MainActor.assumeIsolated {
|
|
execute(outputDirectory: output)
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func execute(outputDirectory: URL) {
|
|
do {
|
|
try FileManager.default.createDirectory(
|
|
at: outputDirectory,
|
|
withIntermediateDirectories: true
|
|
)
|
|
} catch {
|
|
fail(expected: expectedLabel(), got: "could not create output dir: \(error)")
|
|
}
|
|
|
|
guard let screen = NSScreen.screens.first(where: { $0.frame.contains(pointA) })
|
|
?? NSScreen.screens.first
|
|
else {
|
|
fail(expected: expectedLabel(), got: "no NSScreen")
|
|
}
|
|
|
|
let appKitRect = CGRect(
|
|
x: min(pointA.x, pointB.x),
|
|
y: min(pointA.y, pointB.y),
|
|
width: abs(pointB.x - pointA.x),
|
|
height: abs(pointB.y - pointA.y)
|
|
).intersection(screen.frame)
|
|
let expected = CaptureRegion.fromAppKit(rect: appKitRect, on: screen)
|
|
|
|
let picker = RegionPickerController()
|
|
var result: CaptureRegion??
|
|
picker.pick { region in
|
|
result = .some(region)
|
|
}
|
|
|
|
guard let overlay = overlayWindow(containing: pointA) else {
|
|
fail(expected: format(expected.rect), got: "no overlay window after pick()")
|
|
}
|
|
overlay.makeKey()
|
|
|
|
var eventNumber = 1
|
|
func post(_ type: NSEvent.EventType, at screenPoint: NSPoint, clickCount: Int) {
|
|
let locationInWindow = overlay.convertPoint(fromScreen: screenPoint)
|
|
guard let event = NSEvent.mouseEvent(
|
|
with: type,
|
|
location: locationInWindow,
|
|
modifierFlags: [],
|
|
timestamp: ProcessInfo.processInfo.systemUptime,
|
|
windowNumber: overlay.windowNumber,
|
|
context: nil,
|
|
eventNumber: eventNumber,
|
|
clickCount: clickCount,
|
|
pressure: 1
|
|
) else {
|
|
fail(expected: format(expected.rect), got: "NSEvent.mouseEvent(\(type.rawValue)) returned nil")
|
|
}
|
|
eventNumber += 1
|
|
NSApp.postEvent(event, atStart: false)
|
|
// Local monitors run during NSApp.sendEvent (production dispatch),
|
|
// not during nextEvent dequeue. Drive that same path here.
|
|
NSApp.sendEvent(event)
|
|
}
|
|
|
|
post(.leftMouseDown, at: pointA, clickCount: 1)
|
|
for step in 1...(dragSteps / 2) {
|
|
post(.leftMouseDragged, at: interpolate(step), clickCount: 0)
|
|
}
|
|
|
|
writeOverlayBitmap(overlay, to: outputDirectory.appendingPathComponent("overlay-middrag.png"))
|
|
|
|
for step in ((dragSteps / 2) + 1)...dragSteps {
|
|
post(.leftMouseDragged, at: interpolate(step), clickCount: 0)
|
|
}
|
|
post(.leftMouseUp, at: pointB, clickCount: 1)
|
|
|
|
guard let wrapped = result else {
|
|
fail(expected: format(expected.rect), got: "completion never fired")
|
|
}
|
|
guard let got = wrapped else {
|
|
fail(expected: format(expected.rect), got: "nil")
|
|
}
|
|
|
|
if got.rect != expected.rect {
|
|
fail(expected: format(expected.rect), got: format(got.rect))
|
|
}
|
|
|
|
print("PICKER-SELFTEST PASS rect=\(format(got.rect))")
|
|
fflush(stdout)
|
|
|
|
runRegionPersistPhase()
|
|
exit(0)
|
|
}
|
|
|
|
/// Phase 2: writes a known region under `CaptureRegion.defaultsKey`, reloads it through
|
|
/// `AppModel.loadPersistedRegion()` (the same path init uses), then restores whatever
|
|
/// value was stored before so a real picked region is untouched.
|
|
private static func runRegionPersistPhase() {
|
|
let defaults = UserDefaults.standard
|
|
let previous = defaults.data(forKey: CaptureRegion.defaultsKey)
|
|
|
|
let known = CaptureRegion(
|
|
displayID: CGMainDisplayID(),
|
|
rect: CGRect(x: 10, y: 10, width: 100, height: 100),
|
|
capturedScale: 2.0
|
|
)
|
|
var failure: String?
|
|
if let encoded = try? JSONEncoder().encode(known) {
|
|
defaults.set(encoded, forKey: CaptureRegion.defaultsKey)
|
|
if let loaded = AppModel.loadPersistedRegion() {
|
|
if loaded.rect != known.rect {
|
|
failure = "expected=\(format(known.rect)) got=\(format(loaded.rect))"
|
|
}
|
|
} else {
|
|
failure = "loadPersistedRegion returned nil"
|
|
}
|
|
} else {
|
|
failure = "could not encode CaptureRegion"
|
|
}
|
|
|
|
if let previous {
|
|
defaults.set(previous, forKey: CaptureRegion.defaultsKey)
|
|
} else {
|
|
defaults.removeObject(forKey: CaptureRegion.defaultsKey)
|
|
}
|
|
|
|
if let failure {
|
|
print("REGION-PERSIST FAIL \(failure)")
|
|
fflush(stdout)
|
|
exit(1)
|
|
}
|
|
print("REGION-PERSIST PASS")
|
|
fflush(stdout)
|
|
}
|
|
|
|
private static func interpolate(_ step: Int) -> NSPoint {
|
|
let t = CGFloat(step) / CGFloat(dragSteps)
|
|
return NSPoint(
|
|
x: pointA.x + (pointB.x - pointA.x) * t,
|
|
y: pointA.y + (pointB.y - pointA.y) * t
|
|
)
|
|
}
|
|
|
|
private static func overlayWindow(containing point: NSPoint) -> RegionPickerWindow? {
|
|
let overlays = NSApp.windows.compactMap { $0 as? RegionPickerWindow }
|
|
return overlays.first(where: { $0.coveringScreen.frame.contains(point) }) ?? overlays.first
|
|
}
|
|
|
|
private static func writeOverlayBitmap(_ overlay: RegionPickerWindow, to url: URL) {
|
|
guard let view = overlay.contentView else {
|
|
fail(expected: expectedLabel(), got: "overlay has no contentView")
|
|
}
|
|
overlay.layoutIfNeeded()
|
|
view.layoutSubtreeIfNeeded()
|
|
view.display()
|
|
let bounds = view.bounds
|
|
guard let rep = view.bitmapImageRepForCachingDisplay(in: bounds) else {
|
|
fail(expected: expectedLabel(), got: "bitmapImageRepForCachingDisplay failed")
|
|
}
|
|
view.cacheDisplay(in: bounds, to: rep)
|
|
guard let png = rep.representation(using: .png, properties: [:]) else {
|
|
fail(expected: expectedLabel(), got: "PNG encode failed")
|
|
}
|
|
do {
|
|
try png.write(to: url)
|
|
} catch {
|
|
fail(expected: expectedLabel(), got: "could not write \(url.path): \(error)")
|
|
}
|
|
print(url.path)
|
|
fflush(stdout)
|
|
}
|
|
|
|
private static func expectedLabel() -> String {
|
|
format(CGRect(
|
|
x: min(pointA.x, pointB.x),
|
|
y: min(pointA.y, pointB.y),
|
|
width: abs(pointB.x - pointA.x),
|
|
height: abs(pointB.y - pointA.y)
|
|
))
|
|
}
|
|
|
|
private static func format(_ rect: CGRect) -> String {
|
|
"(\(rect.origin.x), \(rect.origin.y), \(rect.width), \(rect.height))"
|
|
}
|
|
|
|
private static func fail(expected: String, got: String) -> Never {
|
|
print("PICKER-SELFTEST FAIL expected=\(expected) got=\(got)")
|
|
fflush(stdout)
|
|
exit(1)
|
|
}
|
|
}
|