Wizard state machine & artefact gating (#4) #39
@@ -12,6 +12,20 @@ public struct StageArtefacts: Sendable, Equatable {
|
|||||||
public var stage4Complete = false
|
public var stage4Complete = false
|
||||||
/// Absolute path of the profile file when present.
|
/// Absolute path of the profile file when present.
|
||||||
public var profilePath: URL?
|
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,
|
/// Filesystem probing for wizard artefacts (docs/02 §Working directory,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import AppKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// Root layout: 270 pt sidebar + main stage area with the notification
|
/// Root layout: 270 pt sidebar + main stage area with the notification
|
||||||
@@ -28,6 +29,13 @@ struct RootView: View {
|
|||||||
}
|
}
|
||||||
.frame(minWidth: 1100, minHeight: 700)
|
.frame(minWidth: 1100, minHeight: 700)
|
||||||
.background(Theme.background)
|
.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) {
|
.sheet(isPresented: $showingSettings) {
|
||||||
SettingsView()
|
SettingsView()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,8 +60,8 @@ struct SidebarView: View {
|
|||||||
StepperRow(
|
StepperRow(
|
||||||
stage: stage,
|
stage: stage,
|
||||||
isActive: model.stage == stage,
|
isActive: model.stage == stage,
|
||||||
// Only Stage 1 until artefact gating lands in #4.
|
// Artefact gating (issue #4) — disk is truth.
|
||||||
isEnabled: stage == .generate
|
isEnabled: model.isUnlocked(stage)
|
||||||
) {
|
) {
|
||||||
model.go(to: stage)
|
model.go(to: stage)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,44 +2,142 @@ import Foundation
|
|||||||
import Observation
|
import Observation
|
||||||
import ICCeryCore
|
import ICCeryCore
|
||||||
|
|
||||||
/// Wizard shell state (issue #1). Artefact gating, persistence and the
|
/// Wizard state machine + artefact gating (issue #4, docs/06).
|
||||||
/// "open existing" flow land in issue #4.
|
///
|
||||||
|
/// `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
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
final class WizardViewModel {
|
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`).
|
/// Banner notice currently displayed (`#wizardNotification`).
|
||||||
var notice: Notice?
|
var notice: Notice?
|
||||||
|
/// Current artefact probe result; recomputed on `refreshGating()`.
|
||||||
|
private(set) var artefacts = StageArtefacts()
|
||||||
|
|
||||||
/// Target basename shared across stages (`targetBasename`).
|
private let stateStore: WizardStateStore
|
||||||
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 var noticeDismissTask: Task<Void, Never>?
|
private var noticeDismissTask: Task<Void, Never>?
|
||||||
|
|
||||||
/// `true` while Stage 0 (printer calibration) is shown instead of a
|
init(stateStore: WizardStateStore = WizardStateStore()) {
|
||||||
/// stepper stage.
|
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 }
|
var isCalibrating: Bool { stage == .calibrate }
|
||||||
|
|
||||||
func go(to stage: WizardStage) {
|
/// Re-probes the artefact directory and re-locks (#151). Called on
|
||||||
self.stage = stage
|
/// 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() {
|
func enterCalibration() {
|
||||||
|
sessionMode = .calibration
|
||||||
stage = .calibrate
|
stage = .calibrate
|
||||||
}
|
}
|
||||||
|
|
||||||
func exitCalibration() {
|
func exitCalibration() {
|
||||||
|
sessionMode = .profile
|
||||||
stage = .generate
|
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) {
|
func showNotice(_ text: String, kind: Notice.Kind = .info, autoHideAfter: TimeInterval? = 6) {
|
||||||
noticeDismissTask?.cancel()
|
noticeDismissTask?.cancel()
|
||||||
let notice = Notice(kind: kind, text: text, autoHideAfter: autoHideAfter)
|
let notice = Notice(kind: kind, text: text, autoHideAfter: autoHideAfter)
|
||||||
@@ -59,4 +157,18 @@ final class WizardViewModel {
|
|||||||
noticeDismissTask?.cancel()
|
noticeDismissTask?.cancel()
|
||||||
notice = nil
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user