diff --git a/Sources/Shotdeck/AppModel.swift b/Sources/Shotdeck/AppModel.swift index d3c2675..b59e451 100644 --- a/Sources/Shotdeck/AppModel.swift +++ b/Sources/Shotdeck/AppModel.swift @@ -35,6 +35,11 @@ public final class AppModel { /// Live transport choice; WP-onedrive reads this to pick the send path and to drive /// the Settings "Send via" picker and the menu's "Send…" label. public private(set) var transport: SendTransport + /// Ground truth for the Settings OneDrive row: nil means "no OneDrive folder found". + /// Views read this instead of calling `OneDriveLocator.resolveOneDriveFolder()` + /// directly, so state (and testing with a fake home) flows through the model like + /// everything else — never a View reaching past the model for real UserDefaults/home. + public private(set) var resolvedOneDriveFolder: URL? /// Live outbox; WP-4b reads this (not `paths.outbox`) so Settings folder changes take effect. public private(set) var outboxURL: URL /// Live watch folder; WP-4c updates this alongside `ReturnWatcher.updateWatchFolder`. @@ -93,6 +98,7 @@ public final class AppModel { self.outboxDisplayName = folders.outbox.lastPathComponent self.watchFolderDisplayName = folders.watch.lastPathComponent self.transport = folders.transport + self.resolvedOneDriveFolder = OneDriveLocator.resolveOneDriveFolder() self.captureHotkey = HotkeyPreference.load() self.updateChecker = UpdateChecker() self.updateChecker.onChecked = { [weak self] in @@ -125,6 +131,7 @@ public final class AppModel { setFolderDisplayNames(outbox: outbox.lastPathComponent, watch: watch.lastPathComponent) } func setTransport(_ value: SendTransport) { transport = value } + func setResolvedOneDriveFolder(_ value: URL?) { resolvedOneDriveFolder = value } func rememberLastComposedPDF(_ url: URL) { lastComposedPDFURL = url } /// True when a last-composed PDF path is known this run, or the newest @@ -200,6 +207,7 @@ public final class AppModel { if transport == .oneDrive { try? FileManager.default.createDirectory(at: outboxURL, withIntermediateDirectories: true) } + setResolvedOneDriveFolder(OneDriveLocator.resolveOneDriveFolder()) await watcher.setRecordUncommented(transport == .airDrop) do { diff --git a/Sources/Shotdeck/PanelSnapshot.swift b/Sources/Shotdeck/PanelSnapshot.swift index e6580e3..b5a7640 100644 --- a/Sources/Shotdeck/PanelSnapshot.swift +++ b/Sources/Shotdeck/PanelSnapshot.swift @@ -61,6 +61,28 @@ enum PanelSnapshot { try renderMenuBar(model: model, to: directory, name: "03-empty-session") // 04 — three real PNGs in the temp spool so SessionStrip thumbnails decode. + try await addSampleCaptures(to: model) + try renderMenuBar(model: model, to: directory, name: "04-captures-present") + + // 05 — two inspected PDFs in the temp ledger, one marked / one not. + try await seedReturns(model: model) + try renderMenuBar(model: model, to: directory, name: "05-returns-present") + + // 06 — SettingsView against the same isolated model. + try render( + SettingsView().environment(model), + to: directory.appendingPathComponent("panel-06-settings.png") + ) + + // 07/08/09 — OneDrive-mode Settings + menu bar, on a SEPARATE isolated model so + // this transport switch never bleeds into the AirDrop-mode panels above. + try await captureOneDrivePanels(to: directory) + } + + /// Three real PNGs appended to the given model's temp spool so SessionStrip + /// thumbnails decode. Shared by panel 04 (AirDrop) and panel 09 (OneDrive). + @MainActor + private static func addSampleCaptures(to model: AppModel) async throws { let swatches: [(CGFloat, CGFloat, CGFloat)] = [ (0.85, 0.22, 0.18), (0.18, 0.62, 0.32), @@ -77,17 +99,62 @@ enum PanelSnapshot { ) } model.replaceSession(try await model.spool.currentSession()) - try renderMenuBar(model: model, to: directory, name: "04-captures-present") + } - // 05 — two inspected PDFs in the temp ledger, one marked / one not. - try await seedReturns(model: model) - try renderMenuBar(model: model, to: directory, name: "05-returns-present") + /// Panels 07-09: OneDrive transport, on its own isolated model/temp root so + /// switching transport here never touches the AirDrop-mode model above, the real + /// home directory, or UserDefaults.standard. The "resolved" and "not found" states + /// are produced by calling the real OneDriveLocator functions against fake home + /// trees built under this snapshot's own temp root — never a hand-typed path. + @MainActor + private static func captureOneDrivePanels(to directory: URL) async throws { + let (model, root) = try makeIsolatedModel() + defer { try? FileManager.default.removeItem(at: root) } + model.setTransport(.oneDrive) - // 06 — SettingsView against the same isolated model. + // 07 — a resolved OneDrive folder, shaped like the real default + // (…/Library/CloudStorage/OneDrive-MMDGROUP/Redline): a fake home tree with a + // real OneDrive-MMDGROUP directory under it, resolved via the same pure + // OneDriveLocator function production code uses — never a hand-typed path. + let fakeHomeWithOneDrive = root.appendingPathComponent("fake-home-with-onedrive", isDirectory: true) + let syncRoot = fakeHomeWithOneDrive + .appendingPathComponent("Library/CloudStorage/OneDrive-MMDGROUP", isDirectory: true) + try FileManager.default.createDirectory(at: syncRoot, withIntermediateDirectories: true) + guard let resolvedFolder = OneDriveLocator.defaultRedlineFolder( + home: fakeHomeWithOneDrive, fileManager: .default + ) else { + throw SnapshotError.oneDriveFixtureFailed("fake OneDrive-MMDGROUP root did not resolve") + } + model.setResolvedOneDriveFolder(resolvedFolder) try render( SettingsView().environment(model), - to: directory.appendingPathComponent("panel-06-settings.png") + to: directory.appendingPathComponent("panel-07-settings-onedrive.png") ) + + // 08 — no OneDrive folder found: a fake home with NO Library/CloudStorage at + // all, and a throwaway UserDefaults suite (never .standard, never touched + // before) so the stored-override check also legitimately finds nothing. + let fakeHomeWithoutOneDrive = root.appendingPathComponent("fake-home-without-onedrive", isDirectory: true) + try FileManager.default.createDirectory(at: fakeHomeWithoutOneDrive, withIntermediateDirectories: true) + let isolatedDefaults = try makeIsolatedDefaultsSuite() + defer { isolatedDefaults.defaults.removePersistentDomain(forName: isolatedDefaults.suiteName) } + let missingFolder = OneDriveLocator.resolveOneDriveFolder( + defaults: isolatedDefaults.defaults, home: fakeHomeWithoutOneDrive, fileManager: .default + ) + guard missingFolder == nil else { + throw SnapshotError.oneDriveFixtureFailed("fake home without OneDrive unexpectedly resolved") + } + model.setResolvedOneDriveFolder(nil) + try render( + SettingsView().environment(model), + to: directory.appendingPathComponent("panel-08-settings-onedrive-missing.png") + ) + + // 09 — menu bar panel, 3 captures present, OneDrive mode ("Send to OneDrive"). + model.snapshotSetScreenRecordingGranted(true) + model.replaceRegion(sampleRegion()) + try await addSampleCaptures(to: model) + try renderMenuBar(model: model, to: directory, name: "09-captures-present-onedrive") } @MainActor @@ -177,6 +244,23 @@ enum PanelSnapshot { return (model, root) } + /// A throwaway UserDefaults suite — never `.standard` — for the panel-08 fixture, + /// the same isolation pattern ShotdeckCoreTests uses for TransportSettings/ + /// OneDriveLocator tests. + private struct IsolatedDefaultsSuite { + let suiteName: String + let defaults: UserDefaults + } + + private static func makeIsolatedDefaultsSuite() throws -> IsolatedDefaultsSuite { + let suiteName = "shotdeck-panel-snapshot-\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + throw SnapshotError.oneDriveFixtureFailed("could not create isolated UserDefaults suite") + } + defaults.removePersistentDomain(forName: suiteName) + return IsolatedDefaultsSuite(suiteName: suiteName, defaults: defaults) + } + @MainActor private static func seedReturns(model: AppModel) async throws { let watch = model.paths.watchFolder @@ -283,6 +367,7 @@ private enum SnapshotError: Error, CustomStringConvertible { case encodeFailed(String) case pngGenerationFailed case pdfWriteFailed(String) + case oneDriveFixtureFailed(String) var description: String { switch self { @@ -290,6 +375,7 @@ private enum SnapshotError: Error, CustomStringConvertible { case .encodeFailed(let name): return "PNG encode failed for \(name)" case .pngGenerationFailed: return "CoreGraphics PNG generation failed" case .pdfWriteFailed(let name): return "could not write \(name)" + case .oneDriveFixtureFailed(let detail): return "OneDrive snapshot fixture failed: \(detail)" } } } diff --git a/Sources/Shotdeck/SendController.swift b/Sources/Shotdeck/SendController.swift index 6dd9353..6849703 100644 --- a/Sources/Shotdeck/SendController.swift +++ b/Sources/Shotdeck/SendController.swift @@ -31,10 +31,12 @@ extension AppModel: SendCapable { let path = OneDriveLocator.resolveOneDriveFolder()?.path ?? TransportSettings.storedOneDriveFolderPath() ?? "no OneDrive folder found" + setResolvedOneDriveFolder(nil) setStatus(ShotdeckError.oneDriveFolderUnavailable(path: path).errorDescription) setSending(false) return } + setResolvedOneDriveFolder(folder) if outboxURL != folder || watchFolderURL != folder { setFolderURLs(outbox: folder, watch: folder) try? await watcher.updateWatchFolder(folder) diff --git a/Sources/Shotdeck/SettingsView.swift b/Sources/Shotdeck/SettingsView.swift index af7a39d..5291120 100644 --- a/Sources/Shotdeck/SettingsView.swift +++ b/Sources/Shotdeck/SettingsView.swift @@ -77,7 +77,7 @@ struct SettingsView: View { } else { GridRow(alignment: .center) { fieldLabel("OneDrive folder") - if let folder = resolvedOneDriveFolder { + if let folder = model.resolvedOneDriveFolder { folderValue(path: folder.path) { model.chooseOneDriveFolder() } @@ -122,13 +122,6 @@ struct SettingsView: View { Binding(get: { model.transport }, set: { model.chooseTransport($0) }) } - /// Ground truth from OneDriveLocator, not `model.outboxURL` — the model may be - /// showing an AirDrop-folder fallback when no real OneDrive folder resolves, and - /// the Settings row must say so plainly rather than repeat that fallback path. - private var resolvedOneDriveFolder: URL? { - OneDriveLocator.resolveOneDriveFolder() - } - private func armHotkeyRecorder() { guard !isRecordingHotkey else { return } isRecordingHotkey = true @@ -267,6 +260,7 @@ extension AppModel: SettingsWindowPresenting { ) } setFolderURLs(outbox: folders.outbox, watch: folders.watch) + setResolvedOneDriveFolder(OneDriveLocator.resolveOneDriveFolder()) Task { await watcher.setRecordUncommented(value == .airDrop) do { @@ -280,10 +274,11 @@ extension AppModel: SettingsWindowPresenting { } func chooseOneDriveFolder() { - let start = OneDriveLocator.resolveOneDriveFolder() ?? FileManager.default.homeDirectoryForCurrentUser + let start = resolvedOneDriveFolder ?? FileManager.default.homeDirectoryForCurrentUser guard let url = chooseDirectory(startingAt: start) else { return } TransportSettings.setOneDriveFolder(url) try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + setResolvedOneDriveFolder(OneDriveLocator.resolveOneDriveFolder()) guard transport == .oneDrive else { return } setFolderURLs(outbox: url, watch: url) Task {