feature: model-owned resolvedOneDriveFolder + OneDrive Settings/menu panel snapshots

SettingsView's OneDrive row called OneDriveLocator.resolveOneDriveFolder()
directly with the real UserDefaults.standard and the real home directory,
which made that row impossible to drive from a fake/isolated environment.
Moved that resolution into AppModel as a tracked resolvedOneDriveFolder
property (nil means "no OneDrive folder found"), refreshed at init,
bootstrap, chooseTransport, chooseOneDriveFolder, and inside send()'s live
folder check. SettingsView and chooseOneDriveFolder's picker-start path now
read model.resolvedOneDriveFolder instead of calling OneDriveLocator
directly — state flows through the model like everything else in this app.

PanelSnapshot (SHOTDECK_SNAPSHOT_DIR) adds three panels on a SEPARATE
isolated model so the transport switch never bleeds into the six existing
AirDrop-mode panels:
- panel-07-settings-onedrive.png: transport=oneDrive with a resolved folder,
  built by pointing OneDriveLocator.defaultRedlineFolder at a fake home tree
  (Library/CloudStorage/OneDrive-MMDGROUP under this snapshot's own temp
  root) so the displayed path is shaped like the real default without ever
  touching the real home.
- panel-08-settings-onedrive-missing.png: a fake home with no
  Library/CloudStorage at all, resolved through a throwaway UserDefaults
  suite (never .standard) so the "no OneDrive folder found" state and its
  still-usable Choose... button are exercised for real.
- panel-09-captures-present-onedrive.png: 3 captures + transport=oneDrive,
  confirming the menu row reads "Send to OneDrive".

