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:
@@ -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 {
|
||||||
|
|||||||
@@ -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)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user