diff --git a/Package.swift b/Package.swift index e8361c4..86f89ee 100644 --- a/Package.swift +++ b/Package.swift @@ -27,5 +27,14 @@ let package = Package( dependencies: ["ShotdeckCore"], swiftSettings: [.swiftLanguageMode(.v6)] ), + // Exercises the real Shotdeck-app-target wiring (AppDelegate.makeLaunchModel(), + // AppModel.bootstrap()) via @testable import — logic ShotdeckCoreTests cannot + // reach because it only depends on ShotdeckCore, not the Shotdeck executable + // target itself. See ReturnWatcherLaunchWiringTests.swift. + .testTarget( + name: "ShotdeckTests", + dependencies: ["Shotdeck", "ShotdeckCore"], + swiftSettings: [.swiftLanguageMode(.v6)] + ), ] ) diff --git a/Tests/ShotdeckTests/LaunchWiringTests.swift b/Tests/ShotdeckTests/LaunchWiringTests.swift new file mode 100644 index 0000000..68df70f --- /dev/null +++ b/Tests/ShotdeckTests/LaunchWiringTests.swift @@ -0,0 +1,114 @@ +import AppKit +import Foundation +import PDFKit +import Testing +import ShotdeckCore +@testable import Shotdeck + +/// Coverage gap closed (adversarial review, round 3): the Core-level regression tests +/// in ShotdeckCoreTests hand-replicate what `AppDelegate.makeLaunchModel()` and +/// `AppModel.bootstrap()` do, rather than calling them — so a future revert of +/// `makeLaunchModel()` back to the AirDrop-only resolver, or a dropped +/// `updateWatchFolder` call inside `bootstrap()`, would NOT fail `swift test`. This +/// test goes through the real, unmodified call sites in the `Shotdeck` executable +/// target via `@testable import`, which `ShotdeckCoreTests` cannot reach (it only +/// depends on `ShotdeckCore`) — hence this separate `ShotdeckTests` target. +@MainActor +@Test("Real wiring: AppDelegate.makeLaunchModel() + AppModel.bootstrap() detect a marked OneDrive return") +func realLaunchModelAndBootstrapDetectAMarkedOneDriveReturn() async throws { + let fm = FileManager.default + + // UserDefaults.standard is the ONLY defaults instance makeLaunchModel()/bootstrap() + // actually read — there is no defaults-threading through AppModel/AppDelegate (the + // same reasoning documented in PickerSelfTest.swift's ONEDRIVE-SELFTEST phase). + // "Isolated" here means snapshot-and-restore around the real keys, not a separate + // UserDefaults(suiteName:) instance that these real, unmodified call sites would + // never actually consult. + let defaults = UserDefaults.standard + let previousTransport = defaults.string(forKey: TransportSettings.transportDefaultsKey) + let previousFolder = defaults.string(forKey: TransportSettings.oneDriveFolderDefaultsKey) + defer { + if let previousTransport { + defaults.set(previousTransport, forKey: TransportSettings.transportDefaultsKey) + } else { + defaults.removeObject(forKey: TransportSettings.transportDefaultsKey) + } + if let previousFolder { + defaults.set(previousFolder, forKey: TransportSettings.oneDriveFolderDefaultsKey) + } else { + defaults.removeObject(forKey: TransportSettings.oneDriveFolderDefaultsKey) + } + } + + let oneDriveFolderRaw = fm.temporaryDirectory + .appendingPathComponent("shotdeck-real-wiring-onedrive-\(UUID().uuidString)", isDirectory: true) + try fm.createDirectory(at: oneDriveFolderRaw, withIntermediateDirectories: true) + defer { try? fm.removeItem(at: oneDriveFolderRaw) } + // FileManager's directory enumeration (inside the real ReturnWatcher/AppSupportPaths + // call sites this test exercises) can canonicalize /var -> /private/var for a path + // that actually exists; resolve here so every comparison below agrees. + let oneDriveFolder = oneDriveFolderRaw.resolvingSymlinksInPath() + + let appSupportRoot = fm.temporaryDirectory + .appendingPathComponent("shotdeck-real-wiring-approot-\(UUID().uuidString)", isDirectory: true) + defer { try? fm.removeItem(at: appSupportRoot) } + + TransportSettings.setTransport(.oneDrive, defaults: defaults) + TransportSettings.setOneDriveFolder(oneDriveFolder, defaults: defaults) + + // Best-effort: keeps AppModel.bootstrap()'s real update-check schedule (a real + // HTTP GET after 10s, plus a RunLoop timer) from starting during this test. + // ProcessInfo.processInfo.environment on Darwin reads `environ` fresh each call, + // so a setenv() here is visible to bootstrap()'s own check immediately. + setenv("SHOTDECK_ONEDRIVE_SELFTEST", "1", 1) + defer { unsetenv("SHOTDECK_ONEDRIVE_SELFTEST") } + + // The REAL, unmodified call sites — not a reimplementation. This is exactly what + // launching Redline with OneDrive as the persisted transport does. + let model = AppDelegate.makeLaunchModel(appSupportRoot: appSupportRoot) + // bootstrap() registers a REAL, process-wide Carbon global hotkey (capture combo, + // e.g. Option-Shift-2). Carbon registrations are not scoped to this test/model — + // they must be released before this test ends, or ShotdeckCoreTests' + // HotkeyCenterCarbonTests (a separate test target, same test process) can find the + // combo already taken / the global hotkey table in an unexpected state. + defer { model.hotkeys.unregisterAll() } + + #expect(model.transport == .oneDrive) + #expect(model.watchFolderURL.path == oneDriveFolder.path) + + await model.bootstrap() + + // Drop a marked-up Redline PDF into the folder in place — what OneDrive syncing + // down an already-marked copy after a relaunch looks like. + let pdfURL = oneDriveFolder.appendingPathComponent("Redline-realwiring-\(UUID().uuidString).pdf") + let document = PDFDocument() + let page = PDFPage() + page.setBounds(CGRect(x: 0, y: 0, width: 612, height: 792), for: .mediaBox) + document.insert(page, at: 0) + document.documentAttributes = [ + PDFDocumentAttribute.creatorAttribute: "Redline", + PDFDocumentAttribute.subjectAttribute: UUID().uuidString, + ] + let ink = PDFAnnotation( + bounds: CGRect(x: 20, y: 20, width: 60, height: 60), forType: .ink, withProperties: nil + ) + let stroke = NSBezierPath() + stroke.move(to: NSPoint(x: 20, y: 20)) + stroke.line(to: NSPoint(x: 80, y: 80)) + ink.add(stroke) + page.addAnnotation(ink) + let written = document.write(to: pdfURL) + #expect(written) + guard written else { return } + + // .resolvingSymlinksInPath().path — not plain URL equality — matching how the rest + // of the suite compares a temp-dir-derived expected URL against a returned one. + let expectedPath = pdfURL.resolvingSymlinksInPath().path + let found = try await model.watcher.scanNow() + #expect(found.first(where: { $0.fileURL.resolvingSymlinksInPath().path == expectedPath })?.isCommented == true) + + let commented = try await model.ledger.commented() + #expect(commented.contains(where: { $0.fileURL.resolvingSymlinksInPath().path == expectedPath })) + + await model.watcher.stop() +}