Extracted the 3-swatch capture seeding (panel 04) into addSampleCaptures(to:)
so panel 09 reuses it instead of duplicating the loop.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp
This commit is contained in:
2026-09-05 09:10:59 +04:00
co-authored by Claude Fable 5.1
parent 2937d2d5e0
commit 209921f084
4 changed files with 106 additions and 15 deletions
+8
View File
@@ -35,6 +35,11 @@ public final class AppModel {
/// Live transport choice; WP-onedrive reads this to pick the send path and to drive /// 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. /// the Settings "Send via" picker and the menu's "Send" label.
public private(set) var transport: SendTransport 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. /// Live outbox; WP-4b reads this (not `paths.outbox`) so Settings folder changes take effect.
public private(set) var outboxURL: URL public private(set) var outboxURL: URL
/// Live watch folder; WP-4c updates this alongside `ReturnWatcher.updateWatchFolder`. /// Live watch folder; WP-4c updates this alongside `ReturnWatcher.updateWatchFolder`.
@@ -93,6 +98,7 @@ public final class AppModel {
self.outboxDisplayName = folders.outbox.lastPathComponent self.outboxDisplayName = folders.outbox.lastPathComponent
self.watchFolderDisplayName = folders.watch.lastPathComponent self.watchFolderDisplayName = folders.watch.lastPathComponent
self.transport = folders.transport self.transport = folders.transport
self.resolvedOneDriveFolder = OneDriveLocator.resolveOneDriveFolder()
self.captureHotkey = HotkeyPreference.load() self.captureHotkey = HotkeyPreference.load()
self.updateChecker = UpdateChecker() self.updateChecker = UpdateChecker()
self.updateChecker.onChecked = { [weak self] in self.updateChecker.onChecked = { [weak self] in
@@ -125,6 +131,7 @@ public final class AppModel {
setFolderDisplayNames(outbox: outbox.lastPathComponent, watch: watch.lastPathComponent) setFolderDisplayNames(outbox: outbox.lastPathComponent, watch: watch.lastPathComponent)
} }
func setTransport(_ value: SendTransport) { transport = value } func setTransport(_ value: SendTransport) { transport = value }
func setResolvedOneDriveFolder(_ value: URL?) { resolvedOneDriveFolder = value }
func rememberLastComposedPDF(_ url: URL) { lastComposedPDFURL = url } func rememberLastComposedPDF(_ url: URL) { lastComposedPDFURL = url }
/// True when a last-composed PDF path is known this run, or the newest /// 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 { if transport == .oneDrive {
try? FileManager.default.createDirectory(at: outboxURL, withIntermediateDirectories: true) try? FileManager.default.createDirectory(at: outboxURL, withIntermediateDirectories: true)
} }
setResolvedOneDriveFolder(OneDriveLocator.resolveOneDriveFolder())
await watcher.setRecordUncommented(transport == .airDrop) await watcher.setRecordUncommented(transport == .airDrop)
do { do {
+92 -6
View File
@@ -61,6 +61,28 @@ enum PanelSnapshot {
try renderMenuBar(model: model, to: directory, name: "03-empty-session") try renderMenuBar(model: model, to: directory, name: "03-empty-session")
// 04 three real PNGs in the temp spool so SessionStrip thumbnails decode. // 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)] = [ let swatches: [(CGFloat, CGFloat, CGFloat)] = [
(0.85, 0.22, 0.18), (0.85, 0.22, 0.18),
(0.18, 0.62, 0.32), (0.18, 0.62, 0.32),
@@ -77,17 +99,62 @@ enum PanelSnapshot {
) )
} }
model.replaceSession(try await model.spool.currentSession()) 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. /// Panels 07-09: OneDrive transport, on its own isolated model/temp root so
try await seedReturns(model: model) /// switching transport here never touches the AirDrop-mode model above, the real
try renderMenuBar(model: model, to: directory, name: "05-returns-present") /// 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( try render(
SettingsView().environment(model), 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 @MainActor
@@ -177,6 +244,23 @@ enum PanelSnapshot {
return (model, root) 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 @MainActor
private static func seedReturns(model: AppModel) async throws { private static func seedReturns(model: AppModel) async throws {
let watch = model.paths.watchFolder let watch = model.paths.watchFolder
@@ -283,6 +367,7 @@ private enum SnapshotError: Error, CustomStringConvertible {
case encodeFailed(String) case encodeFailed(String)
case pngGenerationFailed case pngGenerationFailed
case pdfWriteFailed(String) case pdfWriteFailed(String)
case oneDriveFixtureFailed(String)
var description: String { var description: String {
switch self { switch self {
@@ -290,6 +375,7 @@ private enum SnapshotError: Error, CustomStringConvertible {
case .encodeFailed(let name): return "PNG encode failed for \(name)" case .encodeFailed(let name): return "PNG encode failed for \(name)"
case .pngGenerationFailed: return "CoreGraphics PNG generation failed" case .pngGenerationFailed: return "CoreGraphics PNG generation failed"
case .pdfWriteFailed(let name): return "could not write \(name)" case .pdfWriteFailed(let name): return "could not write \(name)"
case .oneDriveFixtureFailed(let detail): return "OneDrive snapshot fixture failed: \(detail)"
} }
} }
} }
+2
View File
@@ -31,10 +31,12 @@ extension AppModel: SendCapable {
let path = OneDriveLocator.resolveOneDriveFolder()?.path let path = OneDriveLocator.resolveOneDriveFolder()?.path
?? TransportSettings.storedOneDriveFolderPath() ?? TransportSettings.storedOneDriveFolderPath()
?? "no OneDrive folder found" ?? "no OneDrive folder found"
setResolvedOneDriveFolder(nil)
setStatus(ShotdeckError.oneDriveFolderUnavailable(path: path).errorDescription) setStatus(ShotdeckError.oneDriveFolderUnavailable(path: path).errorDescription)
setSending(false) setSending(false)
return return
} }
setResolvedOneDriveFolder(folder)
if outboxURL != folder || watchFolderURL != folder { if outboxURL != folder || watchFolderURL != folder {
setFolderURLs(outbox: folder, watch: folder) setFolderURLs(outbox: folder, watch: folder)
try? await watcher.updateWatchFolder(folder) try? await watcher.updateWatchFolder(folder)
+4 -9
View File
@@ -77,7 +77,7 @@ struct SettingsView: View {
} else { } else {
GridRow(alignment: .center) { GridRow(alignment: .center) {
fieldLabel("OneDrive folder") fieldLabel("OneDrive folder")
if let folder = resolvedOneDriveFolder { if let folder = model.resolvedOneDriveFolder {
folderValue(path: folder.path) { folderValue(path: folder.path) {
model.chooseOneDriveFolder() model.chooseOneDriveFolder()
} }
@@ -122,13 +122,6 @@ struct SettingsView: View {
Binding(get: { model.transport }, set: { model.chooseTransport($0) }) 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() { private func armHotkeyRecorder() {
guard !isRecordingHotkey else { return } guard !isRecordingHotkey else { return }
isRecordingHotkey = true isRecordingHotkey = true
@@ -267,6 +260,7 @@ extension AppModel: SettingsWindowPresenting {
) )
} }
setFolderURLs(outbox: folders.outbox, watch: folders.watch) setFolderURLs(outbox: folders.outbox, watch: folders.watch)
setResolvedOneDriveFolder(OneDriveLocator.resolveOneDriveFolder())
Task { Task {
await watcher.setRecordUncommented(value == .airDrop) await watcher.setRecordUncommented(value == .airDrop)
do { do {
@@ -280,10 +274,11 @@ extension AppModel: SettingsWindowPresenting {
} }
func chooseOneDriveFolder() { func chooseOneDriveFolder() {
let start = OneDriveLocator.resolveOneDriveFolder() ?? FileManager.default.homeDirectoryForCurrentUser let start = resolvedOneDriveFolder ?? FileManager.default.homeDirectoryForCurrentUser
guard let url = chooseDirectory(startingAt: start) else { return } guard let url = chooseDirectory(startingAt: start) else { return }
TransportSettings.setOneDriveFolder(url) TransportSettings.setOneDriveFolder(url)
try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
setResolvedOneDriveFolder(OneDriveLocator.resolveOneDriveFolder())
guard transport == .oneDrive else { return } guard transport == .oneDrive else { return }
setFolderURLs(outbox: url, watch: url) setFolderURLs(outbox: url, watch: url)
Task { Task {