Compare commits
4
Commits
c5fc89ec40
...
55ffe879ac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55ffe879ac | ||
|
|
120f713b46 | ||
|
|
b52720700f | ||
|
|
f027693fa9 |
@@ -21,6 +21,9 @@ public struct WizardState: Codable, Equatable, Sendable {
|
||||
public var sessionMode: SessionMode
|
||||
/// May differ from `basename` after a `.ti3` import (#94).
|
||||
public var profileBasename: String?
|
||||
/// The pre-`CAL_` basename, persisted so a crash/relaunch can
|
||||
/// restore the original (#29).
|
||||
public var calibrationOriginalBasename: String = ""
|
||||
|
||||
public init(
|
||||
currentStage: Int = WizardStage.generate.rawValue,
|
||||
@@ -28,7 +31,8 @@ public struct WizardState: Codable, Equatable, Sendable {
|
||||
cwd: String = "",
|
||||
printerName: String? = nil,
|
||||
sessionMode: SessionMode = .profile,
|
||||
profileBasename: String? = nil
|
||||
profileBasename: String? = nil,
|
||||
calibrationOriginalBasename: String = ""
|
||||
) {
|
||||
self.currentStage = currentStage
|
||||
self.basename = basename
|
||||
@@ -36,6 +40,7 @@ public struct WizardState: Codable, Equatable, Sendable {
|
||||
self.printerName = printerName
|
||||
self.sessionMode = sessionMode
|
||||
self.profileBasename = profileBasename
|
||||
self.calibrationOriginalBasename = calibrationOriginalBasename
|
||||
}
|
||||
|
||||
public static let `default` = WizardState()
|
||||
|
||||
@@ -27,8 +27,6 @@ final class CalibrationViewModel {
|
||||
var isComputing = false
|
||||
var lastError: String?
|
||||
|
||||
private var originalBasename: String = ""
|
||||
|
||||
init(workflow: TargetWorkflowViewModel, profile: ProfileWorkflowViewModel, environment: AppEnvironment) {
|
||||
self.workflow = workflow
|
||||
self.profile = profile
|
||||
@@ -53,7 +51,11 @@ final class CalibrationViewModel {
|
||||
}
|
||||
|
||||
private var calBasename: String {
|
||||
originalBasename.isEmpty ? "CAL_\(wizard.basename)" : "CAL_\(originalBasename)"
|
||||
if wizard.basename.hasPrefix("CAL_") { return wizard.basename }
|
||||
let original = !wizard.calibrationOriginalBasename.isEmpty
|
||||
? wizard.calibrationOriginalBasename
|
||||
: wizard.basename
|
||||
return "CAL_\(original)"
|
||||
}
|
||||
|
||||
private var calOutputURL: URL? {
|
||||
@@ -65,8 +67,14 @@ final class CalibrationViewModel {
|
||||
|
||||
func generateTarget() {
|
||||
guard canGenerate, let cwd = wizard.effectiveWorkingDirectory else { return }
|
||||
originalBasename = wizard.basename
|
||||
wizard.basename = calBasename
|
||||
// Snapshot the original (pre-CAL_) basename before changing the live one.
|
||||
if !wizard.basename.hasPrefix("CAL_") {
|
||||
wizard.calibrationOriginalBasename = wizard.basename
|
||||
} else if wizard.calibrationOriginalBasename.isEmpty {
|
||||
wizard.calibrationOriginalBasename = String(wizard.basename.dropFirst(4))
|
||||
}
|
||||
let original = wizard.calibrationOriginalBasename
|
||||
wizard.basename = "CAL_\(original)"
|
||||
wizard.sessionMode = .calibration
|
||||
|
||||
isGenerating = true
|
||||
@@ -79,7 +87,7 @@ final class CalibrationViewModel {
|
||||
whitePatches: whitePatches,
|
||||
includeNeutralEmphasis: includeNeutralEmphasis,
|
||||
inkLimit: inkLimitValue,
|
||||
basename: originalBasename,
|
||||
basename: original,
|
||||
workingDirectory: cwd
|
||||
)
|
||||
|
||||
@@ -102,7 +110,7 @@ final class CalibrationViewModel {
|
||||
"Calibration target failed: \(error.localizedDescription)",
|
||||
kind: .error
|
||||
)
|
||||
self.restoreProfileBasename()
|
||||
self.wizard.restoreCalibration()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -166,6 +174,7 @@ final class CalibrationViewModel {
|
||||
self.profile.calibrationFile = url.path
|
||||
self.profile.applyCalibration = self.applyToProfile
|
||||
self.wizard.showNotice("Calibration curves computed.")
|
||||
self.wizard.restoreCalibration()
|
||||
} catch {
|
||||
self.lastError = error.localizedDescription
|
||||
self.wizard.showNotice(
|
||||
@@ -190,18 +199,10 @@ final class CalibrationViewModel {
|
||||
}
|
||||
|
||||
func returnToProfiling() {
|
||||
restoreProfileBasename()
|
||||
wizard.sessionMode = .profile
|
||||
wizard.restoreCalibration()
|
||||
wizard.go(to: .generate)
|
||||
}
|
||||
|
||||
private func restoreProfileBasename() {
|
||||
if !originalBasename.isEmpty {
|
||||
wizard.basename = originalBasename
|
||||
originalBasename = ""
|
||||
}
|
||||
}
|
||||
|
||||
private var inkLimitValue: Int? {
|
||||
guard colourSpace == .cmyk else { return nil }
|
||||
return Int(inkLimit)
|
||||
|
||||
@@ -5,7 +5,8 @@ import ICCeryCore
|
||||
/// Wizard state machine + artefact gating (issue #4, docs/06).
|
||||
///
|
||||
/// `wizardState` fields (`currentStage`, `basename`, `cwd`,
|
||||
/// `printerName`, `sessionMode`, `profileBasename`) are persisted to
|
||||
/// `printerName`, `sessionMode`, `profileBasename`,
|
||||
/// `calibrationOriginalBasename`) are persisted to
|
||||
/// `wizard_state.json`; unlocks come from `ArtefactProbe.verify` —
|
||||
/// navigation is disk, not buttons.
|
||||
@MainActor
|
||||
@@ -35,6 +36,10 @@ final class WizardViewModel {
|
||||
var profileBasename: String? {
|
||||
didSet { if profileBasename != oldValue { persist() } }
|
||||
}
|
||||
/// Pre-`CAL_` basename, persisted so relaunch/Force Quit can restore it (#29).
|
||||
var calibrationOriginalBasename: String {
|
||||
didSet { if calibrationOriginalBasename != oldValue { persist() } }
|
||||
}
|
||||
|
||||
// MARK: - Ephemeral
|
||||
|
||||
@@ -59,6 +64,15 @@ final class WizardViewModel {
|
||||
self.printerName = s.printerName
|
||||
self.sessionMode = s.sessionMode
|
||||
self.profileBasename = s.profileBasename
|
||||
self.calibrationOriginalBasename = s.calibrationOriginalBasename
|
||||
// A Force Quit mid-calibration leaves a CAL_ basename behind; restore
|
||||
// the original before the UI can do anything with it (#29).
|
||||
if basename.hasPrefix("CAL_"), !calibrationOriginalBasename.isEmpty {
|
||||
basename = calibrationOriginalBasename
|
||||
calibrationOriginalBasename = ""
|
||||
sessionMode = .profile
|
||||
stage = .generate
|
||||
}
|
||||
refreshGating()
|
||||
// A restored stage may have been locked since (#151).
|
||||
if !WizardGating.isUnlocked(stage, artefacts: artefacts), stage != .calibrate {
|
||||
@@ -108,8 +122,25 @@ final class WizardViewModel {
|
||||
|
||||
/// `navigateToStage(n)` — refuses locked forward moves with a
|
||||
/// warning banner; backward is always allowed (docs/06).
|
||||
///
|
||||
/// If the live basename has a `CAL_` prefix, only `.calibrate`,
|
||||
/// `.layOutPrint`, and `.measure` are allowed; any other target is
|
||||
/// refused and the original basename is restored (#29).
|
||||
func go(to target: WizardStage) {
|
||||
guard target != .calibrate else { enterCalibration(); return }
|
||||
if basename.hasPrefix("CAL_") {
|
||||
guard !calibrationOriginalBasename.isEmpty else {
|
||||
showNotice(
|
||||
"Cannot leave calibration — the original target name is missing.",
|
||||
kind: .warning
|
||||
)
|
||||
return
|
||||
}
|
||||
if target == .generate || target == .buildProfile || target == .verifyInstall {
|
||||
restoreCalibration()
|
||||
return
|
||||
}
|
||||
}
|
||||
if WizardGating.canNavigate(to: target, from: stage, artefacts: artefacts) {
|
||||
stage = target
|
||||
} else {
|
||||
@@ -121,6 +152,9 @@ final class WizardViewModel {
|
||||
}
|
||||
|
||||
func enterCalibration() {
|
||||
if !basename.isEmpty, !basename.hasPrefix("CAL_"), calibrationOriginalBasename.isEmpty {
|
||||
calibrationOriginalBasename = basename
|
||||
}
|
||||
sessionMode = .calibration
|
||||
stage = .calibrate
|
||||
}
|
||||
@@ -130,6 +164,15 @@ final class WizardViewModel {
|
||||
stage = .generate
|
||||
}
|
||||
|
||||
/// Restore the original profile basename and leave calibration mode.
|
||||
func restoreCalibration() {
|
||||
if !calibrationOriginalBasename.isEmpty {
|
||||
basename = calibrationOriginalBasename
|
||||
calibrationOriginalBasename = ""
|
||||
}
|
||||
sessionMode = .profile
|
||||
}
|
||||
|
||||
/// Open the 3D gamut viewer (issue #28).
|
||||
func openGamut(profileGamURL: URL? = nil) {
|
||||
self.gamutProfileURL = profileGamURL
|
||||
@@ -177,7 +220,8 @@ final class WizardViewModel {
|
||||
cwd: workingDirectory?.path ?? "",
|
||||
printerName: printerName,
|
||||
sessionMode: sessionMode,
|
||||
profileBasename: profileBasename
|
||||
profileBasename: profileBasename,
|
||||
calibrationOriginalBasename: calibrationOriginalBasename
|
||||
)
|
||||
try? stateStore.save(state)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import ICCeryCore
|
||||
@testable import ICCery
|
||||
|
||||
/// Issue #29 — `CAL_` basename must be restored on relaunch and on any
|
||||
/// attempt to navigate to a non-calibration stage that would use it.
|
||||
@Suite("WizardCalibrationSession")
|
||||
@MainActor
|
||||
struct WizardCalibrationSessionTests {
|
||||
|
||||
private func tempURL() -> URL {
|
||||
FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("iccery-cal-state-\(UUID().uuidString)")
|
||||
.appendingPathComponent("wizard_state.json")
|
||||
}
|
||||
|
||||
private func tempDir() throws -> URL {
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("iccery-cal-dir-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(
|
||||
at: url, withIntermediateDirectories: true
|
||||
)
|
||||
return url
|
||||
}
|
||||
|
||||
@Test("Persist and restore calibrationOriginalBasename across a relaunch")
|
||||
func relaunchRestoresOriginal() throws {
|
||||
let url = tempURL()
|
||||
let store = WizardStateStore(fileURL: url)
|
||||
var saved = WizardState(
|
||||
currentStage: WizardStage.calibrate.rawValue,
|
||||
basename: "CAL_DemoTarget",
|
||||
cwd: "/tmp/charts",
|
||||
sessionMode: .calibration,
|
||||
calibrationOriginalBasename: "DemoTarget"
|
||||
)
|
||||
try store.save(saved)
|
||||
|
||||
let model = WizardViewModel(stateStore: store)
|
||||
|
||||
#expect(model.basename == "DemoTarget")
|
||||
#expect(model.calibrationOriginalBasename == "")
|
||||
#expect(model.sessionMode == .profile)
|
||||
#expect(model.stage == .generate)
|
||||
}
|
||||
|
||||
@Test("go(to: .buildProfile) while basename is CAL_ refuses and restores the original")
|
||||
func goToBuildProfileRefusesAndRestores() throws {
|
||||
let dir = try tempDir()
|
||||
let url = tempURL()
|
||||
let store = WizardStateStore(fileURL: url)
|
||||
let model = WizardViewModel(stateStore: store)
|
||||
|
||||
model.setTarget(basename: "DemoTarget", workingDirectory: dir)
|
||||
model.calibrationOriginalBasename = "DemoTarget"
|
||||
model.basename = "CAL_DemoTarget"
|
||||
model.sessionMode = .calibration
|
||||
model.stage = .calibrate
|
||||
|
||||
model.go(to: .buildProfile)
|
||||
|
||||
#expect(model.basename == "DemoTarget")
|
||||
#expect(model.calibrationOriginalBasename == "")
|
||||
#expect(model.sessionMode == .profile)
|
||||
#expect(model.stage == .calibrate)
|
||||
}
|
||||
|
||||
@Test("go(to: .layOutPrint) while basename is CAL_ stays in calibration")
|
||||
func goToLayoutStaysCal() throws {
|
||||
let dir = try tempDir()
|
||||
let url = tempURL()
|
||||
let store = WizardStateStore(fileURL: url)
|
||||
let model = WizardViewModel(stateStore: store)
|
||||
|
||||
model.setTarget(basename: "DemoTarget", workingDirectory: dir)
|
||||
model.calibrationOriginalBasename = "DemoTarget"
|
||||
model.basename = "CAL_DemoTarget"
|
||||
model.sessionMode = .calibration
|
||||
model.stage = .calibrate
|
||||
|
||||
model.go(to: .layOutPrint)
|
||||
|
||||
#expect(model.basename == "CAL_DemoTarget")
|
||||
#expect(model.calibrationOriginalBasename == "DemoTarget")
|
||||
#expect(model.sessionMode == .calibration)
|
||||
#expect(model.stage == .layOutPrint)
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,7 @@ struct WizardStateStoreTests {
|
||||
s.cwd = "/tmp/charts"
|
||||
s.sessionMode = .calibration
|
||||
s.profileBasename = "imported"
|
||||
s.calibrationOriginalBasename = "pre-cal"
|
||||
try store.save(s)
|
||||
#expect(store.load() == s)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user