diff --git a/Sources/Shotdeck/PickerSelfTest.swift b/Sources/Shotdeck/PickerSelfTest.swift index e4cbf27..97024ae 100644 --- a/Sources/Shotdeck/PickerSelfTest.swift +++ b/Sources/Shotdeck/PickerSelfTest.swift @@ -223,7 +223,7 @@ enum PickerSelfTest { sendTruthFail("seeded session was empty") } - let pending = try await model.composePDFForSend() + let pending = try await model.composePDFForSend(outbox: model.outboxURL) guard fm.fileExists(atPath: pending.fileURL.path) else { sendTruthFail("PDF was not written") } @@ -476,18 +476,13 @@ enum PickerSelfTest { exit(1) } - /// Real sync root this Mac has; the phase proves the transport against the actual - /// OneDrive file provider, never a fake home tree (that is what - /// OneDriveLocatorTests in ShotdeckCoreTests are for). - private static let realOneDriveSyncRoot = URL( - fileURLWithPath: "/Users/benjaminhippler/Library/CloudStorage/OneDrive-MMDGROUP", - isDirectory: true - ) - - /// Phase 5: proves the OneDrive transport end to end against the real sync root. - /// Triggered by `SHOTDECK_ONEDRIVE_SELFTEST` when chained after PICKER/SEND-TRUTH/ - /// UPDATE-SELFTEST — the exact pattern `startUpdateSelfTestIfRequested` uses for its - /// own env var. Returns true when the async phase was scheduled (it calls `exit` itself). + /// Phase 5: proves the OneDrive transport end to end against a REAL sync root — + /// resolved at runtime via `OneDriveLocator.syncRoots()`, never a hardcoded path, so + /// this runs correctly on any Mac/account that has OneDrive signed in (MMD-named + /// root preferred, same as production). Triggered by `SHOTDECK_ONEDRIVE_SELFTEST` + /// when chained after PICKER/SEND-TRUTH/UPDATE-SELFTEST — the exact pattern + /// `startUpdateSelfTestIfRequested` uses for its own env var. Returns true when the + /// async phase was scheduled (it calls `exit` itself). @discardableResult private static func startOneDriveSelfTestIfRequested() -> Bool { guard ProcessInfo.processInfo.environment["SHOTDECK_ONEDRIVE_SELFTEST"] != nil else { @@ -514,9 +509,16 @@ enum PickerSelfTest { } private static func runOneDriveSelfTestAndExit() { + // Never a false PASS: no real OneDrive sync root on this machine/account is a + // SKIP (still non-zero exit), not silently treated as passing. + guard let syncRoot = OneDriveLocator.syncRoots().first else { + print("ONEDRIVE-SELFTEST SKIP no OneDrive sync root") + fflush(stdout) + exit(1) + } Task { @MainActor in do { - let folder = try await executeOneDriveSelfTest() + let folder = try await executeOneDriveSelfTest(syncRoot: syncRoot) print("ONEDRIVE-SELFTEST PASS path=\(folder.path)") fflush(stdout) exit(0) @@ -528,19 +530,30 @@ enum PickerSelfTest { } } - /// Builds a session, sends it through the OneDrive branch of `send(anchor: nil)` - /// against a NEW folder under the real OneDrive sync root, confirms the watcher does - /// NOT report the freshly-written unmarked PDF as a return, then adds a real PDFKit - /// ink annotation in place (what the iPad does) and confirms the watcher now reports - /// it as commented. Never deletes anything under OneDrive — the created folder and - /// PDF are left in place for Ben to inspect / for the real iPad round trip. - private static func executeOneDriveSelfTest() async throws -> URL { + /// Sub-step 1: builds a session, sends it through the OneDrive branch of + /// `send(anchor: nil)` against a NEW folder under `syncRoot`, confirms the watcher + /// does NOT report the freshly-written unmarked PDF as a return, then adds a real + /// PDFKit ink annotation in place (what the iPad does) and confirms the watcher now + /// reports it as commented. + /// + /// Sub-step 1b: rapid transport toggling (chooseTransport(.airDrop) immediately + /// followed by chooseTransport(.oneDrive), no await between them) must still end + /// with the watcher pointed at the OneDrive folder — proves the generation-guarded + /// reconcile in chooseTransport/chooseOneDriveFolder (SettingsView.swift) really + /// does let the last choice win instead of an earlier, superseded call applying its + /// stale folder after a later one already won. + /// + /// Sub-step 2: relaunch simulation — the exact BLOCKER scenario this phase exists to + /// catch. OneDrive is still persisted in defaults from sub-step 1; builds a FRESH + /// model the same way the real app launches (`AppDelegate.makeLaunchModel()` itself, + /// not a reimplementation), bootstraps it, then marks a PDF in the folder in place — + /// the relaunched watcher must report it. A temp app-support root keeps this off the + /// real ~/Library/Application Support/Shotdeck. + /// + /// Never deletes anything under OneDrive — the created folder and PDFs are left in + /// place for Ben to inspect / for the real iPad round trip. + private static func executeOneDriveSelfTest(syncRoot: URL) async throws -> URL { let fm = FileManager.default - guard fm.fileExists(atPath: realOneDriveSyncRoot.path) else { - throw OneDriveSelfTestError.detail( - "real OneDrive sync root not found at \(realOneDriveSyncRoot.path)" - ) - } // UserDefaults.standard is the ONLY defaults instance send()/TransportSettings // actually read at runtime (there is no defaults-threading through AppModel), so @@ -563,8 +576,7 @@ enum PickerSelfTest { } let stamp = DubaiTime.fileStamp(Date()) - let selftestFolder = realOneDriveSyncRoot - .appendingPathComponent("Redline-selftest-\(stamp)", isDirectory: true) + let selftestFolder = syncRoot.appendingPathComponent("Redline-selftest-\(stamp)", isDirectory: true) try fm.createDirectory(at: selftestFolder, withIntermediateDirectories: true) TransportSettings.setTransport(.oneDrive, defaults: defaults) @@ -629,8 +641,77 @@ enum PickerSelfTest { } // What the iPad does: mark it up in place with a real ink annotation, then save. - guard let document = PDFDocument(url: pdfURL), let page = document.page(at: 0) else { - throw OneDriveSelfTestError.detail("could not reopen \(pdfURL.path) to annotate it") + try addInkMark(to: pdfURL) + + let afterMarkup = try await watcher.scanNow() + guard let recorded = afterMarkup.first(where: { $0.fileURL == pdfURL }), recorded.isCommented else { + throw OneDriveSelfTestError.detail("annotated PDF was not reported as commented by scanNow") + } + let commentedAfter = try await ledger.commented() + guard commentedAfter.contains(where: { $0.fileURL == pdfURL }) else { + throw OneDriveSelfTestError.detail("annotated PDF was not recorded in the ledger as commented") + } + + // Sub-step 1b: rapid toggle race — see the doc comment above this function. + model.chooseTransport(.airDrop) + model.chooseTransport(.oneDrive) // immediately superseding the call above + // The generation guard itself is what's under test, not this wait — it just + // gives the (already-guarded) reconcile Task a moment to settle either way. + try await Task.sleep(for: .milliseconds(500)) + guard model.transport == .oneDrive else { + throw OneDriveSelfTestError.detail( + "rapid toggle: model.transport ended as \(model.transport), expected .oneDrive" + ) + } + let racePDFURL = selftestFolder.appendingPathComponent("Redline-race-\(stamp).pdf") + try writeUnmarkedRedlinePDF(to: racePDFURL) + try addInkMark(to: racePDFURL) + let raceFound = try await model.watcher.scanNow() + guard raceFound.first(where: { $0.fileURL == racePDFURL })?.isCommented == true else { + throw OneDriveSelfTestError.detail( + "rapid toggle: watcher did not end up watching \(selftestFolder.path) — an earlier, superseded chooseTransport call won" + ) + } + + // Sub-step 2: relaunch simulation — see the doc comment above this function. + let relaunchAppSupportRoot = fm.temporaryDirectory + .appendingPathComponent("shotdeck-onedrive-relaunch-\(UUID().uuidString)", isDirectory: true) + defer { try? fm.removeItem(at: relaunchAppSupportRoot) } + + let relaunchModel = AppDelegate.makeLaunchModel(appSupportRoot: relaunchAppSupportRoot) + guard relaunchModel.transport == .oneDrive else { + throw OneDriveSelfTestError.detail( + "relaunch: model transport was \(relaunchModel.transport), expected .oneDrive" + ) + } + guard relaunchModel.watchFolderURL.path == selftestFolder.path else { + throw OneDriveSelfTestError.detail( + "relaunch: model watchFolderURL was \(relaunchModel.watchFolderURL.path), expected \(selftestFolder.path) — this is the exact BLOCKER this phase guards against" + ) + } + + await relaunchModel.bootstrap() + + let relaunchPDFURL = selftestFolder.appendingPathComponent("Redline-relaunch-\(stamp).pdf") + try writeUnmarkedRedlinePDF(to: relaunchPDFURL) + try addInkMark(to: relaunchPDFURL) + + let relaunchFound = try await relaunchModel.watcher.scanNow() + guard relaunchFound.first(where: { $0.fileURL == relaunchPDFURL })?.isCommented == true else { + throw OneDriveSelfTestError.detail( + "relaunch: watcher did not report the marked PDF at \(relaunchPDFURL.path) as returned — it was watching the wrong folder after relaunch" + ) + } + await relaunchModel.watcher.stop() + + return selftestFolder + } + + /// Adds a real PDFKit ink annotation to the PDF at `url` in place and saves it — + /// exactly what the iPad does when marking up a page. + private static func addInkMark(to url: URL) throws { + guard let document = PDFDocument(url: url), let page = document.page(at: 0) else { + throw OneDriveSelfTestError.detail("could not reopen \(url.path) to annotate it") } let ink = PDFAnnotation( bounds: CGRect(x: 20, y: 20, width: 60, height: 60), @@ -642,20 +723,27 @@ enum PickerSelfTest { stroke.line(to: NSPoint(x: 80, y: 80)) ink.add(stroke) page.addAnnotation(ink) - guard document.write(to: pdfURL) else { - throw OneDriveSelfTestError.detail("could not save the annotated PDF back to \(pdfURL.path)") + guard document.write(to: url) else { + throw OneDriveSelfTestError.detail("could not save the annotated PDF back to \(url.path)") } + } - let afterMarkup = try await watcher.scanNow() - guard let recorded = afterMarkup.first(where: { $0.fileURL == pdfURL }), recorded.isCommented else { - throw OneDriveSelfTestError.detail("annotated PDF was not reported as commented by scanNow") + /// Writes a fresh, unmarked, single-page "Redline"-creator PDF straight to `url` — + /// standing in for a PDF that has just landed in the watch folder, before any + /// human mark. Used by the rapid-toggle and relaunch sub-steps, which don't need to + /// exercise send()/composePDFForSend() again (sub-step 1 already does). + private static func writeUnmarkedRedlinePDF(to url: URL) throws { + 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, + ] + guard document.write(to: url) else { + throw OneDriveSelfTestError.detail("could not write \(url.path)") } - let commentedAfter = try await ledger.commented() - guard commentedAfter.contains(where: { $0.fileURL == pdfURL }) else { - throw OneDriveSelfTestError.detail("annotated PDF was not recorded in the ledger as commented") - } - - return selftestFolder } private static func oneDriveFail(_ detail: String) -> Never {