diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactProbe.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactProbe.swift index a090b31..94f62b0 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactProbe.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactProbe.swift @@ -12,6 +12,20 @@ public struct StageArtefacts: Sendable, Equatable { public var stage4Complete = false /// Absolute path of the profile file when present. public var profilePath: URL? + + public init( + stage1Complete: Bool = false, + stage2Complete: Bool = false, + stage3Complete: Bool = false, + stage4Complete: Bool = false, + profilePath: URL? = nil + ) { + self.stage1Complete = stage1Complete + self.stage2Complete = stage2Complete + self.stage3Complete = stage3Complete + self.stage4Complete = stage4Complete + self.profilePath = profilePath + } } /// Filesystem probing for wizard artefacts (docs/02 §Working directory, diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Wizard/WizardGating.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Wizard/WizardGating.swift new file mode 100644 index 0000000..91973d0 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Wizard/WizardGating.swift @@ -0,0 +1,59 @@ +import Foundation + +/// Artefact-driven stage gating (issue #4, docs/06 §Stages). +/// +/// Navigation is *disk*, not buttons: a stage unlocks only when its +/// predecessor artefacts exist. Forward moves are gated; backward is +/// always allowed. Gating is re-evaluated on window focus and on stage +/// entry (#151 — files can disappear in Finder). +public enum WizardGating { + + /// Whether `stage` is reachable given the probed artefacts. + /// + /// - Stage 0 (calibrate): always — it is out-of-band, not gated. + /// - Stage 1: always. + /// - Stage 2: `.ti1` exists. + /// - Stage 3: `.ti1` **and** `.ti2`. + /// - Stage 4: `.ti3` exists (accepted measurement only — a `.ti2` + /// alone never unlocks it; #109/#110). + /// - Stage 5: `.ti3` **and** `.icc`/`.icm`. + public static func isUnlocked( + _ stage: WizardStage, + artefacts: StageArtefacts + ) -> Bool { + switch stage { + case .calibrate: return true + case .generate: return true + case .layOutPrint: return artefacts.stage1Complete + case .measure: return artefacts.stage1Complete && artefacts.stage2Complete + case .buildProfile: return artefacts.stage3Complete + case .verifyInstall: return artefacts.stage3Complete && artefacts.stage4Complete + } + } + + /// Whether `go(to:)` may proceed. Backward moves and the current + /// stage are always allowed; forward moves must be unlocked. + public static func canNavigate( + to target: WizardStage, + from current: WizardStage, + artefacts: StageArtefacts + ) -> Bool { + if target == current { return true } + if target == .calibrate || current == .calibrate { + // Stage 0 is a side-trip, not stepper navigation. + return true + } + if target.rawValue < current.rawValue { return true } + return isUnlocked(target, artefacts: artefacts) + } + + /// The deepest unlocked stepper stage — used when revalidation + /// locks the current stage (#151). + public static func deepestUnlocked(artefacts: StageArtefacts) -> WizardStage { + for stage in WizardStage.stepperStages.reversed() + where isUnlocked(stage, artefacts: artefacts) { + return stage + } + return .generate + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Wizard/WizardState.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Wizard/WizardState.swift new file mode 100644 index 0000000..85da63e --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Wizard/WizardState.swift @@ -0,0 +1,72 @@ +import Foundation + +/// Session mode (docs/06 §wizardState). `"calibration"` is set while +/// Stage 0 is driving a `CAL_` chart through the same pipeline. +public enum SessionMode: String, Codable, Sendable { + case profile + case calibration +} + +/// Persisted wizard state (docs/06 §wizardState fields) — +/// `wizard_state.json` in app data. +public struct WizardState: Codable, Equatable, Sendable { + /// 0–5 (`WizardStage.rawValue`). + public var currentStage: Int + /// Run name without extension — never invented (#60). + public var basename: String + /// Working directory for artefacts; empty → `resolveSafeCwd` (#59). + public var cwd: String + /// Last spooled printer, for calibration drift history. + public var printerName: String? + public var sessionMode: SessionMode + /// May differ from `basename` after a `.ti3` import (#94). + public var profileBasename: String? + + public init( + currentStage: Int = WizardStage.generate.rawValue, + basename: String = "", + cwd: String = "", + printerName: String? = nil, + sessionMode: SessionMode = .profile, + profileBasename: String? = nil + ) { + self.currentStage = currentStage + self.basename = basename + self.cwd = cwd + self.printerName = printerName + self.sessionMode = sessionMode + self.profileBasename = profileBasename + } + + public static let `default` = WizardState() + + /// The stage a saved `currentStage` resolves to, clamped to a valid + /// value (corrupt ints fall back to Stage 1). + public var stage: WizardStage { + WizardStage(rawValue: currentStage) ?? .generate + } +} + +/// Atomic JSON persistence for `WizardState` (issue #4). +public final class WizardStateStore: Sendable { + public let fileURL: URL + + public init( + fileURL: URL = AppPaths.appDataDir.appendingPathComponent("wizard_state.json") + ) { + self.fileURL = fileURL + } + + public func load() -> WizardState { + guard let data = try? Data(contentsOf: fileURL), + let state = try? JSONDecoder().decode(WizardState.self, from: data) + else { return .default } + return state + } + + public func save(_ state: WizardState) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + try AtomicFileWriter.write(encoder.encode(state), to: fileURL) + } +} diff --git a/Sources/ICCery/RootView.swift b/Sources/ICCery/RootView.swift index 59f5d9f..3a38f5c 100644 --- a/Sources/ICCery/RootView.swift +++ b/Sources/ICCery/RootView.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI /// Root layout: 270 pt sidebar + main stage area with the notification @@ -28,6 +29,13 @@ struct RootView: View { } .frame(minWidth: 1100, minHeight: 700) .background(Theme.background) + // #151: re-probe artefacts when the window regains focus — + // files deleted in Finder must re-lock stages. + .onReceive( + NotificationCenter.default.publisher( + for: NSWindow.didBecomeKeyNotification + ) + ) { _ in model.windowDidBecomeKey() } .sheet(isPresented: $showingSettings) { SettingsView() } diff --git a/Sources/ICCery/SidebarView.swift b/Sources/ICCery/SidebarView.swift index 0cefc64..7774bed 100644 --- a/Sources/ICCery/SidebarView.swift +++ b/Sources/ICCery/SidebarView.swift @@ -60,8 +60,8 @@ struct SidebarView: View { StepperRow( stage: stage, isActive: model.stage == stage, - // Only Stage 1 until artefact gating lands in #4. - isEnabled: stage == .generate + // Artefact gating (issue #4) — disk is truth. + isEnabled: model.isUnlocked(stage) ) { model.go(to: stage) } diff --git a/Sources/ICCery/WizardViewModel.swift b/Sources/ICCery/WizardViewModel.swift index 4c405bf..2b0b9dc 100644 --- a/Sources/ICCery/WizardViewModel.swift +++ b/Sources/ICCery/WizardViewModel.swift @@ -2,44 +2,142 @@ import Foundation import Observation import ICCeryCore -/// Wizard shell state (issue #1). Artefact gating, persistence and the -/// "open existing" flow land in issue #4. +/// Wizard state machine + artefact gating (issue #4, docs/06). +/// +/// `wizardState` fields (`currentStage`, `basename`, `cwd`, +/// `printerName`, `sessionMode`, `profileBasename`) are persisted to +/// `wizard_state.json`; unlocks come from `ArtefactProbe.verify` — +/// navigation is disk, not buttons. @MainActor @Observable final class WizardViewModel { - /// Currently displayed stage. - var stage: WizardStage = .generate + + // MARK: - wizardState fields (persisted) + + var stage: WizardStage { + didSet { if stage != oldValue { persist() } } + } + /// `wizardState.basename` — empty until a real artefact names it (#60). + var basename: String { + didSet { if basename != oldValue { refreshGating(); persist() } } + } + /// `wizardState.cwd` — resolved via `resolveSafeCwd` (#59). + var workingDirectory: URL? { + didSet { if workingDirectory != oldValue { refreshGating(); persist() } } + } + var printerName: String? { + didSet { if printerName != oldValue { persist() } } + } + var sessionMode: SessionMode { + didSet { if sessionMode != oldValue { persist() } } + } + /// `profileBasename` may differ after a `.ti3` import (#94). + var profileBasename: String? { + didSet { if profileBasename != oldValue { persist() } } + } + + // MARK: - Ephemeral /// Banner notice currently displayed (`#wizardNotification`). var notice: Notice? + /// Current artefact probe result; recomputed on `refreshGating()`. + private(set) var artefacts = StageArtefacts() - /// Target basename shared across stages (`targetBasename`). - var basename: String = "" - - /// Working directory for all Argyll artefacts. - var workingDirectory: URL? - - /// Printer queue selected in Stage 2; retained across stages. - var printerName: String? - + private let stateStore: WizardStateStore private var noticeDismissTask: Task? - /// `true` while Stage 0 (printer calibration) is shown instead of a - /// stepper stage. + init(stateStore: WizardStateStore = WizardStateStore()) { + self.stateStore = stateStore + let s = stateStore.load() + self.stage = s.stage + self.basename = s.basename + self.workingDirectory = s.cwd.isEmpty ? nil : URL(fileURLWithPath: s.cwd) + self.printerName = s.printerName + self.sessionMode = s.sessionMode + self.profileBasename = s.profileBasename + refreshGating() + // A restored stage may have been locked since (#151). + if !WizardGating.isUnlocked(stage, artefacts: artefacts), stage != .calibrate { + stage = WizardGating.deepestUnlocked(artefacts: artefacts) + } + } + + // MARK: - Gating + + /// `isUnlocked` for the sidebar stepper. + func isUnlocked(_ stage: WizardStage) -> Bool { + WizardGating.isUnlocked(stage, artefacts: artefacts) + } + + /// `true` while Stage 0 (printer calibration) is shown. var isCalibrating: Bool { stage == .calibrate } - func go(to stage: WizardStage) { - self.stage = stage + /// Re-probes the artefact directory and re-locks (#151). Called on + /// window focus, stage entry, and basename/cwd changes. + func refreshGating() { + guard !basename.isEmpty, let dir = effectiveWorkingDirectory else { + artefacts = StageArtefacts() + return + } + artefacts = ArtefactProbe.verify(basename: basename, cwd: dir) + } + + /// `setTarget(basename, cwd)` — validates the basename (no `/`, `\`, + /// `..`; no placeholders — #60) and resolves the cwd (#59). + func setTarget(basename: String, workingDirectory: URL?) { + do { + self.basename = try PathSecurity.sanitizeBasename(basename) + } catch { + showNotice("Invalid target name.", kind: .error) + return + } + self.workingDirectory = PathSecurity.resolveSafeCwd(workingDirectory) + } + + /// cwd never stays empty once a basename exists (#59). + var effectiveWorkingDirectory: URL? { + if let workingDirectory { return workingDirectory } + return basename.isEmpty ? nil : PathSecurity.resolveSafeCwd(nil) + } + + // MARK: - Navigation + + /// `navigateToStage(n)` — refuses locked forward moves with a + /// warning banner; backward is always allowed (docs/06). + func go(to target: WizardStage) { + guard target != .calibrate else { enterCalibration(); return } + if WizardGating.canNavigate(to: target, from: stage, artefacts: artefacts) { + stage = target + } else { + showNotice( + "Stage \(target.stepperIndex ?? 0) is locked — the required artefact is missing.", + kind: .warning + ) + } } func enterCalibration() { + sessionMode = .calibration stage = .calibrate } func exitCalibration() { + sessionMode = .profile stage = .generate } + /// Window-focus hook (#151): files deleted in Finder re-lock stages. + /// If the current stage re-locked, fall back to the deepest unlocked. + func windowDidBecomeKey() { + refreshGating() + if stage != .calibrate, + !WizardGating.isUnlocked(stage, artefacts: artefacts) { + stage = WizardGating.deepestUnlocked(artefacts: artefacts) + } + } + + // MARK: - Notice + func showNotice(_ text: String, kind: Notice.Kind = .info, autoHideAfter: TimeInterval? = 6) { noticeDismissTask?.cancel() let notice = Notice(kind: kind, text: text, autoHideAfter: autoHideAfter) @@ -59,4 +157,18 @@ final class WizardViewModel { noticeDismissTask?.cancel() notice = nil } + + // MARK: - Persistence + + private func persist() { + let state = WizardState( + currentStage: stage.rawValue, + basename: basename, + cwd: workingDirectory?.path ?? "", + printerName: printerName, + sessionMode: sessionMode, + profileBasename: profileBasename + ) + try? stateStore.save(state) + } } diff --git a/Tests/ICCeryCoreTests/WizardGatingTests.swift b/Tests/ICCeryCoreTests/WizardGatingTests.swift new file mode 100644 index 0000000..4ebf930 --- /dev/null +++ b/Tests/ICCeryCoreTests/WizardGatingTests.swift @@ -0,0 +1,127 @@ +import Testing +import Foundation +@testable import ICCeryCore + +private func artefacts( + ti1: Bool = false, ti2: Bool = false, ti3: Bool = false, profile: Bool = false +) -> StageArtefacts { + var a = StageArtefacts() + a.stage1Complete = ti1 + a.stage2Complete = ti2 + a.stage3Complete = ti3 + a.stage4Complete = profile + if profile { + a.profilePath = URL(fileURLWithPath: "/x/t.icc") + } + return a +} + +@Suite("WizardGating matrix") +struct WizardGatingTests { + + @Test func emptyProjectOnlyStage1() { + let a = artefacts() + #expect(WizardGating.isUnlocked(.generate, artefacts: a)) + #expect(WizardGating.isUnlocked(.calibrate, artefacts: a)) + for s in [WizardStage.layOutPrint, .measure, .buildProfile, .verifyInstall] { + #expect(!WizardGating.isUnlocked(s, artefacts: a), "\(s) should be locked") + } + } + + @Test func ti1UnlocksStage2Only() { + let a = artefacts(ti1: true) + #expect(WizardGating.isUnlocked(.layOutPrint, artefacts: a)) + #expect(!WizardGating.isUnlocked(.measure, artefacts: a)) + #expect(!WizardGating.isUnlocked(.buildProfile, artefacts: a)) + #expect(!WizardGating.isUnlocked(.verifyInstall, artefacts: a)) + } + + @Test func stage3NeedsTi1AndTi2() { + #expect(!WizardGating.isUnlocked(.measure, artefacts: artefacts(ti2: true))) + #expect(WizardGating.isUnlocked(.measure, artefacts: artefacts(ti1: true, ti2: true))) + } + + @Test func stage4NeedsTi3NotTi2() { + // #109/#110: .ti2 alone must never unlock Stage 4. + let a = artefacts(ti1: true, ti2: true) + #expect(!WizardGating.isUnlocked(.buildProfile, artefacts: a)) + #expect(WizardGating.isUnlocked(.buildProfile, artefacts: artefacts(ti3: true))) + } + + @Test func stage5NeedsTi3AndProfile() { + #expect(!WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(ti3: true))) + #expect(!WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(profile: true))) + #expect(WizardGating.isUnlocked( + .verifyInstall, artefacts: artefacts(ti3: true, profile: true) + )) + } + + @Test func forwardGatedBackwardFree() { + let a = artefacts() + #expect(!WizardGating.canNavigate(to: .layOutPrint, from: .generate, artefacts: a)) + // Backward always allowed even when artefacts vanished. + #expect(WizardGating.canNavigate(to: .generate, from: .measure, artefacts: a)) + // Same stage is a no-op. + #expect(WizardGating.canNavigate(to: .measure, from: .measure, artefacts: a)) + // Stage 0 is a side-trip, never gated. + #expect(WizardGating.canNavigate(to: .calibrate, from: .generate, artefacts: a)) + } + + @Test func deepestUnlocked() { + #expect(WizardGating.deepestUnlocked(artefacts: artefacts()) == .generate) + #expect(WizardGating.deepestUnlocked( + artefacts: artefacts(ti1: true, ti2: true) + ) == .measure) + #expect(WizardGating.deepestUnlocked( + artefacts: artefacts(ti3: true, profile: true) + ) == .verifyInstall) + } +} + +@Suite("WizardStateStore") +struct WizardStateStoreTests { + private func tempURL() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-wiz-\(UUID().uuidString)") + .appendingPathComponent("wizard_state.json") + } + + @Test func roundTrip() throws { + let url = tempURL() + let store = WizardStateStore(fileURL: url) + var s = WizardState() + s.currentStage = 3 + s.basename = "run-42" + s.cwd = "/tmp/charts" + s.sessionMode = .calibration + s.profileBasename = "imported" + try store.save(s) + #expect(store.load() == s) + } + + @Test func missingFileDefaults() { + let s = WizardStateStore(fileURL: tempURL()).load() + #expect(s == .default) + #expect(s.stage == .generate) + #expect(s.sessionMode == .profile) + } + + @Test func corruptStageFallsBackToGenerate() throws { + let url = tempURL() + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true + ) + try #"{"current_stage": 99, "basename": "", "cwd": "", "session_mode": "profile"}"# + .write(to: url, atomically: true, encoding: .utf8) + #expect(WizardStateStore(fileURL: url).load().stage == .generate) + } + + @Test func sessionModeCalibrationRoundTrips() throws { + var s = WizardState(sessionMode: .calibration) + let data = try JSONEncoder().encode(s) + let decoded = try JSONDecoder().decode(WizardState.self, from: data) + #expect(decoded.sessionMode == .calibration) + s.sessionMode = .profile + #expect(s.sessionMode == .profile) + } +}