Files
shotdeck/Sources/Shotdeck/PickerSelfTest.swift

710 lines
28 KiB
Swift

import AppKit
import CoreGraphics
import Darwin
import Foundation
import ImageIO
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)
}
}
}
/// Standalone HISTORY phase when `SHOTDECK_HISTORY_SELFTEST` is set without
/// the picker chain. Waits for NSApp like the other phases, then exits.
static func runHistoryIfRequested() {
guard let raw = ProcessInfo.processInfo.environment["SHOTDECK_HISTORY_SELFTEST"],
!raw.isEmpty
else { return }
_ = raw
DispatchQueue.main.async {
MainActor.assumeIsolated {
runHistoryPhase()
}
}
}
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()
// Hop off this MainActor job so the SEND-TRUTH Task can run; do not
// exit(0) here — runSendTruthPhase prints its own PASS/FAIL, then
// chains to HISTORY, then UPDATE-SELFTEST (or exits if that phase is
// not requested).
runSendTruthPhase()
}
/// 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)
}
/// Phase 3: drive SendController's share-outcome seams with no AirDrop sheet.
/// Fail path must leave the session open in the temp spool; success path archives
/// and mints a fresh empty session. Scheduled as a new MainActor job because this
/// function is called from inside `execute()` — a nested run-loop wait would never
/// let the Task start. On success, chains to HISTORY instead of exiting.
private static func runSendTruthPhase() {
Task { @MainActor in
do {
try await executeSendTruth()
print("SEND-TRUTH PASS")
fflush(stdout)
} catch {
print("SEND-TRUTH FAIL \(error)")
fflush(stdout)
exit(1)
}
runHistoryPhase()
}
}
/// Phase 4: drive HistoryStore record/prune in a temp root. Env-gated by
/// `SHOTDECK_HISTORY_SELFTEST` the same way UPDATE is gated, and also runs
/// whenever the picker self-test chain is already in flight so a full
/// self-test prints HISTORY PASS|FAIL. Chains to UPDATE-SELFTEST (or exits).
private static func runHistoryPhase() {
let historyRequested = ProcessInfo.processInfo.environment["SHOTDECK_HISTORY_SELFTEST"]
let pickerRequested = ProcessInfo.processInfo.environment["SHOTDECK_PICKER_SELFTEST"]
let shouldRun = (historyRequested.map { !$0.isEmpty } ?? false)
|| (pickerRequested.map { !$0.isEmpty } ?? false)
guard shouldRun else {
if !startUpdateSelfTestIfRequested() {
exit(0)
}
return
}
Task { @MainActor in
do {
try await executeHistorySelfTest()
print("HISTORY PASS")
fflush(stdout)
if !startUpdateSelfTestIfRequested() {
exit(0)
}
} catch {
print("HISTORY FAIL \(error)")
fflush(stdout)
exit(1)
}
}
}
private static func executeHistorySelfTest() async throws {
let fm = FileManager.default
let root = fm.temporaryDirectory
.appendingPathComponent("shotdeck-history-selftest-\(UUID().uuidString)", isDirectory: true)
defer { try? fm.removeItem(at: root) }
let paths = try AppSupportPaths(
root: root.appendingPathComponent("root", isDirectory: true),
outbox: root.appendingPathComponent("outbox", isDirectory: true),
watchFolder: root.appendingPathComponent("watch", isDirectory: true)
)
let store = try HistoryStore(paths: paths)
let sources = root.appendingPathComponent("user-sources", isDirectory: true)
try fm.createDirectory(at: sources, withIntermediateDirectories: true)
var recorded: [HistoryEntry] = []
for i in 0..<12 {
let source = sources.appendingPathComponent("source-\(i).pdf")
try Data("%PDF-1.4\n%hist-\(i)\n%%EOF\n".utf8).write(to: source)
let entry = try await store.recordSentPDF(
sourceURL: source,
sessionID: UUID(),
pageCount: 1,
sentAt: Date(timeIntervalSince1970: 1_800_000_000 + TimeInterval(i))
)
recorded.append(entry)
let onDisk = try Data(contentsOf: source)
guard onDisk == Data("%PDF-1.4\n%hist-\(i)\n%%EOF\n".utf8) else {
throw HistorySelfTestError.detail("user source PDF was modified: \(source.path)")
}
}
let listed = await store.listPDFs()
guard listed.count == 10 else {
throw HistorySelfTestError.detail("listPDFs count \(listed.count) want 10")
}
let wantNewest = Array(recorded.suffix(10).reversed())
guard listed.map(\.id) == wantNewest.map(\.id) else {
throw HistorySelfTestError.detail("listPDFs did not return the 10 newest")
}
let pdfsDir = paths.root.appendingPathComponent("history/pdfs", isDirectory: true)
for entry in recorded.prefix(2) {
let gone = pdfsDir.appendingPathComponent(entry.fileName)
guard !fm.fileExists(atPath: gone.path) else {
throw HistorySelfTestError.detail("oldest PDF still on disk: \(gone.path)")
}
}
let same = sources.appendingPathComponent("same.pdf")
try Data("%PDF-1.4\n%same\n%%EOF\n".utf8).write(to: same)
let first = try await store.recordSentPDF(
sourceURL: same, sessionID: nil, pageCount: 1,
sentAt: Date(timeIntervalSince1970: 1_800_000_100)
)
let second = try await store.recordSentPDF(
sourceURL: same, sessionID: nil, pageCount: 1,
sentAt: Date(timeIntervalSince1970: 1_800_000_101)
)
guard first.id != second.id, first.fileURL.path != second.fileURL.path,
fm.fileExists(atPath: first.fileURL.path),
fm.fileExists(atPath: second.fileURL.path),
fm.fileExists(atPath: same.path)
else {
throw HistorySelfTestError.detail("re-record same source did not produce two distinct copies")
}
let spool = try SpoolStore(paths: paths)
let png = try makeTinyPNGData()
let base = Date(timeIntervalSince1970: 1_800_100_000)
var n = 0
var oldestSessionID: UUID?
for count in [5, 15, 15] {
if n == 0 {
oldestSessionID = try await spool.currentSession().id
}
for _ in 0..<count {
_ = try await spool.append(
pngData: png, pixelWidth: 64, pixelHeight: 48, scale: 1,
capturedAt: base.addingTimeInterval(TimeInterval(n))
)
n += 1
}
_ = try await spool.archiveCurrent(pdfFileName: "Redline-hist-\(n).pdf")
}
let openCapture = try await spool.append(
pngData: png, pixelWidth: 64, pixelHeight: 48, scale: 1,
capturedAt: Date(timeIntervalSince1970: 1_900_000_000)
)
let openSession = try await spool.currentSession()
let openPNG = paths.sessionDirectory(openSession.id).appendingPathComponent(openCapture.fileName)
let openBytes = try Data(contentsOf: openPNG)
try await store.pruneImages()
let images = try await store.listImages(limit: 50)
guard images.count == 30 else {
throw HistorySelfTestError.detail("listImages count \(images.count) want 30")
}
if let oldestSessionID {
let oldDir = paths.archiveDirectory(oldestSessionID)
guard !fm.fileExists(atPath: oldDir.path) else {
throw HistorySelfTestError.detail("emptied archive session still on disk")
}
}
guard fm.fileExists(atPath: openPNG.path), try Data(contentsOf: openPNG) == openBytes else {
throw HistorySelfTestError.detail("open session PNG was touched")
}
let remainingOpen = try await spool.currentSession()
guard remainingOpen.id == openSession.id,
remainingOpen.captures.map(\.id) == [openCapture.id]
else {
throw HistorySelfTestError.detail("open session manifest was touched")
}
}
private static func executeSendTruth() async throws {
let fm = FileManager.default
let root = fm.temporaryDirectory
.appendingPathComponent("shotdeck-send-truth-\(UUID().uuidString)", isDirectory: true)
defer { try? fm.removeItem(at: root) }
let paths = try AppSupportPaths(
root: root,
outbox: root.appendingPathComponent("outbox", isDirectory: true),
watchFolder: root.appendingPathComponent("watch", isDirectory: true)
)
let ledger = try ReturnLedger(paths: paths)
let model = try AppModel(
paths: paths,
spool: try SpoolStore(paths: paths),
composer: PDFComposer(),
capturer: ScreenCapturer(),
hotkeys: HotkeyCenter(),
picker: RegionPickerController(),
ledger: ledger,
watcher: ReturnWatcher(paths: paths, ledger: ledger)
)
model.setFolderURLs(outbox: paths.outbox, watch: paths.watchFolder)
let png = try makeTinyPNGData()
_ = try await model.spool.append(
pngData: png,
pixelWidth: 64,
pixelHeight: 48,
scale: 1,
capturedAt: Date()
)
model.replaceSession(try await model.spool.currentSession())
let openID = model.session.id
guard !model.session.isEmpty else {
sendTruthFail("seeded session was empty")
}
let pending = try await model.composePDFForSend()
guard fm.fileExists(atPath: pending.fileURL.path) else {
sendTruthFail("PDF was not written")
}
model.handleDidFailToShareItems(fileName: pending.fileName)
let still = try await model.spool.currentSession()
guard still.id == openID, !still.isEmpty, still.state == .open else {
sendTruthFail("fail path archived or replaced the session")
}
let spoolDir = paths.sessionDirectory(openID)
guard fm.fileExists(atPath: spoolDir.path) else {
sendTruthFail("fail path: session missing from temp spool")
}
guard let status = model.statusLine, status.contains("nothing was sent") else {
sendTruthFail("fail path status missing 'nothing was sent': \(model.statusLine ?? "nil")")
}
await model.handleDidShareItems(fileName: pending.fileName, pageCount: pending.pageCount)
let fresh = try await model.spool.currentSession()
guard fresh.isEmpty, fresh.id != openID, fresh.state == .open else {
sendTruthFail("success path did not mint a fresh empty session")
}
let archived = try await model.spool.archivedSessions()
guard archived.contains(where: { $0.id == openID && $0.state == .archived }) else {
sendTruthFail("success path did not archive the session")
}
let archiveDir = paths.archiveDirectory(openID)
guard fm.fileExists(atPath: archiveDir.path) else {
sendTruthFail("success path: archive dir missing")
}
guard !fm.fileExists(atPath: spoolDir.path) else {
sendTruthFail("success path: session still in spool")
}
}
private static func makeTinyPNGData() throws -> Data {
let width = 64
let height = 48
let colorSpace = CGColorSpaceCreateDeviceRGB()
guard let context = CGContext(
data: nil,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: width * 4,
space: colorSpace,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else {
sendTruthFail("could not create PNG context")
}
context.setFillColor(red: 0.2, green: 0.4, blue: 0.8, alpha: 1)
context.fill(CGRect(x: 0, y: 0, width: width, height: height))
guard let image = context.makeImage() else {
sendTruthFail("could not make CGImage")
}
let buffer = NSMutableData()
guard let destination = CGImageDestinationCreateWithData(
buffer,
"public.png" as CFString,
1,
nil
) else {
sendTruthFail("could not create PNG destination")
}
CGImageDestinationAddImage(destination, image, nil)
guard CGImageDestinationFinalize(destination) else {
sendTruthFail("could not finalize PNG")
}
return buffer as Data
}
private static func sendTruthFail(_ reason: String) -> Never {
print("SEND-TRUTH FAIL \(reason)")
fflush(stdout)
exit(1)
}
/// Phase 5: builds a fake 99.0.0 bundle, serves a local appcast, stages via
/// `checkNow`, then `installStaged` into the env dir — never `/Applications`.
/// Returns true when the async phase was scheduled (it calls `exit` itself).
@discardableResult
private static func startUpdateSelfTestIfRequested() -> Bool {
guard let raw = ProcessInfo.processInfo.environment["SHOTDECK_UPDATE_SELFTEST"],
!raw.isEmpty
else { return false }
let output = URL(fileURLWithPath: raw, isDirectory: true)
Task { @MainActor in
do {
try await runUpdateSelfTest(outputDirectory: output)
print("UPDATE-SELFTEST PASS version=99.0.0")
fflush(stdout)
exit(0)
} catch let error as UpdateSelfTestError {
updateFail(error.description)
} catch {
updateFail(String(describing: error))
}
}
return true
}
private static func runUpdateSelfTest(outputDirectory: URL) async throws {
let fm = FileManager.default
try fm.createDirectory(at: outputDirectory, withIntermediateDirectories: true)
guard let sourceApp = ownAppBundleURL() else {
throw UpdateSelfTestError.detail("own bundle is not a .app (\(Bundle.main.bundleURL.path))")
}
let payload = outputDirectory.appendingPathComponent("payload", isDirectory: true)
if fm.fileExists(atPath: payload.path) {
try fm.removeItem(at: payload)
}
try fm.createDirectory(at: payload, withIntermediateDirectories: true)
let fakeApp = payload.appendingPathComponent("Redline.app")
try fm.copyItem(at: sourceApp, to: fakeApp)
let plistURL = fakeApp.appendingPathComponent("Contents/Info.plist")
let plistData = try Data(contentsOf: plistURL)
guard var plist = try PropertyListSerialization.propertyList(from: plistData, format: nil) as? [String: Any] else {
throw UpdateSelfTestError.detail("could not parse copied Info.plist")
}
plist["CFBundleShortVersionString"] = "99.0.0"
let rewritten = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
try rewritten.write(to: plistURL)
let zipURL = outputDirectory.appendingPathComponent("Redline-99.0.0.zip")
if fm.fileExists(atPath: zipURL.path) {
try fm.removeItem(at: zipURL)
}
try runDitto(arguments: ["-c", "-k", payload.path, zipURL.path])
let zipData = try Data(contentsOf: zipURL)
let hex = UpdateChecker.sha256Hex(zipData)
let appcastURL = outputDirectory.appendingPathComponent("appcast.json")
let appcast: [String: String] = [
"version": "99.0.0",
"zipURL": zipURL.absoluteString,
"sha256": hex,
"notes": "UPDATE-SELFTEST",
]
let appcastData = try JSONSerialization.data(withJSONObject: appcast, options: [.sortedKeys])
try appcastData.write(to: appcastURL)
let defaults = UserDefaults.standard
let previous = defaults.string(forKey: UpdateChecker.appcastURLDefaultsKey)
defaults.set(appcastURL.absoluteString, forKey: UpdateChecker.appcastURLDefaultsKey)
defer {
if let previous {
defaults.set(previous, forKey: UpdateChecker.appcastURLDefaultsKey)
} else {
defaults.removeObject(forKey: UpdateChecker.appcastURLDefaultsKey)
}
}
let (model, isolatedRoot) = try makeIsolatedUpdateModel()
defer { try? fm.removeItem(at: isolatedRoot) }
await model.updateChecker.checkNow()
guard model.updateAvailable?.version == "99.0.0" else {
throw UpdateSelfTestError.detail(
"updateAvailable=\(model.updateAvailable?.version ?? "nil")"
)
}
guard let staged = model.updateChecker.stagedAppURL else {
throw UpdateSelfTestError.detail("staged payload missing")
}
guard staged.lastPathComponent == "Redline.app" else {
throw UpdateSelfTestError.detail("staged name \(staged.lastPathComponent)")
}
let stagedExe = staged.appendingPathComponent("Contents/MacOS/Shotdeck")
guard fm.fileExists(atPath: stagedExe.path) else {
throw UpdateSelfTestError.detail("staged Contents/MacOS/Shotdeck missing")
}
let targetRoot = outputDirectory.appendingPathComponent("target", isDirectory: true)
if fm.fileExists(atPath: targetRoot.path) {
try fm.removeItem(at: targetRoot)
}
let target = targetRoot.appendingPathComponent("Redline.app")
model.updateChecker.installStaged(to: target)
let installedPlist = target.appendingPathComponent("Contents/Info.plist")
guard let installed = NSDictionary(contentsOf: installedPlist) as? [String: Any],
let installedVersion = installed["CFBundleShortVersionString"] as? String
else {
throw UpdateSelfTestError.detail("installed Info.plist unreadable")
}
guard installedVersion == "99.0.0" else {
throw UpdateSelfTestError.detail("installed version \(installedVersion)")
}
}
private static func ownAppBundleURL() -> URL? {
let bundle = Bundle.main.bundleURL
if bundle.pathExtension == "app" { return bundle }
let up3 = bundle
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
if up3.pathExtension == "app" { return up3 }
return nil
}
private static func makeIsolatedUpdateModel() throws -> (AppModel, URL) {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("shotdeck-update-selftest-\(UUID().uuidString)", isDirectory: true)
let paths = try AppSupportPaths(
root: root,
outbox: root.appendingPathComponent("outbox", isDirectory: true),
watchFolder: root.appendingPathComponent("watch", isDirectory: true)
)
let ledger = try ReturnLedger(paths: paths)
let model = try AppModel(
paths: paths,
spool: try SpoolStore(paths: paths),
composer: PDFComposer(),
capturer: ScreenCapturer(),
hotkeys: HotkeyCenter(),
picker: RegionPickerController(),
ledger: ledger,
watcher: ReturnWatcher(paths: paths, ledger: ledger)
)
return (model, root)
}
private static func runDitto(arguments: [String]) throws {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto")
process.arguments = arguments
let err = Pipe()
process.standardError = err
process.standardOutput = Pipe()
try process.run()
process.waitUntilExit()
guard process.terminationStatus == 0 else {
let message = String(data: err.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
throw UpdateSelfTestError.detail("ditto failed: \(message)")
}
}
private static func updateFail(_ detail: String) -> Never {
print("UPDATE-SELFTEST FAIL \(detail)")
fflush(stdout)
exit(1)
}
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)
}
}
private enum UpdateSelfTestError: Error, CustomStringConvertible {
case detail(String)
var description: String {
switch self {
case .detail(let s): return s
}
}
}
private enum HistorySelfTestError: Error, CustomStringConvertible {
case detail(String)
var description: String {
switch self {
case .detail(let s): return s
}
}
}