diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Library/ICCeryProject.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Library/ICCeryProject.swift new file mode 100644 index 0000000..e45f2b2 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Library/ICCeryProject.swift @@ -0,0 +1,218 @@ +import Foundation + +/// The `.icceryproj` file — a JSON **index** over a basename + working +/// directory + optional media recipe/preset binding + last verification +/// snapshot (issue #149, docs/06 §Project file). +/// +/// The project is never a second source of truth: artefact gating stays +/// on disk (`ArtefactProbe`), and `wizard_state.json` continues to +/// persist the live session. snake_case keys match the v1 schema; +/// `schema_version != 1` is a hard decode error — v2 fields are never +/// partially decoded. +public struct ICCeryProject: Codable, Equatable, Sendable { + + /// The only schema version this build reads and writes. + public static let currentSchemaVersion = 1 + + public var schemaVersion: Int + public var name: String + public var notes: String + /// Run name without extension — never `CAL_`-persisted (#60/R11). + public var basename: String + /// Absolute working directory holding the artefacts (#59). + public var cwd: String + /// May differ from `basename` after a `.ti3` import (#94). + public var profileBasename: String? + /// CUPS queue id, when a printer was selected. + public var printerID: String? + public var printerDisplayName: String? + /// `MediaRecipe.id` — optional; ignored when the library file is + /// absent or the id is unknown (soft-dependency, #146). + public var mediaRecipeID: String? + public var presetID: String? + /// Absolute `.cal` path stored verbatim; `nil` = none. + public var calibrationURL: String? + public var lastVerification: VerificationSnapshot? + public var updated: Date + + public init( + schemaVersion: Int = ICCeryProject.currentSchemaVersion, + name: String = "", + notes: String = "", + basename: String, + cwd: String, + profileBasename: String? = nil, + printerID: String? = nil, + printerDisplayName: String? = nil, + mediaRecipeID: String? = nil, + presetID: String? = nil, + calibrationURL: String? = nil, + lastVerification: VerificationSnapshot? = nil, + updated: Date = Date() + ) { + self.schemaVersion = schemaVersion + self.name = name + self.notes = notes + self.basename = basename + self.cwd = cwd + self.profileBasename = profileBasename + self.printerID = printerID + self.printerDisplayName = printerDisplayName + self.mediaRecipeID = mediaRecipeID + self.presetID = presetID + self.calibrationURL = calibrationURL + self.lastVerification = lastVerification + self.updated = updated + } + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case name, notes, basename, cwd + case profileBasename = "profile_basename" + case printerID = "printer_id" + case printerDisplayName = "printer_display_name" + case mediaRecipeID = "media_recipe_id" + case presetID = "preset_id" + case calibrationURL = "calibration_url" + case lastVerification = "last_verification" + case updated + } + + /// Strict decode: `schema_version` is required and must equal 1 — + /// anything else throws before a single v2 field is read. Required + /// strings (`basename`, `cwd`) must be present; optionals default. + /// Unknown keys are ignored. + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + let version = try c.decode(Int.self, forKey: .schemaVersion) + guard version == ICCeryProject.currentSchemaVersion else { + throw ValidationError.unsupportedSchema(version) + } + schemaVersion = version + name = try c.decodeIfPresent(String.self, forKey: .name) ?? "" + notes = try c.decodeIfPresent(String.self, forKey: .notes) ?? "" + basename = try c.decode(String.self, forKey: .basename) + cwd = try c.decode(String.self, forKey: .cwd) + profileBasename = try c.decodeIfPresent(String.self, forKey: .profileBasename) + printerID = try c.decodeIfPresent(String.self, forKey: .printerID) + printerDisplayName = try c.decodeIfPresent(String.self, forKey: .printerDisplayName) + mediaRecipeID = try c.decodeIfPresent(String.self, forKey: .mediaRecipeID) + presetID = try c.decodeIfPresent(String.self, forKey: .presetID) + calibrationURL = try c.decodeIfPresent(String.self, forKey: .calibrationURL) + lastVerification = try c.decodeIfPresent(VerificationSnapshot.self, forKey: .lastVerification) + updated = try c.decodeIfPresent(Date.self, forKey: .updated) ?? Date() + } + + public enum ValidationError: LocalizedError, Equatable { + case unsupportedSchema(Int) + case emptyBasename + case invalidBasename(String) + case emptyCwd + case unsafeCwd(String) + case invalidCalibrationURL(String) + + public var errorDescription: String? { + switch self { + case .unsupportedSchema: + return "This project file is not schema 1." + case .emptyBasename: + return "A project needs a target basename." + case .invalidBasename(let v): + return "Illegal project basename \"\(v)\"." + case .emptyCwd: + return "A project needs a working folder." + case .unsafeCwd(let v): + return "cwd must be an absolute path, got \"\(v)\"." + case .invalidCalibrationURL(let v): + return "calibration_url must be an absolute path without \"..\" or NUL, got \"\(v)\"." + } + } + } + + /// Validates the index fields. Empty basename/cwd refuse (#59/#60); + /// basename still rejects `/`, `\`, `..`; cwd must be absolute. An + /// empty `name` falls back to the basename. + @discardableResult + public func validated() throws -> ICCeryProject { + var p = self + p.basename = basename.trimmingCharacters(in: .whitespacesAndNewlines) + p.name = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !p.basename.isEmpty else { throw ValidationError.emptyBasename } + guard PathSecurity.isValidBasename(p.basename) else { + throw ValidationError.invalidBasename(p.basename) + } + let trimmedCwd = p.cwd.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedCwd.isEmpty else { throw ValidationError.emptyCwd } + guard trimmedCwd.hasPrefix("/"), !trimmedCwd.contains("\0") else { + throw ValidationError.unsafeCwd(p.cwd) + } + p.cwd = trimmedCwd + if p.name.isEmpty { p.name = p.basename } + if let cal = p.calibrationURL, !cal.isEmpty { + guard cal.hasPrefix("/"), !cal.contains(".."), !cal.contains("\0") else { + throw ValidationError.invalidCalibrationURL(cal) + } + } + return p + } + + /// Reads a `.icceryproj` file. Throws `unsupportedSchema` for + /// `schema_version != 1` and the decode/validation error otherwise; + /// callers must leave live state untouched on failure. + public static func load(from url: URL) throws -> ICCeryProject { + let data = try Data(contentsOf: url) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(ICCeryProject.self, from: data).validated() + } + + /// Atomic `.tmp` + rename write via `AtomicFileWriter` (#213). + /// Validation runs first — a refused project never touches disk. + public func save(to url: URL) throws { + let encoder = JSONEncoder.icceryPretty(dateEncoding: .iso8601) + try AtomicFileWriter.write(try encoder.encode(validated()), to: url) + } +} + +/// The last `VerificationRecord` frozen into the project file — notes +/// only, never gating truth. +public struct VerificationSnapshot: Codable, Equatable, Sendable { + public var date: Date + public var avgDE00: Double + public var maxDE00: Double + /// `VerificationStatus.rawValue`. + public var status: String + public var profileFilename: String + + public init( + date: Date, + avgDE00: Double, + maxDE00: Double, + status: String, + profileFilename: String + ) { + self.date = date + self.avgDE00 = avgDE00 + self.maxDE00 = maxDE00 + self.status = status + self.profileFilename = profileFilename + } + + public init(record: VerificationRecord) { + self.init( + date: record.timestamp, + avgDE00: record.avgDE, + maxDE00: record.maxDE, + status: record.status.rawValue, + profileFilename: record.profileName + ) + } + + enum CodingKeys: String, CodingKey { + case date + case avgDE00 = "avg_de00" + case maxDE00 = "max_de00" + case status + case profileFilename = "profile_filename" + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Library/ProjectReport.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Library/ProjectReport.swift new file mode 100644 index 0000000..d5a94ab --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Library/ProjectReport.swift @@ -0,0 +1,95 @@ +import Foundation + +/// `Save Report…` — a one-page UTF-8 Markdown summary written next to +/// the artefacts as `{basename}-report.md` (issue #149). Generated +/// output, safe to overwrite. Filenames only — never absolute paths — +/// and no HTML. +public enum ProjectReport { + + /// `{cwd}/{basename}-report.md`. + public static func url(for project: ICCeryProject) -> URL { + URL(fileURLWithPath: project.cwd, isDirectory: true) + .appendingPathComponent("\(project.basename)-report.md") + } + + /// Renders the report. `artefacts` is the live probe result so the + /// checklist shows what is actually on disk, not what the project + /// JSON claims (R18). + public static func markdown( + project: ICCeryProject, + recipeName: String?, + artefacts: StageArtefacts + ) -> String { + var lines: [String] = [] + lines.append("# \(project.name)") + lines.append("") + if let printer = project.printerDisplayName ?? project.printerID, + !printer.isEmpty { + lines.append("- **Printer:** \(printer)") + } + if let recipeName, !recipeName.isEmpty { + lines.append("- **Media recipe:** \(recipeName)") + } + if let preset = project.presetID, !preset.isEmpty { + lines.append("- **Preset:** \(preset)") + } + lines.append("") + + lines.append("## Artefacts") + lines.append("") + lines.append("| File | Status |") + lines.append("|------|--------|") + lines.append(row("\(project.basename).ti1", exists: artefacts.stage1Complete)) + lines.append(row("\(project.basename).ti2", exists: artefacts.stage2Complete)) + lines.append(row("\(project.basename).ti3", exists: artefacts.stage3Complete)) + let profileName = artefacts.profilePath?.lastPathComponent + ?? "\(project.basename).\(ArtefactProbe.defaultProfileExtension)" + lines.append(row(profileName, exists: artefacts.stage4Complete)) + if let gam = artefacts.gamPath { + lines.append(row(gam.lastPathComponent, exists: true)) + } + lines.append("") + + if let verification = project.lastVerification { + lines.append("## Last verification") + lines.append("") + lines.append( + "- avg ΔE₀₀ \(f(verification.avgDE00)), max ΔE₀₀ \(f(verification.maxDE00))" + + " (\(verification.status))") + lines.append("- Profile: \(verification.profileFilename)") + lines.append("- Date: \(ISO8601DateFormatter().string(from: verification.date))") + lines.append("") + } + + if !project.notes.isEmpty { + lines.append("## Notes") + lines.append("") + lines.append(project.notes) + lines.append("") + } + return lines.joined(separator: "\n") + } + + /// Writes `{cwd}/{basename}-report.md` atomically, overwriting an + /// existing generated report. + @discardableResult + public static func write( + project: ICCeryProject, + recipeName: String?, + artefacts: StageArtefacts + ) throws -> URL { + let destination = url(for: project) + try AtomicFileWriter.write( + markdown(project: project, recipeName: recipeName, artefacts: artefacts), + to: destination) + return destination + } + + private static func row(_ filename: String, exists: Bool) -> String { + "| \(filename) | \(exists ? "exists" : "missing") |" + } + + private static func f(_ value: Double) -> String { + String(format: "%.2f", value) + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Library/RecentProjectsStore.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Library/RecentProjectsStore.swift new file mode 100644 index 0000000..630e81f --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Library/RecentProjectsStore.swift @@ -0,0 +1,162 @@ +import Foundation + +/// One row in `recent_projects.json` — bookmark `Data` *and* the plain +/// absolute path so the entry still resolves when the bookmark can't. +public struct RecentProjectEntry: Codable, Equatable, Sendable, Identifiable { + public var name: String + /// Absolute path to the `.icceryproj` file. + public var path: String + /// File bookmark; optional — path is the fallback. + public var bookmark: Data? + public var updated: Date + + public var id: String { path } + + /// Stable digest of `path` for `projectRecent-{hash}` menu ids — + /// FNV-1a 64, stable across launches unlike `hashValue`. + public var bookmarkHash: String { + var hash: UInt64 = 0xcbf29ce484222325 + for byte in path.utf8 { + hash ^= UInt64(byte) + hash &*= 0x100000001b3 + } + return String(hash, radix: 16) + } + + public init(name: String, path: String, bookmark: Data? = nil, updated: Date = Date()) { + self.name = name + self.path = path + self.bookmark = bookmark + self.updated = updated + } + + enum CodingKeys: String, CodingKey { + case name, path, bookmark, updated + } +} + +/// Recent-projects list (issue #149). Lives in +/// `AppPaths.appDataDir/recent_projects.json` — app data, never inside +/// the project file (R12). +/// +/// Cap 20, newest first, deduplicated by path. Entries whose file is +/// gone are dropped by `pruneMissing()` (called when the Open Recent +/// submenu builds). A corrupt file throws on load and is never +/// overwritten — the view model shows an empty list and keeps the +/// bytes (`.throwCorrupt`, R12). +public actor RecentProjectsStore { + + /// Default cap. + public static let defaultCapacity = 20 + + /// Path to the JSON store. + public let url: URL + + /// In-memory cache, kept in sync with disk. + private var entries: [RecentProjectEntry] = [] + + /// Explicit load flag — an empty file is still "loaded". + private var loaded = false + + private let capacity: Int + private let fileStore: JSONFileStore<[RecentProjectEntry]> + private let fileManager: FileManager + + public init( + url: URL = AppPaths.appDataDir.appendingPathComponent("recent_projects.json"), + capacity: Int = defaultCapacity, + fileManager: FileManager = .default + ) { + self.url = url + self.capacity = capacity + self.fileManager = fileManager + self.fileStore = JSONFileStore( + fileURL: url, + corrupt: .throwCorrupt, + defaultValue: { [] }, + dateEncoding: .iso8601, + dateDecoding: .iso8601 + ) + } + + /// Loads entries from disk. Returns the existing cache if already + /// loaded. Throws when the file exists but cannot be parsed; the + /// existing file is never overwritten in that case. + public func load() throws -> [RecentProjectEntry] { + guard !loaded else { return entries } + guard fileManager.fileExists(atPath: url.path) else { + loaded = true + return [] + } + entries = try fileStore.load() + loaded = true + return entries + } + + /// Returns all cached entries, newest first. + public func all() -> [RecentProjectEntry] { + entries + } + + /// Pushes a project to the front, deduplicating by path and + /// trimming to `capacity`. Writes atomically. + /// + /// Loads the existing list first and propagates any load error so + /// an unparseable file is never overwritten. + @discardableResult + public func add(url fileURL: URL, name: String) throws -> [RecentProjectEntry] { + try load() + let bookmark = try? fileURL.bookmarkData() + var updated = entries.filter { $0.path != fileURL.path } + updated.insert( + RecentProjectEntry( + name: name, + path: fileURL.path, + bookmark: bookmark, + updated: Date()), + at: 0) + if updated.count > capacity { + updated = Array(updated.prefix(capacity)) + } + try fileStore.save(updated) + entries = updated + return updated + } + + /// Removes an entry by path and writes atomically. Returns false + /// when no entry with that path exists. + @discardableResult + public func remove(path: String) throws -> Bool { + try load() + let before = entries.count + let updated = entries.filter { $0.path != path } + guard updated.count != before else { return false } + try fileStore.save(updated) + entries = updated + return true + } + + /// `Clear Menu` — wipes the recents file only; `.icceryproj` files + /// are never deleted (R12). + public func clear() throws { + try load() + try fileStore.save([]) + entries = [] + } + + /// Drops entries whose file is gone and rewrites the store. + /// Called when the Open Recent submenu builds — missing files are + /// dropped there, not at launch (issue #149). + @discardableResult + public func pruneMissing() throws -> [RecentProjectEntry] { + try load() + let kept = entries.filter { + fileManager.fileExists(atPath: $0.path) + } + if kept.count != entries.count { + try fileStore.save(kept) + entries = kept + } + return kept + } +} diff --git a/Sources/ICCery/AppEnvironment.swift b/Sources/ICCery/AppEnvironment.swift index 27420b8..5fdb687 100644 --- a/Sources/ICCery/AppEnvironment.swift +++ b/Sources/ICCery/AppEnvironment.swift @@ -14,6 +14,7 @@ struct AppEnvironment: Sendable { let cupsService: CupsService let historyStore: VerificationHistoryStore let mediaStore: MediaLibraryStore + let recentProjectsStore: RecentProjectsStore static func live( environment: [String: String] = ProcessInfo.processInfo.environment @@ -47,7 +48,8 @@ struct AppEnvironment: Sendable { processManager: .shared, binaryDir: cupsDir), historyStore: VerificationHistoryStore(), - mediaStore: MediaLibraryStore() + mediaStore: MediaLibraryStore(), + recentProjectsStore: RecentProjectsStore() ) } } @@ -89,6 +91,12 @@ enum UITestHooks { static var gamutProfileURL: URL? { url("ICCERY_TEST_GAMUT_PROFILE") } /// TIFF sample picker result (gamut sheet, #147). Unset → cancel. static var gamutTiffURL: URL? { url("ICCERY_TEST_GAMUT_TIFF") } + /// `selectProjectFile` result (`.icceryproj` open, #149). Unset → cancel. + static var projectOpenURL: URL? { url("ICCERY_TEST_PROJECT_OPEN") } + /// `selectProjectSavePath` result (`.icceryproj` save-as, #149). + static var projectSaveURL: URL? { url("ICCERY_TEST_PROJECT_SAVE") } + /// Relocate-folder result when a project's `cwd` is missing (#149). + static var projectRelocateURL: URL? { url("ICCERY_TEST_PROJECT_RELOCATE") } // MARK: - Print panel / CUPS stubs (issue 13/17) diff --git a/Sources/ICCery/FileDialogService.swift b/Sources/ICCery/FileDialogService.swift index f7f8379..7b30144 100644 --- a/Sources/ICCery/FileDialogService.swift +++ b/Sources/ICCery/FileDialogService.swift @@ -95,6 +95,23 @@ final class FileDialogService { message: "Export this preset as JSON") } + /// `selectProjectFile` — open `.icceryproj` only (issue #149). + func selectProjectFile(startingAt start: URL? = nil) -> URL? { + open(extensions: ["icceryproj"], startingAt: start, + message: "Open ICCery Project", + title: "Open ICCery Project", + allowsOtherFileTypes: false) + } + + /// `selectProjectSavePath` — save `.icceryproj`, suggested name + /// `{basename}.icceryproj` in the working folder (issue #149). + func selectProjectSavePath( + basename: String, startingAt start: URL? = nil + ) -> URL? { + save(named: "\(basename).icceryproj", extensions: ["icceryproj"], + startingAt: start, message: "Save ICCery Project") + } + // MARK: - Internals (private — not a shared public picker API) private func save( @@ -116,6 +133,7 @@ final class FileDialogService { extensions: [String], startingAt start: URL?, message: String?, + title: String? = nil, allowsOtherFileTypes: Bool = true ) -> URL? { let panel = NSOpenPanel() @@ -126,6 +144,7 @@ final class FileDialogService { panel.allowsOtherFileTypes = allowsOtherFileTypes panel.directoryURL = start if let message { panel.message = message } + if let title { panel.title = title } return run(panel) } diff --git a/Sources/ICCery/ICCeryApp.swift b/Sources/ICCery/ICCeryApp.swift index d0bec78..8535ad9 100644 --- a/Sources/ICCery/ICCeryApp.swift +++ b/Sources/ICCery/ICCeryApp.swift @@ -25,8 +25,13 @@ struct ICCeryApp: App { .preferredColorScheme(.dark) } .commands { - // Single-window app: no File > New window. - CommandGroup(replacing: .newItem) {} + // Single-window app: the File menu carries the project + // commands (issue #149). Replacing `.newItem` keeps + // SwiftUI's empty default New from stacking (R19 — the + // group is filled, so no second New appears). + CommandGroup(replacing: .newItem) { + ProjectCommands(workflow: workflow) + } } } } diff --git a/Sources/ICCery/ProjectSession.swift b/Sources/ICCery/ProjectSession.swift new file mode 100644 index 0000000..2e7888b --- /dev/null +++ b/Sources/ICCery/ProjectSession.swift @@ -0,0 +1,689 @@ +import AppKit +import Combine +import Foundation +import ICCeryCore + +/// Project file session (issue #149): `.icceryproj` open/save/new/close, +/// recents, the dirty flag, and the window title. +/// +/// The project is an **index** — disk artefacts still own the stepper +/// (R18). Open writes `WizardState` through the normal setters and ends +/// in the same probe `windowDidBecomeKey` uses; it never auto-runs +/// `targen`/`colprof`/`chartread` and never unlocks a stage the disk +/// does not back. +@MainActor +final class ProjectSession: ObservableObject { + + /// What to do once the dirty alert resolves. + enum PendingAction { + case new + case open(URL) + case close + } + + /// A live session snapshot, compared against the bound project for + /// the dirty flag (title `•`, `btnProjectSave`). + private struct Snapshot: Equatable { + var basename: String + var cwd: String + var printerID: String? + var presetID: String? + var mediaRecipeID: String? + var calibrationURL: String? + } + + let workflow: TargetWorkflowViewModel + let environment: AppEnvironment + private let fileDialogs = FileDialogService.shared + private let recentsStore: RecentProjectsStore + private var cancellables = Set() + + /// Bound project file location; `nil` = no project. + @Published var projectURL: URL? + /// Last saved/opened project payload. + @Published var project: ICCeryProject? + /// Open Recent entries, newest first, missing files pruned. + @Published var recents: [RecentProjectEntry] = [] + /// JSON claimed a finished profile but disk stops earlier — + /// `projectChipStale` + info banner (R18). + @Published var diskBehindNotes = false + /// `ICCery` / `ICCery — {name}` / `ICCery — {name} •`. + @Published var windowTitle = "ICCery" + /// Live session fields differ from the bound project — computed + /// fresh so decision paths (`requestNew`/`requestClose`) never see + /// a stale willSet value. + var isDirty: Bool { + guard let project else { return false } + return liveSnapshot() != snapshot(of: project) + } + + // Flow state for RootView. + @Published var showingNewAlert = false + @Published var showingDirtyAlert = false + @Published var showingRelocateSheet = false + + private var pendingAction: PendingAction? + /// Loaded project whose `cwd` is missing — relocate sheet payload. + @Published private(set) var pendingRelocate: + (project: ICCeryProject, url: URL)? + + init(workflow: TargetWorkflowViewModel, environment: AppEnvironment) { + self.workflow = workflow + self.environment = environment + self.recentsStore = environment.recentProjectsStore + refreshRecents() + + // Dirty / title derive from live fields — observe each source; + // child VMs are not tracked through the parent's + // objectWillChange (R22-style weak sinks). `@Published` fires on + // willSet, so the recompute is deferred one main turn to read + // post-set values. + let wizard = workflow.wizard + let printSession = workflow.print + let media = workflow.media + let profile = workflow.profile + wizard.$basename.sink { [weak self] _ in self?.scheduleRecompute() } + .store(in: &cancellables) + wizard.$workingDirectory.sink { [weak self] _ in self?.scheduleRecompute() } + .store(in: &cancellables) + wizard.$printerName.sink { [weak self] _ in self?.scheduleRecompute() } + .store(in: &cancellables) + printSession?.$selectedPrinter.sink { [weak self] _ in self?.scheduleRecompute() } + .store(in: &cancellables) + workflow.$selectedPresetID.sink { [weak self] _ in self?.scheduleRecompute() } + .store(in: &cancellables) + media?.$selectedRecipeID.sink { [weak self] _ in self?.scheduleRecompute() } + .store(in: &cancellables) + profile.$calibrationFile.sink { [weak self] _ in self?.scheduleRecompute() } + .store(in: &cancellables) + recomputeDerived() + } + + deinit { cancellables.removeAll() } + + // MARK: - Derived + + var isBound: Bool { project != nil } + + /// A chartread / spotread / colprof child is live — project changes + /// wait for the instrument run to finish. + var childSessionLive: Bool { + workflow.measurement.isChartreadRunning + || workflow.spotRead.isRunning + || workflow.profile.isColprofRunning + } + + /// Basename eligible for persistence. A live `CAL_` stem resolves + /// only through the persisted original — never by stripping, so a + /// bare `CAL_` (Force-Quit anomaly, empty persisted original) is + /// refused rather than trusted (R11). + var resolvedBasename: String? { + let live = workflow.wizard.basename + guard CalibrationIdentity.isCalibration(live) else { + return live.isEmpty ? nil : live + } + let original = workflow.wizard.calibrationOriginalBasename + return original.isEmpty ? nil : original + } + + /// ⌘S / `btnProjectSave` enable rule. A `CAL_` live basename stays + /// enabled so the refusal can show its banner. + var canSave: Bool { + isBound && !workflow.wizard.basename.isEmpty + && workflow.wizard.workingDirectory != nil + } + + /// `menuProjectSaveAs` — no binding required. + var canSaveAs: Bool { + !workflow.wizard.basename.isEmpty + && workflow.wizard.workingDirectory != nil + } + + /// `menuProjectReport` — needs a real basename and cwd on disk. + var canReport: Bool { + !workflow.wizard.basename.isEmpty + && workflow.wizard.workingDirectory != nil + } + + private func liveSnapshot() -> Snapshot { + Snapshot( + basename: resolvedBasename ?? "", + cwd: workflow.wizard.workingDirectory?.path ?? "", + printerID: workflow.print.selectedPrinter.isEmpty + ? nil : workflow.print.selectedPrinter, + presetID: workflow.selectedPresetID == "none" + ? nil : workflow.selectedPresetID, + mediaRecipeID: workflow.media.selectedRecipeID == "none" + ? nil : workflow.media.selectedRecipeID, + calibrationURL: workflow.profile.calibrationFile.isEmpty + ? nil : workflow.profile.calibrationFile) + } + + private func snapshot(of project: ICCeryProject) -> Snapshot { + Snapshot( + basename: project.basename, + cwd: project.cwd, + printerID: project.printerID, + presetID: project.presetID, + mediaRecipeID: project.mediaRecipeID, + calibrationURL: project.calibrationURL) + } + + /// Recomputes the window title from live fields. + func recomputeDerived() { + if let project { + windowTitle = "ICCery — \(project.name)" + (isDirty ? " •" : "") + } else { + windowTitle = "ICCery" + } + } + + /// Defer the recompute one main turn — the Combine sinks fire on + /// willSet, before the changed property holds its new value. + private func scheduleRecompute() { + Task { @MainActor [weak self] in self?.recomputeDerived() } + } + + // MARK: - New + + /// ⌘N / `menuProjectNew`. Dirty sessions detour through the dirty + /// alert first; a live child gets a banner instead of the alert. + func requestNew() { + guard !childSessionLive else { + workflow.wizard.showNotice( + "Finish the instrument session before starting a project.", + kind: .warning) + return + } + if isDirty { + pendingAction = .new + showingDirtyAlert = true + return + } + showingNewAlert = true + } + + /// `btnProjectNewConfirm` — unbinds and clears the basename. Empty + /// is the legal "no target yet" state (#60); artefacts and + /// `media_library.json` are never deleted (R18). + func confirmNew() { + showingNewAlert = false + projectURL = nil + project = nil + pendingRelocate = nil + diskBehindNotes = false + workflow.wizard.basename = "" + workflow.targetBasename = "" + workflow.media.selectedRecipeID = "none" + workflow.wizard.sessionMode = .profile + // Re-probe — an empty basename locks stages 2–5. + workflow.wizard.windowDidBecomeKey() + recomputeDerived() + } + + // MARK: - Open + + /// ⌘O / `btnProjectOpen` — `selectProjectFile` (`.icceryproj` + /// only). Cancel is a no-op. + func requestOpen() { + guard !childSessionLive else { + workflow.wizard.showNotice( + "Finish the instrument session before opening a project.", + kind: .warning) + return + } + let url = UITestHooks.isEnabled + ? UITestHooks.projectOpenURL + : fileDialogs.selectProjectFile() + guard let url else { return } + if isDirty { + pendingAction = .open(url) + showingDirtyAlert = true + return + } + open(url) + } + + /// Open from the recents submenu — same path, no open panel. A + /// missing file is dropped with a banner; no relocate is offered. + func openRecent(_ entry: RecentProjectEntry) { + guard !childSessionLive else { + workflow.wizard.showNotice( + "Finish the instrument session before opening a project.", + kind: .warning) + return + } + let url = URL(fileURLWithPath: entry.path) + guard FileManager.default.fileExists(atPath: url.path) else { + Task { try? await recentsStore.remove(path: entry.path) } + refreshRecents() + workflow.wizard.showNotice("Project file is gone.", kind: .warning) + return + } + if isDirty { + pendingAction = .open(url) + showingDirtyAlert = true + return + } + open(url) + } + + /// Shared open path — validates, routes to relocate when `cwd` is + /// gone, then applies. Failures leave live state untouched. + func open(_ url: URL) { + guard let loaded = loadProject(url) else { return } + apply(loaded, from: url) + } + + /// Awaitable open for tests — identical to `open` but completes + /// after apply finishes. + func openAsync(_ url: URL) async { + guard let loaded = loadProject(url) else { return } + await applyAsync(loaded, from: url) + } + + /// Decode + validate + cwd-existence check. Returns the project + /// ready to apply, or nil after presenting an error banner / the + /// relocate sheet. + private func loadProject(_ url: URL) -> ICCeryProject? { + let loaded: ICCeryProject + do { + loaded = try ICCeryProject.load(from: url) + } catch let error as ICCeryProject.ValidationError { + workflow.wizard.showNotice( + error.errorDescription ?? "Could not open project.", + kind: .error) + return nil + } catch { + workflow.wizard.showNotice( + "Could not open project: \(error.localizedDescription)", + kind: .error) + return nil + } + var isDir: ObjCBool = false + guard FileManager.default.fileExists( + atPath: loaded.cwd, isDirectory: &isDir), isDir.boolValue + else { + pendingRelocate = (loaded, url) + showingRelocateSheet = true + return nil + } + return loaded + } + + /// Sync entry — the apply half runs in a Task so `media.apply` + /// (async) can run inside it. + private func apply(_ project: ICCeryProject, from url: URL) { + Task { @MainActor [weak self] in + await self?.applyAsync(project, from: url) + } + } + + /// Apply order (issue #149): + /// 1. cwd + basename on the wizard **and** Stage 1 fields. + /// 2. `mediaRecipeID` → `MediaLibraryViewModel.apply`; else + /// `presetID` → existing preset apply. Missing recipe is not + /// fatal — open still succeeds. + /// 3. Re-probe via the existing window-focus path (#151). + /// 4. Never auto-run `targen` / `colprof` / `chartread`. + private func applyAsync(_ project: ICCeryProject, from url: URL) async { + let cwdURL = URL(fileURLWithPath: project.cwd, isDirectory: true) + + workflow.wizard.setTarget( + basename: project.basename, workingDirectory: cwdURL) + workflow.targetBasename = project.basename + workflow.targetDirectory = cwdURL + workflow.wizard.printerName = project.printerDisplayName + ?? project.printerID + workflow.wizard.profileBasename = project.profileBasename + workflow.wizard.sessionMode = .profile + if let queue = project.printerID, + workflow.print.printers.contains(where: { $0.name == queue }) { + workflow.print.selectedPrinter = queue + } + + var appliedRecipe = false + if let recipeID = project.mediaRecipeID { + if workflow.media.recipes.isEmpty { + await workflow.media.reloadAsync() + } + if let recipe = workflow.media.recipes + .first(where: { $0.id == recipeID }) { + appliedRecipe = await workflow.media.apply(recipe) + } + } + if !appliedRecipe, let presetID = project.presetID, + let preset = workflow.presets + .first(where: { $0.id == presetID }) { + workflow.applyPreset(preset) + } + + // Disk owns the stepper — same probe window focus uses. + workflow.wizard.windowDidBecomeKey() + + // JSON-vs-disk mismatch: notes say the profile is done but the + // artefacts stop earlier — index drift, not gating truth. + let artefacts = workflow.wizard.artefacts + if project.lastVerification != nil && !artefacts.stage4Complete { + let already = diskBehindNotes && projectURL == url + diskBehindNotes = true + if !already { + let last = artefacts.stage3Complete ? ".ti3" + : artefacts.stage2Complete ? ".ti2" + : artefacts.stage1Complete ? ".ti1" : nil + let detail = last.map { "artefacts on disk stop at \($0)." } + ?? "no artefacts found on disk." + workflow.wizard.showNotice( + "Project notes say profile done; \(detail)", + kind: .info) + } + } else { + diskBehindNotes = false + } + + self.project = project + projectURL = url + pushRecent(url: url, name: project.name) + recomputeDerived() + } + + // MARK: - Relocate + + /// `btnProjectRelocate` — pick a new cwd, rewrite the project file + /// atomically, then continue Apply. + func chooseRelocateFolder() { + guard let pending = pendingRelocate else { return } + let url = UITestHooks.isEnabled + ? UITestHooks.projectRelocateURL + : fileDialogs.selectDirectory() + guard let url else { return } + var rewritten = pending.project + rewritten.cwd = url.path + rewritten.updated = Date() + do { + try rewritten.save(to: pending.url) + } catch { + workflow.wizard.showNotice( + "Could not update project: \(error.localizedDescription)", + kind: .error) + return + } + pendingRelocate = nil + showingRelocateSheet = false + apply(rewritten, from: pending.url) + } + + /// `btnProjectRelocateCancel` — aborts the open; live session + /// unchanged. + func cancelRelocate() { + pendingRelocate = nil + showingRelocateSheet = false + } + + // MARK: - Save / Save As + + /// ⌘S / `btnProjectSave` — writes the live session into the bound + /// URL. A live `CAL_` stem refuses unless the persisted original + /// resolves; `CAL_` is never persisted (R11). + func saveProject() { + Task { @MainActor in _ = await saveProjectAsync() } + } + + @discardableResult + func saveProjectAsync() async -> Bool { + guard let url = projectURL, project != nil else { return false } + guard let basename = resolvedBasename else { + refuseUnsavable() + return false + } + return await write(to: url, basename: basename) + } + + /// ⇧⌘S / `menuProjectSaveAs` — always shows the save picker, then + /// binds the chosen URL and pushes recents. + func saveProjectAs() { + guard let basename = resolvedBasename else { + refuseUnsavable() + return + } + guard let cwd = workflow.wizard.workingDirectory else { + refuseUnsavable() + return + } + let url = UITestHooks.isEnabled + ? UITestHooks.projectSaveURL + : fileDialogs.selectProjectSavePath( + basename: basename, startingAt: cwd) + guard let url else { return } + Task { @MainActor in _ = await write(to: url, basename: basename) } + } + + private func refuseUnsavable() { + if CalibrationIdentity.isCalibration(workflow.wizard.basename) { + workflow.wizard.showNotice( + "Finish or exit calibration before saving a project.", + kind: .warning) + } else { + workflow.wizard.showNotice( + "Set a target basename and working folder before saving a project.", + kind: .warning) + } + } + + /// Builds the project payload from live session fields and writes + /// it atomically. On success binds `url`, refreshes recents, and + /// clears the stale-chip flag. Failure → error banner, bound URL + /// unchanged. + @discardableResult + private func write(to url: URL, basename: String) async -> Bool { + guard let cwd = workflow.wizard.workingDirectory, + !cwd.path.isEmpty else { + refuseUnsavable() + return false + } + let stem = workflow.wizard.profileBasename ?? basename + let snapshot = await lastVerificationSnapshot(stem: stem) + ?? project?.lastVerification + let queue = workflow.print.selectedPrinter + let display = workflow.print.printers + .first { $0.name == queue }?.displayName + ?? workflow.wizard.printerName + let existing = project + let name = (existing?.name.isEmpty == false) + ? existing!.name : basename + let updated = ICCeryProject( + name: name, + notes: existing?.notes ?? "", + basename: basename, + cwd: cwd.path, + profileBasename: workflow.wizard.profileBasename, + printerID: queue.isEmpty ? nil : queue, + printerDisplayName: display, + mediaRecipeID: workflow.media.selectedRecipeID == "none" + ? nil : workflow.media.selectedRecipeID, + presetID: workflow.selectedPresetID == "none" + ? nil : workflow.selectedPresetID, + calibrationURL: workflow.profile.calibrationFile.isEmpty + ? nil : workflow.profile.calibrationFile, + lastVerification: snapshot, + updated: Date()) + do { + try updated.save(to: url) + } catch { + workflow.wizard.showNotice( + "Could not save project: \(error.localizedDescription)", + kind: .error) + return false + } + project = updated + projectURL = url + diskBehindNotes = false + pushRecent(url: url, name: updated.name) + workflow.wizard.showNotice("Project saved: \(url.lastPathComponent)") + recomputeDerived() + return true + } + + /// Last `VerificationHistoryStore` record for this profile stem, + /// if any — notes only. + private func lastVerificationSnapshot( + stem: String + ) async -> VerificationSnapshot? { + guard let records = try? await environment.historyStore.load() + else { return nil } + guard let record = records.last(where: { + $0.profileName == stem || $0.profileName == workflow.wizard.basename + }) else { return nil } + return VerificationSnapshot(record: record) + } + + // MARK: - Close + + /// `menuProjectClose` — unbinds; live basename/cwd/artefacts stay. + func requestClose() { + guard isBound else { return } + if isDirty { + pendingAction = .close + showingDirtyAlert = true + return + } + closeProject() + } + + func closeProject() { + projectURL = nil + project = nil + diskBehindNotes = false + recomputeDerived() + } + + // MARK: - Dirty alert + + /// `btnProjectDirtySave` / `btnProjectDirtyDiscard`. Save proceeds + /// to the pending action only when the write succeeded. + func resolveDirty(save: Bool) { + showingDirtyAlert = false + guard let action = pendingAction else { return } + if save { + Task { @MainActor [weak self] in + guard let self else { return } + if await self.saveProjectAsync() { + self.pendingAction = nil + self.proceed(with: action) + } + } + } else { + pendingAction = nil + proceed(with: action) + } + } + + /// `btnProjectDirtyCancel` — abandons the pending action. + func cancelDirty() { + pendingAction = nil + showingDirtyAlert = false + } + + private func proceed(with action: PendingAction) { + switch action { + case .new: + confirmNew() + case .open(let url): + open(url) + case .close: + closeProject() + } + } + + // MARK: - Report + + /// `menuProjectReport` — writes `{cwd}/{basename}-report.md` + /// atomically (generated; overwrites). No picker. + func saveReport() { + guard canReport, let cwd = workflow.wizard.workingDirectory else { + return + } + let basename = workflow.wizard.basename + Task { @MainActor [weak self] in + guard let self else { return } + let stem = self.workflow.wizard.profileBasename ?? basename + let snapshot = await self.lastVerificationSnapshot(stem: stem) + ?? self.project?.lastVerification + let queue = self.workflow.print.selectedPrinter + let display = self.workflow.print.printers + .first { $0.name == queue }?.displayName + ?? self.workflow.wizard.printerName + let recipeName = self.workflow.media.recipes + .first { $0.id == self.workflow.media.selectedRecipeID }?.name + let payload = ICCeryProject( + name: self.project?.name.isEmpty == false + ? self.project!.name : basename, + notes: self.project?.notes ?? "", + basename: basename, + cwd: cwd.path, + profileBasename: self.workflow.wizard.profileBasename, + printerID: queue.isEmpty ? nil : queue, + printerDisplayName: display, + mediaRecipeID: self.workflow.media.selectedRecipeID == "none" + ? nil : self.workflow.media.selectedRecipeID, + presetID: self.workflow.selectedPresetID == "none" + ? nil : self.workflow.selectedPresetID, + calibrationURL: self.workflow.profile.calibrationFile.isEmpty + ? nil : self.workflow.profile.calibrationFile, + lastVerification: snapshot, + updated: Date()) + do { + let written = try ProjectReport.write( + project: payload, + recipeName: recipeName, + artefacts: self.workflow.wizard.artefacts) + self.workflow.wizard.showNotice( + "Wrote \(written.lastPathComponent)") + } catch { + self.workflow.wizard.showNotice( + "Report failed: \(error.localizedDescription)", + kind: .error) + } + } + } + + // MARK: - Recents / reveal + + /// Rebuilds the recents list; entries whose file is gone are + /// dropped here (submenu build), not at launch. Corrupt → `[]`, + /// file kept (R12). + func refreshRecents() { + Task { @MainActor [weak self] in + guard let self else { return } + do { + self.recents = try await self.recentsStore.pruneMissing() + } catch { + self.recents = [] + } + } + } + + private func pushRecent(url: URL, name: String) { + Task { @MainActor [weak self] in + guard let self else { return } + try? await self.recentsStore.add(url: url, name: name) + self.refreshRecents() + } + } + + /// `menuProjectRecentsClear` — wipes `recent_projects.json` only; + /// `.icceryproj` files are never deleted. + func clearRecents() { + Task { @MainActor [weak self] in + try? await self?.recentsStore.clear() + self?.recents = [] + } + } + + /// `btnProjectReveal` — reveals the bound **project file** in + /// Finder, not the cwd. + func revealInFinder() { + guard let projectURL else { return } + NSWorkspace.shared.activateFileViewerSelecting([projectURL]) + } +} diff --git a/Sources/ICCery/ProjectUI.swift b/Sources/ICCery/ProjectUI.swift new file mode 100644 index 0000000..3dfe2fd --- /dev/null +++ b/Sources/ICCery/ProjectUI.swift @@ -0,0 +1,151 @@ +import SwiftUI +import ICCeryCore + +/// File-menu commands for the project file (issue #149). Content of the +/// `CommandGroup(replacing: .newItem)` in `ICCeryApp` — split out so the +/// app body stays small (type-checker budget). Menu ids are distinct +/// from the sidebar chip ids (`menuProject*` vs `btnProject*`). +struct ProjectCommands: View { + @ObservedObject private var project: ProjectSession + + init(workflow: TargetWorkflowViewModel) { + self._project = ObservedObject(wrappedValue: workflow.project) + } + + var body: some View { + Button("New Project") { project.requestNew() } + .keyboardShortcut("n") + .accessibilityIdentifier("menuProjectNew") + Button("Open Project…") { project.requestOpen() } + .keyboardShortcut("o") + .accessibilityIdentifier("menuProjectOpen") + Menu("Open Recent") { + ForEach(project.recents) { entry in + // Names render via Text only (#114); never the raw path. + Button(entry.name) { project.openRecent(entry) } + .accessibilityIdentifier("projectRecent-\(entry.bookmarkHash)") + } + Divider() + Button("Clear Menu") { project.clearRecents() } + .accessibilityIdentifier("menuProjectRecentsClear") + } + .disabled(project.recents.isEmpty) + .accessibilityIdentifier("menuProjectRecents") + Divider() + Button("Save Project") { project.saveProject() } + .keyboardShortcut("s") + .disabled(!project.canSave) + .accessibilityIdentifier("menuProjectSave") + Button("Save Project As…") { project.saveProjectAs() } + .keyboardShortcut("s", modifiers: [.command, .shift]) + .disabled(!project.canSaveAs) + .accessibilityIdentifier("menuProjectSaveAs") + Button("Save Report…") { project.saveReport() } + .disabled(!project.canReport) + .accessibilityIdentifier("menuProjectReport") + Divider() + Button("Close Project") { project.requestClose() } + .disabled(!project.isBound) + .accessibilityIdentifier("menuProjectClose") + } +} + +/// Compact project footer at the bottom of the 270 pt sidebar (issue +/// #149) — never a fourth row of large buttons (R10). +struct ProjectChip: View { + @ObservedObject var project: ProjectSession + @Binding var showingAllHelp: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + if let bound = project.project { + Text(bound.name) + .font(.callout) + .foregroundStyle(Theme.text) + .lineLimit(1) + .accessibilityIdentifier("projectChipName") + Text(URL(fileURLWithPath: bound.cwd).lastPathComponent) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + // The raw path is a tooltip — never the window title. + .help(bound.cwd) + .accessibilityIdentifier("projectChipPath") + if project.diskBehindNotes { + Text("Disk behind project notes") + .font(.caption) + .foregroundStyle(.orange) + .accessibilityIdentifier("projectChipStale") + } + HStack(spacing: 8) { + Button("Show in Finder") { project.revealInFinder() } + .accessibilityIdentifier("btnProjectReveal") + Button("Save") { project.saveProject() } + .disabled(!project.canSave) + .accessibilityIdentifier("btnProjectSave") + Spacer() + } + .font(.caption) + .controlSize(.small) + } else { + HStack(spacing: 8) { + Text("No project") + .font(.callout) + .foregroundStyle(.secondary) + .accessibilityIdentifier("projectChipName") + Spacer() + Button("Open…") { project.requestOpen() } + .controlSize(.small) + .accessibilityIdentifier("btnProjectOpen") + } + } + } + .padding(8) + .background( + RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium) + .fill(Theme.panel) + ) + .overlay( + RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium) + .stroke(Theme.border) + ) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("projectChip") + .helpOverlay( + "A project remembers printer, preset and folder. The stepper still follows files on disk.", + showing: $showingAllHelp) + } +} + +/// `projectRelocateSheet` — shown when an opened project's `cwd` no +/// longer exists (issue #149). Choosing a folder rewrites `cwd` in the +/// project file atomically, then continues Apply; Cancel aborts the +/// open with live state untouched. +struct ProjectRelocateSheet: View { + @ObservedObject var project: ProjectSession + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Missing project folder") + .font(.title3) + .foregroundStyle(Theme.text) + Text( + "The folder for \(project.pendingRelocate?.project.name ?? "this project") is missing. Choose a new working folder." + ) + .foregroundStyle(Theme.text) + HStack { + Spacer() + Button("Cancel") { project.cancelRelocate() } + .accessibilityIdentifier("btnProjectRelocateCancel") + Button("Choose Folder…") { project.chooseRelocateFolder() } + .accessibilityIdentifier("btnProjectRelocate") + } + } + .padding(20) + .frame(width: 420) + .background(Theme.background) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("projectRelocateSheet") + } +} diff --git a/Sources/ICCery/RootView.swift b/Sources/ICCery/RootView.swift index 7d8c849..6496880 100644 --- a/Sources/ICCery/RootView.swift +++ b/Sources/ICCery/RootView.swift @@ -9,6 +9,7 @@ struct RootView: View { /// Observed directly: nested ObservableObjects are not tracked /// through the parent's `objectWillChange`. @ObservedObject private var model: WizardViewModel + @ObservedObject private var project: ProjectSession @State private var showingSettings = false @State private var showingAbout = false @State private var showingAllHelp = false @@ -16,6 +17,7 @@ struct RootView: View { init(workflow: TargetWorkflowViewModel) { self.workflow = workflow self._model = ObservedObject(wrappedValue: workflow.wizard) + self._project = ObservedObject(wrappedValue: workflow.project) } var body: some View { @@ -87,6 +89,37 @@ struct RootView: View { profileGamURL: workflow.wizard.gamutProfileURL, showingAllHelp: $showingAllHelp) } + // Project file chrome (issue #149): window title, New confirm + // alert, dirty alert, relocate sheet. Panels never appear from + // a View — UITestHooks inject fixture paths instead. + .onReceive(project.$windowTitle) { title in + for window in NSApp.windows where !(window is NSPanel) { + window.title = title + } + } + .alert("Start a new project?", isPresented: $project.showingNewAlert) { + Button("Cancel", role: .cancel) {} + .accessibilityIdentifier("btnProjectNewCancel") + Button("Start") { project.confirmNew() } + .accessibilityIdentifier("btnProjectNewConfirm") + } message: { + Text("The working folder and targets on disk are not deleted.") + .accessibilityIdentifier("projectNewAlert") + } + .alert( + "Save the current project first?", + isPresented: $project.showingDirtyAlert + ) { + Button("Save") { project.resolveDirty(save: true) } + .accessibilityIdentifier("btnProjectDirtySave") + Button("Don't Save") { project.resolveDirty(save: false) } + .accessibilityIdentifier("btnProjectDirtyDiscard") + Button("Cancel", role: .cancel) { project.cancelDirty() } + .accessibilityIdentifier("btnProjectDirtyCancel") + } + .sheet(isPresented: $project.showingRelocateSheet) { + ProjectRelocateSheet(project: project) + } } } diff --git a/Sources/ICCery/SidebarView.swift b/Sources/ICCery/SidebarView.swift index 4b97cda..4a939ff 100644 --- a/Sources/ICCery/SidebarView.swift +++ b/Sources/ICCery/SidebarView.swift @@ -12,6 +12,7 @@ struct SidebarView: View { @ObservedObject private var media: MediaLibraryViewModel @ObservedObject private var printSession: PrintSessionViewModel @ObservedObject private var measurement: MeasurementWorkflowViewModel + @ObservedObject private var project: ProjectSession var onOpenSettings: () -> Void var onOpenAbout: () -> Void @Binding var showingAllHelp: Bool @@ -28,6 +29,7 @@ struct SidebarView: View { self._media = ObservedObject(wrappedValue: workflow.media) self._printSession = ObservedObject(wrappedValue: workflow.print) self._measurement = ObservedObject(wrappedValue: workflow.measurement) + self._project = ObservedObject(wrappedValue: workflow.project) self.onOpenSettings = onOpenSettings self.onOpenAbout = onOpenAbout self._showingAllHelp = showingAllHelp @@ -205,6 +207,12 @@ struct SidebarView: View { .padding(.horizontal, 6) Spacer() + + // Project chip (issue #149) — a compact footer in the + // spacer's bottom, under the stepper. The 270 pt column + // cannot take four more large buttons (R10). + ProjectChip(project: project, showingAllHelp: $showingAllHelp) + .padding(8) } .frame(width: Theme.Metrics.sidebarWidth) .background(Theme.panel) diff --git a/Sources/ICCery/TargetWorkflowViewModel.swift b/Sources/ICCery/TargetWorkflowViewModel.swift index 76b20e2..2e9af7f 100644 --- a/Sources/ICCery/TargetWorkflowViewModel.swift +++ b/Sources/ICCery/TargetWorkflowViewModel.swift @@ -119,8 +119,11 @@ final class TargetWorkflowViewModel: ObservableObject { @Published var print: PrintSessionViewModel! /// Media recipe library — needs a complete `self`. @Published var media: MediaLibraryViewModel! - /// Spot-read console, created last — needs `wizard` / `measurement`. + /// Spot-read console — needs `wizard` / `measurement`. @Published var spotRead: SpotReadViewModel! + /// Project file session (issue #149), created last — needs `media` + /// for recipe apply and `spotRead` for the live-child check. + @Published var project: ProjectSession! init(environment: AppEnvironment = .live()) { self.environment = environment @@ -148,6 +151,10 @@ final class TargetWorkflowViewModel: ObservableObject { workflow: self, environment: environment ) + self.project = ProjectSession( + workflow: self, + environment: environment + ) reloadPresets() } diff --git a/Tests/ICCeryCoreTests/ICCeryProjectTests.swift b/Tests/ICCeryCoreTests/ICCeryProjectTests.swift new file mode 100644 index 0000000..e4122f0 --- /dev/null +++ b/Tests/ICCeryCoreTests/ICCeryProjectTests.swift @@ -0,0 +1,203 @@ +import Foundation +import XCTest +@testable import ICCeryCore + +/// Issue #149 — `.icceryproj` schema: snake_case keys, strict +/// `schema_version == 1`, and save-time refusal for empty/illegal +/// basename and empty/unsafe cwd (#59/#60/R11). +final class ICCeryProjectTests: XCTestCase { + + private func tempDir() throws -> URL { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-proj-\(UUID().uuidString)") + try FileManager.default.createDirectory( + at: dir, withIntermediateDirectories: true) + return dir + } + + private func makeProject( + basename: String = "run-1", + cwd: String = "/tmp" + ) -> ICCeryProject { + ICCeryProject( + name: "Run One", + basename: basename, + cwd: cwd, + profileBasename: "run-1", + printerID: "Mock_Queue", + printerDisplayName: "Mock Queue", + mediaRecipeID: "recipe-1", + presetID: "preset-std-rgb", + calibrationURL: "/tmp/r.cal", + lastVerification: VerificationSnapshot( + date: Date(timeIntervalSince1970: 1_700_000_000), + avgDE00: 0.8, maxDE00: 2.1, + status: "excellent", profileFilename: "run-1.icc")) + } + + // MARK: - Round trip / keys + + func testSnakeCaseRoundTrip() throws { + let dir = try tempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let url = dir.appendingPathComponent("p.icceryproj") + + let project = makeProject() + try project.save(to: url) + let loaded = try ICCeryProject.load(from: url) + + XCTAssertEqual(loaded.schemaVersion, 1) + XCTAssertEqual(loaded.basename, "run-1") + XCTAssertEqual(loaded.cwd, "/tmp") + XCTAssertEqual(loaded.profileBasename, "run-1") + XCTAssertEqual(loaded.printerID, "Mock_Queue") + XCTAssertEqual(loaded.mediaRecipeID, "recipe-1") + XCTAssertEqual(loaded.presetID, "preset-std-rgb") + XCTAssertEqual(loaded.calibrationURL, "/tmp/r.cal") + XCTAssertEqual(loaded.lastVerification?.avgDE00, 0.8) + XCTAssertEqual(loaded.lastVerification?.maxDE00, 2.1) + XCTAssertEqual(loaded.lastVerification?.status, "excellent") + XCTAssertEqual(loaded.lastVerification?.profileFilename, "run-1.icc") + + let raw = try String(contentsOf: url, encoding: .utf8) + for key in [ + "\"schema_version\"", "\"profile_basename\"", + "\"printer_id\"", "\"printer_display_name\"", + "\"media_recipe_id\"", "\"preset_id\"", + "\"calibration_url\"", "\"last_verification\"", + "\"avg_de00\"", "\"max_de00\"", "\"profile_filename\"", + ] { + XCTAssertTrue(raw.contains(key), "missing key \(key)") + } + } + + func testOptionalFieldsDecodeWhenAbsent() throws { + let dir = try tempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let url = dir.appendingPathComponent("min.icceryproj") + let json = """ + { + "schema_version": 1, + "name": "Minimal", + "basename": "abc", + "cwd": "/tmp", + "updated": "2026-09-13T00:00:00Z" + } + """ + try json.write(to: url, atomically: true, encoding: .utf8) + + let loaded = try ICCeryProject.load(from: url) + XCTAssertEqual(loaded.basename, "abc") + XCTAssertNil(loaded.mediaRecipeID) + XCTAssertNil(loaded.presetID) + XCTAssertNil(loaded.lastVerification) + XCTAssertNil(loaded.calibrationURL) + } + + // MARK: - Schema gate + + func testSchemaVersion2Throws() throws { + let dir = try tempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let url = dir.appendingPathComponent("v2.icceryproj") + try """ + {"schema_version": 2, "name": "x", "basename": "abc", + "cwd": "/tmp", "future_field": [1, 2]} + """.write(to: url, atomically: true, encoding: .utf8) + + XCTAssertThrowsError(try ICCeryProject.load(from: url)) { error in + guard case ICCeryProject.ValidationError.unsupportedSchema(2) + = error else { + return XCTFail("expected unsupportedSchema, got \(error)") + } + XCTAssertEqual( + error.localizedDescription, + "This project file is not schema 1.") + } + } + + func testMissingSchemaVersionThrows() throws { + let dir = try tempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let url = dir.appendingPathComponent("noversion.icceryproj") + try "{\"basename\": \"abc\", \"cwd\": \"/tmp\"}" + .write(to: url, atomically: true, encoding: .utf8) + XCTAssertThrowsError(try ICCeryProject.load(from: url)) + } + + // MARK: - Validation + + func testEmptyBasenameRefusesSave() throws { + let dir = try tempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let url = dir.appendingPathComponent("p.icceryproj") + XCTAssertThrowsError( + try makeProject(basename: "").save(to: url) + ) { error in + XCTAssertEqual( + error as? ICCeryProject.ValidationError, .emptyBasename) + } + XCTAssertFalse(FileManager.default.fileExists(atPath: url.path)) + } + + func testIllegalBasenameRefusesSave() throws { + let dir = try tempDir() + defer { try? FileManager.default.removeItem(at: dir) } + for bad in ["a/b", "a\\b", "..", "x..y"] { + XCTAssertThrowsError( + try makeProject(basename: bad).save( + to: dir.appendingPathComponent("p.icceryproj")) + ) { error in + guard case ICCeryProject.ValidationError.invalidBasename + = error else { + return XCTFail("expected invalidBasename, got \(error)") + } + } + } + } + + func testEmptyAndRelativeCwdRefuseSave() throws { + let dir = try tempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let url = dir.appendingPathComponent("p.icceryproj") + XCTAssertThrowsError(try makeProject(cwd: "").save(to: url)) { error in + XCTAssertEqual( + error as? ICCeryProject.ValidationError, .emptyCwd) + } + XCTAssertThrowsError( + try makeProject(cwd: "relative/dir").save(to: url) + ) { error in + guard case ICCeryProject.ValidationError.unsafeCwd = error else { + return XCTFail("expected unsafeCwd, got \(error)") + } + } + XCTAssertFalse(FileManager.default.fileExists(atPath: url.path)) + } + + func testIllegalBasenameRefusesOpen() throws { + let dir = try tempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let url = dir.appendingPathComponent("bad.icceryproj") + try """ + {"schema_version": 1, "basename": "a/b", "cwd": "/tmp"} + """.write(to: url, atomically: true, encoding: .utf8) + XCTAssertThrowsError(try ICCeryProject.load(from: url)) { error in + guard case ICCeryProject.ValidationError.invalidBasename + = error else { + return XCTFail("expected invalidBasename, got \(error)") + } + } + } + + func testEmptyNameFallsBackToBasename() throws { + let project = try ICCeryProject( + name: "", basename: "stem", cwd: "/tmp").validated() + XCTAssertEqual(project.name, "stem") + } + + func testMissingFileThrowsOnOpen() { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("gone-\(UUID().uuidString).icceryproj") + XCTAssertThrowsError(try ICCeryProject.load(from: url)) + } +} diff --git a/Tests/ICCeryCoreTests/ProjectSessionTests.swift b/Tests/ICCeryCoreTests/ProjectSessionTests.swift new file mode 100644 index 0000000..6169b0f --- /dev/null +++ b/Tests/ICCeryCoreTests/ProjectSessionTests.swift @@ -0,0 +1,406 @@ +import Foundation +import XCTest +@testable import ICCeryCore +@testable import ICCery + +/// Issue #149 — `ProjectSession` open/save/new/close against an +/// isolated `TestAppEnvironment`. Disk artefacts always win over the +/// project JSON (R18); a `CAL_` basename is never persisted (R11). +@MainActor +final class ProjectSessionTests: XCTestCase { + + private var env: TestAppEnvironment! + private var workflow: TargetWorkflowViewModel! + private var project: ProjectSession! + private var cwd: URL! + + override func setUp() async throws { + env = try TestAppEnvironment.make() + workflow = TargetWorkflowViewModel(environment: env.environment) + project = workflow.project + cwd = env.root.appendingPathComponent("proj-cwd") + try FileManager.default.createDirectory( + at: cwd, withIntermediateDirectories: true) + // Recents are pushed asynchronously; drain before asserting. + await flush() + } + + override func tearDown() async throws { + env?.cleanup() + env = nil + workflow = nil + project = nil + cwd = nil + } + + /// Lets pending `Task`s in the view models run. + private func flush() async { + try? await Task.sleep(nanoseconds: 100_000_000) + } + + private func artefact(_ ext: String, stem: String = "job") throws { + try "x".write( + to: cwd.appendingPathComponent("\(stem).\(ext)"), + atomically: true, encoding: .utf8) + } + + private func writeProjectFile( + _ name: String = "job.icceryproj", + basename: String = "job", + cwdOverride: String? = nil, + lastVerification: Bool = false, + mediaRecipeID: String? = nil, + presetID: String? = nil + ) throws -> URL { + let snapshot: VerificationSnapshot? = lastVerification + ? VerificationSnapshot( + date: Date(), avgDE00: 0.7, maxDE00: 1.9, + status: "excellent", profileFilename: "\(basename).icc") + : nil + let p = ICCeryProject( + name: "Fixture Project", + basename: basename, + cwd: cwdOverride ?? cwd.path, + mediaRecipeID: mediaRecipeID, + presetID: presetID, + lastVerification: snapshot) + let url = env.root.appendingPathComponent(name) + try p.save(to: url) + return url + } + + // MARK: - Open + + func testOpenAppliesBasenameCwdAndBinds() async throws { + try artefact("ti1") + try artefact("ti2") + try artefact("ti3") + let url = try writeProjectFile() + + await project.openAsync(url) + + XCTAssertEqual(workflow.wizard.basename, "job") + XCTAssertEqual(workflow.wizard.workingDirectory?.path, cwd.path) + XCTAssertEqual(workflow.targetBasename, "job") + XCTAssertEqual(workflow.targetDirectory?.path, cwd.path) + XCTAssertEqual(project.projectURL, url) + XCTAssertEqual(project.project?.name, "Fixture Project") + XCTAssertEqual(project.windowTitle, "ICCery — Fixture Project") + XCTAssertFalse(project.isDirty) + XCTAssertTrue(workflow.wizard.isUnlocked(.buildProfile)) + XCTAssertFalse(workflow.wizard.isUnlocked(.verifyInstall)) + } + + func testOpenDiskWinsOverJsonStage() async throws { + // JSON claims a finished profile; disk only has .ti2 (R18). + try artefact("ti2") + let url = try writeProjectFile(lastVerification: true) + + await project.openAsync(url) + + XCTAssertFalse(workflow.wizard.isUnlocked(.buildProfile)) + XCTAssertFalse(workflow.wizard.isUnlocked(.verifyInstall)) + XCTAssertTrue(project.diskBehindNotes) + let notice = workflow.wizard.notice + XCTAssertEqual(notice?.kind, .info) + XCTAssertTrue( + notice?.text.contains("artefacts on disk stop at .ti2") == true, + "got: \(notice?.text ?? "nil")") + } + + func testOpenWithTi3ButNoIccLocksStage5Only() async throws { + try artefact("ti3") + let url = try writeProjectFile(lastVerification: true) + + await project.openAsync(url) + + XCTAssertTrue(workflow.wizard.isUnlocked(.buildProfile)) + XCTAssertFalse(workflow.wizard.isUnlocked(.verifyInstall)) + XCTAssertTrue(project.diskBehindNotes) + } + + func testOpenSchema2LeavesLiveStateUntouched() async throws { + workflow.wizard.setTarget(basename: "live", workingDirectory: cwd) + let url = env.root.appendingPathComponent("v2.icceryproj") + try """ + {"schema_version": 2, "basename": "other", "cwd": "\(cwd.path)"} + """.write(to: url, atomically: true, encoding: .utf8) + + await project.openAsync(url) + + XCTAssertNil(project.projectURL) + XCTAssertEqual(workflow.wizard.basename, "live") + XCTAssertEqual(workflow.wizard.notice?.kind, .error) + XCTAssertTrue( + workflow.wizard.notice?.text.contains("not schema 1") == true) + } + + func testOpenMissingCwdPresentsRelocateSheet() async throws { + let gone = env.root.appendingPathComponent("no-such-dir").path + let url = try writeProjectFile(cwdOverride: gone) + workflow.wizard.setTarget(basename: "live", workingDirectory: cwd) + + await project.openAsync(url) + + XCTAssertTrue(project.showingRelocateSheet) + XCTAssertNotNil(project.pendingRelocate) + XCTAssertNil(project.projectURL) + XCTAssertEqual(workflow.wizard.basename, "live") + } + + func testRelocateCancelAbortsOpen() async throws { + let gone = env.root.appendingPathComponent("no-such-dir").path + let url = try writeProjectFile(cwdOverride: gone) + + await project.openAsync(url) + project.cancelRelocate() + + XCTAssertFalse(project.showingRelocateSheet) + XCTAssertNil(project.projectURL) + } + + func testOpenUnknownMediaRecipeStillOpens() async throws { + let url = try writeProjectFile(mediaRecipeID: "recipe-absent") + + await project.openAsync(url) + await flush() + + XCTAssertEqual(project.projectURL, url) + XCTAssertEqual(workflow.wizard.basename, "job") + // The unknown recipe was ignored, not fatal. + XCTAssertEqual(workflow.media.selectedRecipeID, "none") + } + + func testOpenAppliesPresetWhenNoRecipe() async throws { + let url = try writeProjectFile(presetID: "preset-std-rgb") + + await project.openAsync(url) + await flush() + + XCTAssertEqual(workflow.selectedPresetID, "preset-std-rgb") + } + + // MARK: - Save + + func testSaveRefusesEmptyBasename() async throws { + let url = try writeProjectFile() + await project.openAsync(url) + await flush() + workflow.wizard.basename = "" + + XCTAssertFalse(project.canSave) + let saved = await project.saveProjectAsync() + XCTAssertFalse(saved) + // The file keeps its original basename. + let reloaded = try ICCeryProject.load(from: url) + XCTAssertEqual(reloaded.basename, "job") + } + + func testSaveRefusesEmptyCwd() async throws { + let url = try writeProjectFile() + await project.openAsync(url) + await flush() + workflow.wizard.workingDirectory = nil + + XCTAssertFalse(project.canSave) + let saved = await project.saveProjectAsync() + XCTAssertFalse(saved) + } + + func testCalBasenameRefusedWithoutPersistedOriginal() async throws { + let url = try writeProjectFile() + await project.openAsync(url) + await flush() + // Bare CAL_ stem — no persisted original to trust (R11). + workflow.wizard.basename = "CAL_job" + workflow.wizard.calibrationOriginalBasename = "" + + XCTAssertTrue(project.canSave) // enabled so the banner shows + let saved = await project.saveProjectAsync() + + XCTAssertFalse(saved) + XCTAssertTrue( + workflow.wizard.notice?.text.contains( + "Finish or exit calibration") == true) + let raw = try String(contentsOf: url, encoding: .utf8) + XCTAssertFalse(raw.contains("CAL_")) + XCTAssertEqual(try ICCeryProject.load(from: url).basename, "job") + } + + func testCalBasenameSavesPersistedOriginal() async throws { + let url = try writeProjectFile() + await project.openAsync(url) + await flush() + workflow.wizard.basename = "CAL_job" + workflow.wizard.calibrationOriginalBasename = "job" + + let saved = await project.saveProjectAsync() + + XCTAssertTrue(saved) + let reloaded = try ICCeryProject.load(from: url) + XCTAssertEqual(reloaded.basename, "job") + XCTAssertFalse(rawContainsCal(url)) + } + + private func rawContainsCal(_ url: URL) -> Bool { + guard let raw = try? String(contentsOf: url, encoding: .utf8) else { + return false + } + return raw.contains("CAL_") + } + + func testSaveCapturesLiveFields() async throws { + let url = try writeProjectFile() + await project.openAsync(url) + await flush() + workflow.print.printers = [ + Printer(name: "Mock_Queue", displayName: "Mock Queue Display") + ] + workflow.print.selectedPrinter = "Mock_Queue" + workflow.selectedPresetID = "preset-std-rgb" + workflow.profile.calibrationFile = "/tmp/live.cal" + + XCTAssertTrue(project.isDirty) + await flush() // title recompute is deferred one main turn + XCTAssertTrue(project.windowTitle.hasSuffix("•")) + + let saved = await project.saveProjectAsync() + XCTAssertTrue(saved) + let reloaded = try ICCeryProject.load(from: url) + XCTAssertEqual(reloaded.printerID, "Mock_Queue") + XCTAssertEqual( + reloaded.printerDisplayName, "Mock Queue Display") + XCTAssertEqual(reloaded.presetID, "preset-std-rgb") + XCTAssertEqual(reloaded.calibrationURL, "/tmp/live.cal") + XCTAssertFalse(project.isDirty) + } + + // MARK: - New / Close + + func testNewClearsBasenameKeepsArtefacts() async throws { + try artefact("ti3") + let url = try writeProjectFile() + await project.openAsync(url) + await flush() + XCTAssertTrue(workflow.wizard.isUnlocked(.buildProfile)) + + project.requestNew() + XCTAssertTrue(project.showingNewAlert) + project.confirmNew() + + XCTAssertEqual(workflow.wizard.basename, "") + XCTAssertEqual(workflow.targetBasename, "") + XCTAssertNil(project.projectURL) + XCTAssertEqual(project.windowTitle, "ICCery") + XCTAssertEqual(workflow.media.selectedRecipeID, "none") + XCTAssertEqual(workflow.wizard.sessionMode, .profile) + // The artefact is untouched; the stepper just can't see it. + XCTAssertTrue( + FileManager.default.fileExists( + atPath: cwd.appendingPathComponent("job.ti3").path)) + XCTAssertFalse(workflow.wizard.isUnlocked(.buildProfile)) + } + + func testNewWhileChildLiveBannersInstead() async throws { + workflow.measurement.isChartreadRunning = true + project.requestNew() + XCTAssertFalse(project.showingNewAlert) + XCTAssertNotNil(workflow.wizard.notice) + } + + func testDirtyNewShowsDirtyAlert() async throws { + let url = try writeProjectFile() + await project.openAsync(url) + await flush() + workflow.wizard.basename = "changed" + + project.requestNew() + XCTAssertFalse(project.showingNewAlert) + XCTAssertTrue(project.showingDirtyAlert) + + // Don't Save → New proceeds. + project.resolveDirty(save: false) + XCTAssertEqual(workflow.wizard.basename, "") + XCTAssertNil(project.projectURL) + } + + func testCloseKeepsLiveSession() async throws { + try artefact("ti1") + let url = try writeProjectFile() + await project.openAsync(url) + await flush() + + project.requestClose() + + XCTAssertNil(project.projectURL) + XCTAssertEqual(workflow.wizard.basename, "job") + XCTAssertEqual(workflow.wizard.workingDirectory?.path, cwd.path) + XCTAssertEqual(project.windowTitle, "ICCery") + } + + func testDirtyCloseSaveThenCloses() async throws { + let url = try writeProjectFile() + await project.openAsync(url) + await flush() + workflow.wizard.basename = "renamed" + + project.requestClose() + XCTAssertTrue(project.showingDirtyAlert) + + project.resolveDirty(save: true) + try await Task.sleep(nanoseconds: 300_000_000) + + XCTAssertNil(project.projectURL) + XCTAssertEqual(try ICCeryProject.load(from: url).basename, + "renamed") + } + + // MARK: - Report / recents + + func testSaveReportWritesMarkdown() async throws { + try artefact("ti1") + try artefact("ti3") + let url = try writeProjectFile(lastVerification: true) + await project.openAsync(url) + await flush() + + project.saveReport() + try await Task.sleep(nanoseconds: 300_000_000) + + let report = cwd.appendingPathComponent("job-report.md") + XCTAssertTrue(FileManager.default.fileExists(atPath: report.path)) + let text = try String(contentsOf: report, encoding: .utf8) + XCTAssertTrue(text.contains("job.ti1 | exists")) + XCTAssertTrue(text.contains("job.ti2 | missing")) + XCTAssertTrue(text.contains("job.ti3 | exists")) + XCTAssertTrue(text.contains("avg ΔE₀₀ 0.70")) + XCTAssertTrue( + workflow.wizard.notice?.text.contains("job-report.md") == true) + } + + func testOpenPushesRecent() async throws { + let url = try writeProjectFile() + await project.openAsync(url) + try await Task.sleep(nanoseconds: 300_000_000) + + let recents = project.recents + XCTAssertEqual(recents.first?.path, url.path) + XCTAssertEqual(recents.first?.name, "Fixture Project") + } + + func testOpenRecentMissingFileDropped() async throws { + let store = env.environment.recentProjectsStore + let ghost = env.root.appendingPathComponent("ghost.icceryproj") + try await store.add(url: ghost, name: "Ghost") + + project.openRecent( + RecentProjectEntry(name: "Ghost", path: ghost.path)) + try await Task.sleep(nanoseconds: 300_000_000) + + XCTAssertTrue( + workflow.wizard.notice?.text.contains("gone") == true) + let loaded = try await store.load() + XCTAssertTrue(loaded.isEmpty) + XCTAssertNil(project.projectURL) + } +} diff --git a/Tests/ICCeryCoreTests/RecentProjectsStoreTests.swift b/Tests/ICCeryCoreTests/RecentProjectsStoreTests.swift new file mode 100644 index 0000000..e73cf85 --- /dev/null +++ b/Tests/ICCeryCoreTests/RecentProjectsStoreTests.swift @@ -0,0 +1,134 @@ +import Foundation +import XCTest +@testable import ICCeryCore + +/// Issue #149 — `recent_projects.json`: cap 20, newest first, dedupe +/// by path, missing files pruned on submenu build, Clear Menu wipes the +/// recents file only, corrupt file kept (R12). +final class RecentProjectsStoreTests: XCTestCase { + + private var root: URL! + + override func setUp() async throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-recents-\(UUID().uuidString)") + try FileManager.default.createDirectory( + at: root, withIntermediateDirectories: true) + } + + override func tearDown() async throws { + if let root { try? FileManager.default.removeItem(at: root) } + root = nil + } + + private var storeURL: URL { + root.appendingPathComponent("recent_projects.json") + } + + private func projectFile(_ name: String) throws -> URL { + let url = root.appendingPathComponent("\(name).icceryproj") + try "{}".write(to: url, atomically: true, encoding: .utf8) + return url + } + + func testCapTwentyNewestFirst() async throws { + let store = RecentProjectsStore(url: storeURL) + for i in 0..<25 { + let url = try projectFile("p\(i)") + try await store.add(url: url, name: "P\(i)") + } + let all = try await store.load() + XCTAssertEqual(all.count, 20) + XCTAssertEqual(all.first?.name, "P24") + XCTAssertEqual(all.last?.name, "P5") + } + + func testAddDeduplicatesByPath() async throws { + let store = RecentProjectsStore(url: storeURL) + let url = try projectFile("dup") + try await store.add(url: url, name: "First") + try await store.add(url: try projectFile("other"), name: "Other") + try await store.add(url: url, name: "Second") + + let all = try await store.load() + XCTAssertEqual(all.count, 2) + XCTAssertEqual(all.first?.name, "Second") + XCTAssertEqual( + all.filter { $0.path == url.path }.count, 1) + } + + func testPruneMissingDropsGoneFiles() async throws { + let store = RecentProjectsStore(url: storeURL) + let live = try projectFile("live") + try await store.add(url: live, name: "Live") + try await store.add( + url: root.appendingPathComponent("gone.icceryproj"), + name: "Gone") + + let pruned = try await store.pruneMissing() + XCTAssertEqual(pruned.count, 1) + XCTAssertEqual(pruned.first?.name, "Live") + + // The rewrite persisted the drop. + let reloaded = try await RecentProjectsStore(url: storeURL).load() + XCTAssertEqual(reloaded.count, 1) + } + + func testRemoveByPath() async throws { + let store = RecentProjectsStore(url: storeURL) + let url = try projectFile("a") + try await store.add(url: url, name: "A") + let removed = try await store.remove(path: url.path) + XCTAssertTrue(removed) + let loaded = try await store.load() + XCTAssertTrue(loaded.isEmpty) + let second = try await store.remove(path: url.path) + XCTAssertFalse(second) + } + + func testClearWipesRecentsFileOnly() async throws { + let store = RecentProjectsStore(url: storeURL) + let projectURL = try projectFile("keep") + try await store.add(url: projectURL, name: "Keep") + + try await store.clear() + + let loaded = try await store.load() + XCTAssertTrue(loaded.isEmpty) + // The `.icceryproj` itself survives Clear Menu. + XCTAssertTrue( + FileManager.default.fileExists(atPath: projectURL.path)) + let raw = try String(contentsOf: storeURL, encoding: .utf8) + XCTAssertTrue(raw.contains("[")) + } + + func testCorruptFileThrowsAndKeepsBytes() async throws { + try "not json".write( + to: storeURL, atomically: true, encoding: .utf8) + let store = RecentProjectsStore(url: storeURL) + + await assertAsyncThrows(expectedType: DecodingError.self) { + try await store.load() + } + XCTAssertEqual( + try String(contentsOf: storeURL, encoding: .utf8), "not json") + } + + func testEntryStoresBookmarkAndPath() async throws { + let store = RecentProjectsStore(url: storeURL) + let url = try projectFile("b") + try await store.add(url: url, name: "B") + let entry = try await store.load().first + XCTAssertEqual(entry?.path, url.path) + XCTAssertNotNil(entry?.bookmark) + XCTAssertFalse(entry?.bookmarkHash.isEmpty ?? true) + } + + func testBookmarkHashStableAcrossStores() async throws { + let a = RecentProjectEntry(name: "x", path: "/tmp/p.icceryproj") + let b = RecentProjectEntry(name: "y", path: "/tmp/p.icceryproj") + XCTAssertEqual(a.bookmarkHash, b.bookmarkHash) + let c = RecentProjectEntry(name: "z", path: "/tmp/q.icceryproj") + XCTAssertNotEqual(a.bookmarkHash, c.bookmarkHash) + } +} diff --git a/Tests/ICCeryCoreTests/TestAppEnvironment.swift b/Tests/ICCeryCoreTests/TestAppEnvironment.swift index 20cfda0..eb15c85 100644 --- a/Tests/ICCeryCoreTests/TestAppEnvironment.swift +++ b/Tests/ICCeryCoreTests/TestAppEnvironment.swift @@ -22,6 +22,9 @@ struct TestAppEnvironment { var mediaLibraryURL: URL { root.appendingPathComponent("media_library.json") } + var recentProjectsURL: URL { + root.appendingPathComponent("recent_projects.json") + } /// Creates an isolated environment under `NSTemporaryDirectory()`. /// Call `cleanup()` when finished. @@ -65,6 +68,9 @@ struct TestAppEnvironment { ), mediaStore: MediaLibraryStore( url: root.appendingPathComponent("media_library.json") + ), + recentProjectsStore: RecentProjectsStore( + url: root.appendingPathComponent("recent_projects.json") ) ) return TestAppEnvironment(root: root, environment: environment) diff --git a/Tests/ICCeryUITests/Milestone10ProjectUITests.swift b/Tests/ICCeryUITests/Milestone10ProjectUITests.swift new file mode 100644 index 0000000..55a3249 --- /dev/null +++ b/Tests/ICCeryUITests/Milestone10ProjectUITests.swift @@ -0,0 +1,226 @@ +import XCTest + +/// Milestone 10 UI tests — issue #149 project file. Panels are never +/// real: `ICCERY_TEST_PROJECT_OPEN` / `ICCERY_TEST_PROJECT_SAVE` +/// inject fixture paths through `UITestHooks`. Menu commands are driven +/// by their keyboard shortcuts (⌘N) or the sidebar chip so the tests do +/// not depend on menu AX exposure (R19). All queries by identifier. +@MainActor +final class Milestone10ProjectUITests: XCTestCase { + + private var app: XCUIApplication! + private var testRoot: URL! + private var binDir: URL! + private var workDir: URL! + + override func setUp() async throws { + continueAfterFailure = false + testRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-ui10p-\(UUID().uuidString)") + workDir = testRoot.appendingPathComponent("WorkDir") + binDir = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .appendingPathComponent("Fixtures/bin") + + try FileManager.default.createDirectory( + at: testRoot.appendingPathComponent("AppData"), + withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: workDir, withIntermediateDirectories: true) + + app = XCUIApplication() + app.launchEnvironment = [ + "ICCERY_UI_TESTING": "1", + "ICCERY_TEST_ROOT": testRoot.path, + "ICCERY_ARGYLL_BINARY_DIR": binDir.path, + "ICCERY_CUPS_BIN_DIR": binDir.path, + ] + } + + override func tearDown() async throws { + app?.terminate() + app = nil + if let testRoot { + try? FileManager.default.removeItem(at: testRoot) + } + testRoot = nil + workDir = nil + } + + private func launchApp() { + app.launch() + if !app.wait(for: .runningForeground, timeout: 10) { + app.activate() + } + } + + private func element(_ id: String) -> XCUIElement { + let inApp = app.descendants(matching: .any)[id].firstMatch + if inApp.exists { return inApp } + return app.sheets.firstMatch.descendants(matching: .any)[id].firstMatch + } + + private func waitFor(_ id: String, timeout: TimeInterval = 10) -> XCUIElement { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + let el = element(id) + if el.exists { return el } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + let el = element(id) + XCTAssertTrue(el.exists, "Expected element \(id)") + return el + } + + private func noticeText(timeout: TimeInterval = 10) -> String { + let el = waitFor("noticeText", timeout: timeout) + return (el.value as? String) ?? el.label + } + + /// Writes a `.icceryproj` fixture under `testRoot` and points the + /// open-picker hook at it. + private func stageProjectFixture( + basename: String = "ui149job", + lastVerification: Bool = false + ) throws -> URL { + let verification = lastVerification ? """ + "last_verification": { + "date": "2026-09-12T00:00:00Z", + "avg_de00": 0.7, + "max_de00": 1.9, + "status": "excellent", + "profile_filename": "\(basename).icc" + }, + """ : "" + let json = """ + { + "schema_version": 1, + "name": "UI Fixture Project", + "notes": "", + "basename": "\(basename)", + "cwd": "\(workDir.path)", + "printer_id": null, + "media_recipe_id": null, + "preset_id": null, + "calibration_url": null, + \(verification) + "updated": "2026-09-13T00:00:00Z" + } + """ + let url = testRoot.appendingPathComponent("fixture.icceryproj") + try json.write(to: url, atomically: true, encoding: .utf8) + app.launchEnvironment["ICCERY_TEST_PROJECT_OPEN"] = url.path + return url + } + + private func artefact(_ ext: String, stem: String = "ui149job") throws { + try "x".write( + to: workDir.appendingPathComponent("\(stem).\(ext)"), + atomically: true, encoding: .utf8) + } + + // MARK: - Tests + + func testNewProjectClearsBasenameDoesNotDeleteFixtureTi3() throws { + try artefact("ti3") + _ = try stageProjectFixture() + launchApp() + + // Open the fixture via the chip — never a real panel. + waitFor("btnProjectOpen").click() + _ = waitFor("projectChipPath") + let basenameField = app.textFields["targetBasename"] + XCTAssertTrue(basenameField.waitForExistence(timeout: 10)) + XCTAssertEqual(basenameField.value as? String, "ui149job") + + // ⌘N fires the File-menu New command even when the menu is not + // in the AX tree. Mock CUPS may have enumerated a queue that the + // fixture does not record, making the session dirty — in that + // case the dirty alert gates New first. + app.typeKey("n", modifierFlags: .command) + let deadline = Date().addingTimeInterval(10) + var confirmed = false + while Date() < deadline { + // Dirty sessions show the dirty alert first; discarding it + // runs the New reset directly (no second confirm). + if element("btnProjectDirtyDiscard").exists { + element("btnProjectDirtyDiscard").click() + confirmed = true + break + } + if element("btnProjectNewConfirm").exists { + element("btnProjectNewConfirm").click() + confirmed = true + break + } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + XCTAssertTrue(confirmed, "expected the New or dirty alert") + + // Basename cleared (legal "no target" state — not a + // placeholder), fixture `.ti3` untouched on disk. + let cleared = basenameField.value as? String ?? "" + XCTAssertEqual(cleared, "") + XCTAssertTrue(FileManager.default.fileExists( + atPath: workDir.appendingPathComponent("ui149job.ti3").path)) + XCTAssertTrue(element("projectChip").exists) + XCTAssertEqual( + element("projectChipName").value as? String, "No project") + } + + func testOpenProjectDiskWinsOverJsonStage() throws { + // JSON claims a finished profile; disk stops at .ti2 (R18). + try artefact("ti2") + _ = try stageProjectFixture(lastVerification: true) + launchApp() + + waitFor("btnProjectOpen").click() + + let notice = noticeText() + XCTAssertTrue( + notice.contains("artefacts on disk stop at .ti2"), + "got: \(notice)") + XCTAssertTrue(element("projectChipStale").exists) + XCTAssertEqual( + element("projectChipPath").value as? String, workDir.lastPathComponent) + } + + func testSaveDisabledWithoutBasename() throws { + launchApp() + + _ = waitFor("projectChip") + XCTAssertEqual(element("projectChipName").value as? String, "No project") + // Chip Save is hidden while unbound; Save As lives in the menu. + XCTAssertFalse(element("btnProjectSave").exists) + + // When the File menu is in the AX tree, Save must be disabled. + let fileMenu = app.menuBarItems["File"] + if fileMenu.waitForExistence(timeout: 3) { + fileMenu.click() + let save = app.menuItems["menuProjectSave"].firstMatch + if save.waitForExistence(timeout: 3) { + XCTAssertFalse(save.isEnabled) + } + app.typeKey(XCUIKeyboardKey.escape, modifierFlags: []) + } + } + + func testCalBasenameRefused() throws { + // A fixture whose stem is CAL_-prefixed binds a live CAL_ + // basename; the persisted original is empty, so Save must + // refuse and never write CAL_ back (R11). + let url = try stageProjectFixture(basename: "CAL_ui149") + let before = try Data(contentsOf: url) + launchApp() + + waitFor("btnProjectOpen").click() + let save = waitFor("btnProjectSave") + XCTAssertTrue(save.isEnabled) + save.click() + + XCTAssertTrue( + noticeText().contains("Finish or exit calibration"), + "got: \(noticeText())") + XCTAssertEqual(try Data(contentsOf: url), before) + } +} diff --git a/docs/06-wizard-and-artefacts.md b/docs/06-wizard-and-artefacts.md index 20a5ccb..45241a6 100644 --- a/docs/06-wizard-and-artefacts.md +++ b/docs/06-wizard-and-artefacts.md @@ -46,3 +46,9 @@ Never spawn with `cwd: ""`. Initialize from `get_default_working_dir` (Documents ## No `"test_target"` fallbacks (#60) A rewrite must not invent default basenames. Later stages stay inert until `wizardState.basename` is set from a real artefact. + +## Project file (#149) + +A `.icceryproj` is a JSON **index** (`schema_version` 1) over `basename` + `cwd` + optional `media_recipe_id`/`preset_id`/`calibration_url`/`printer_*` + a `last_verification` ΔE₀₀ snapshot. It is never a second source of truth: opening one writes `WizardState` and re-runs the same artefact probe window focus uses — if JSON claims Stage 5 but disk stops at `.ti2`, the stepper follows the disk and the sidebar chip shows `projectChipStale` with an info banner. + +`wizard_state.json` remains the crash-resume file; there is no auto-save. Recents live in `appDataDir/recent_projects.json` (cap 20, bookmark + path, corrupt → `[]` + keep bytes). `Save Report…` writes `{basename}-report.md` in cwd atomically. A live `CAL_` basename is never persisted — Save resolves the persisted `calibrationOriginalBasename` or refuses with a banner. diff --git a/docs/21-ui-reference.md b/docs/21-ui-reference.md index ae9ced1..3cc677f 100644 --- a/docs/21-ui-reference.md +++ b/docs/21-ui-reference.md @@ -10,6 +10,8 @@ Vanilla HTML + CSS. Rewrite may use any toolkit; **ids and behaviours** below ar Sidebar chrome ids: `openSettingsBtn`, `openAboutBtn`, `btnSavePresetModal`, `btnOpenPresetsDialog`, `presetSelect`, `mediaSelect`, `mediaRecipeStale`, `btnMediaLibraryCapture`, `btnMediaLibraryManage`, `btnCalibratePrinter`, `btnSpotRead`, `calStatusChip`. +Project chrome (File menu + bottom sidebar chip, #149): `menuProjectNew`, `menuProjectOpen`, `menuProjectRecents`, `projectRecent-{id}`, `menuProjectRecentsClear`, `menuProjectSave`, `menuProjectSaveAs`, `menuProjectReport`, `menuProjectClose`, `projectChip`, `projectChipName`, `projectChipPath`, `projectChipStale`, `btnProjectOpen`, `btnProjectSave`, `btnProjectReveal`, `projectNewAlert`, `btnProjectNewCancel`, `btnProjectNewConfirm`, `btnProjectDirtySave`, `btnProjectDirtyDiscard`, `btnProjectDirtyCancel`. + Banner: `wizardNotification`, `wizardNotificationIcon`, `wizardNotificationText`, `wizardNotificationClose`. Auto-hides via `wizardState.noticeTimer`. ## Design tokens (`main.css`) @@ -75,12 +77,13 @@ Keyboard: **R** resets gamut camera when Stage 5 is visible. Bind to a focusable | Cal collision | `calCollisionDialog` | Overwrite / Rename / Cancel | | Profile install collision | `profileInstallCollisionDialog` | `profileInstallCollisionMessage`, `profileOverwriteBtn`, `profileRenameBtn`, `profileCancelCollisionBtn` | | Spot read | `spotReadView` | `btnSpotDetectInstruments`, `spotDetectError`, `spotInstrumentSelect`, `spotDefaultMissing`, `spotSetDefault`, `spotXYHint`, `spotPrompt`, `spotLastError`, `spotLogContainer`, `spotLog`, `btnSpotStart`, `btnSpotCalibrate`, `btnSpotTrigger`, `btnSpotStop`, `spotLastSample`, `spotLastEmpty`, `spotLabL`, `spotLabA`, `spotLabB`, `spotXYZ`, `spotSwatch`, `spotDeltaE`, `spotLastInstrument`, `spotLabImplausible`, `spotHistoryTable`, `spotHistoryEmpty`, `spotHistoryRow-{uuid}`, `btnSpotCopyLab`, `btnSpotExportCsv`, `spotSidecarMissing`, `btnCloseSpotRead` | +| Project relocate | `projectRelocateSheet` | `btnProjectRelocate`, `btnProjectRelocateCancel` | | Gamut viewer | `gamutView` | `gamutLayer-sRGB`, `gamutLayer-profile`, `gamutLayer-compare`, `btnGamutAddCompare`, `btnGamutOpenGam`, `btnGamutOpenProfile`, `btnGamutRemoveCompare`, `btnGamutSampleTiff`, `btnResetGamutCamera`, `gamutStatusText`, `gamutNoticeText`, `gamutInspectPanel`, `gamutInspectIdle`, `gamutInspectL`, `gamutInspectA`, `gamutInspectB`, `gamutInspect-sRGB`, `gamutInspect-profile`, `gamutInspect-compare`, `gamutInspectSwatch`, `gamutInspectApprox`, `gamutLabEntryL`, `gamutLabEntryA`, `gamutLabEntryB`, `btnGamutInspectLab`, `gamutTiffPreview`, `btnCloseGamutTiffPreview`, `gamutViewerUnavailable` | ## Dialogs must go through host APIs Tauri v2 has **no** `window.__TAURI__.dialog`. Use invoke wrappers (`select_*`). Bugs #103, #210, #211 were exactly this. -## Complete `id=` roster (330) +## Complete `id=` roster (355) -`openSettingsBtn`, `openAboutBtn`, `btnSavePresetModal`, `btnOpenPresetsDialog`, `presetSelect`, `btnCalibratePrinter`, `calStatusChip`, `wizardNotification`, `wizardNotificationIcon`, `wizardNotificationText`, `wizardNotificationClose`, `stage-cal`, `calApplyToggleDash`, `calRgbHint`, `calSteps`, `calInkExplore`, `calNeutralEmphasis`, `btnCalGenerate`, `btnCalLayout`, `btnCalMeasure`, `calCurrentFile`, `btnCalLoad`, `btnCalLibrary`, `btnCalClear`, `calSavedSelect`, `btnCalCompute`, `calCurveSvg`, `calCurveLegend`, `calTacValue`, `calTacOverride`, `calInkLimitControls`, `calRecommendedPower`, `btnCalBackToWizard`, `calLogContainer`, `calLog`, `stage-1`, `btnToggleAllHelp`, `calStage1Recommend`, `btnCalRecalibrate`, `stage1FormContainer`, `patchCountPreset`, `patchCountCustom`, `whitePatches`, `blackPatches`, `btn-import-dataset`, `btnOpenExisting`, `targetBasename`, `btnBrowse`, `selectedPathDisplay`, `targenAdvancedDetails`, `targenPrecondProfile`, `btnBrowsePrecondProfile`, `targenNeutralSteps`, `targenNeutralConcentration`, `targenNeutralConcVal`, `targenGreySteps`, `targenSingleChannelSteps`, `targenAdaptation`, `targenAdaptationVal`, `targenDarkEmphasis`, `targenDarkEmphasisVal`, `targenDevicePower`, `targenInkLimitGroup`, `targenInkLimit`, `targenAlgorithm`, `targenHighQuality`, `btnGenerate`, `targenLogContainer`, `targenLog`, `stage-2`, `cmWarningBanner`, `instrumentSelect`, `pageSizeSelect`, `customPageSizeRow`, `customPageW`, `customPageH`, `tiffDpi`, `printtargLayoutOrder`, `printtargCustomSeedGroup`, `printtargCustomSeed`, `btnToggleLabelEdit`, `targetMetadataPrinter`, `targetMetadataInkSet`, `targetMetadataDriverPaper`, `targetMetadataActualPaper`, `targetLabelPreview`, `btnCreateLayout`, `printtargLogContainer`, `printtargLog`, `tiffGallery`, `galleryInfo`, `galleryGrid`, `rawPrintPanel`, `printNotification`, `printNotificationIcon`, `printNotificationText`, `printerSelect`, `btnRefreshPrinters`, `btnPrinterProperties`, `printerStatusBadge`, `cupsOptionsGroup`, `chkPpdFallback`, `printerTraySelect`, `mediaTypeGroup`, `printerMediaTypeSelect`, `btnOrientPortrait`, `btnOrientLandscape`, `btnPrintAll`, `btnAdvanceToStage3`, `stage-3`, `stage3LoadedTargetBanner`, `stage3TargetBasename`, `stage3TargetMeta`, `stage3TargetBadge`, `chartreadInstrumentSelect`, `btnDetectInstruments`, `xyTableHint`, `xyTablePanel`, `xyTableActiveStepBadge`, `xyStepPlace`, `xyStepAlign`, `xyStepScan`, `xyStepRemove`, `chartreadState`, `chartreadPrompt`, `btnStartRead`, `btnCalibrate`, `btnDoneRead`, `btnAccept`, `btnRetry`, `btnUndo`, `btnSkip`, `btnCancel`, `readProgressContainer`, `readProgress`, `readProgressText`, `readStats`, `swatchGrid`, `chartreadAveragingPanel`, `passCounterBadge`, `passesList`, `btnMeasureAnotherSheet`, `btnFinishAndAverage`, `chartreadLogContainer`, `chartreadLog`, `stage-4`, `colprofQuality`, `colprofDescription`, `colprofCopyright`, `colprofAlgorithm`, `colprofFwa`, `colprofCustomSpRow`, `colprofCustomSpPath`, `btnBrowseCustomSp`, `colprofIlluminant`, `colprofObserver`, `colprofInputViewCond`, `colprofOutputViewCond`, `btnCreateProfile`, `colprofSpinnerContainer`, `colprofStageLabel`, `colprofSuccessCard`, `colprofSuccessInfo`, `btnGoToVerify`, `colprofLogContainer`, `colprofLog`, `stage-5`, `btnVerify`, `btnInstallProfile`, `profcheckReportCard`, `profcheckBadge`, `profcheckAvgDe`, `profcheckMaxDe`, `profcheckRmsDe`, `driftHistorySection`, `driftAlertCard`, `driftAlertIcon`, `driftAlertText`, `btnDriftRecalibrate`, `driftFilterRow`, `driftPrinterFilter`, `driftChartWrap`, `driftTrendChart`, `driftEmptyState`, `verificationHistoryTable`, `verificationHistoryTbody`, `btnExportHistoryCsv`, `btnClearHistory`, `gamutViewerWrap`, `gamutViewerContainer`, `gamutControlsPanel`, `chkProfileGamut`, `rngProfileOpacity`, `chkSrgbReference`, `rngSrgbOpacity`, `chkLabAxes`, `rngAxisOpacity`, `btnGamutResetCamera`, `profcheckLogContainer`, `profcheckLog`, `settingsDialog`, `argyll_binary_dir`, `default_instrument`, `enable_i1pro2_leds`, `deltaEGoodMax`, `deltaEWarningMax`, `deltaEThresholdError`, `calibrationStaleDays`, `defaultInstallLocation`, `askBeforeOverwriteProfile`, `openColorPanelAfterInstall`, `logLevelSelect`, `btnOpenLogFolder`, `btnCopyLogPath`, `btnCopyLogExcerpt`, `logPathDisplay`, `saveSettingsBtn`, `closeSettingsBtn`, `calCollisionDialog`, `calCollisionMessage`, `calOverwriteBtn`, `calRenameBtn`, `calCancelCollisionBtn`, `profileInstallCollisionDialog`, `profileInstallCollisionMessage`, `profileOverwriteBtn`, `profileRenameBtn`, `profileCancelCollisionBtn`, `aboutDialog`, `aboutVersion`, `aboutBuildDate`, `closeAboutBtn`, `savePresetDialog`, `savePresetName`, `savePresetDesc`, `btnConfirmSavePreset`, `btnCloseSavePresetDialog`, `managePresetsDialog`, `managePresetsList`, `btnExportActivePreset`, `btnImportPreset`, `btnCloseManagePresetsDialog`, `mediaSelect`, `mediaRecipeStale`, `btnMediaLibraryCapture`, `btnMediaLibraryManage`, `saveMediaRecipeDialog`, `saveMediaName`, `saveMediaNotes`, `saveMediaPaper`, `saveMediaInk`, `saveMediaPrinter`, `saveMediaPreset`, `saveMediaColourSpace`, `saveMediaCal`, `saveMediaApplyCal`, `btnConfirmSaveMedia`, `btnCloseSaveMediaDialog`, `manageMediaDialog`, `mediaLibraryList`, `mediaLibraryEmpty`, `mediaRow-{id}`, `btnMediaLibraryApply-{id}`, `btnMediaLibraryDelete-{id}`, `btnMediaLibraryApply`, `btnMediaLibraryCaptureFromManage`, `btnCloseManageMediaDialog`, `btnSpotRead`, `spotReadView`, `btnCloseSpotRead`, `spotSidecarMissing`, `btnSpotDetectInstruments`, `spotDetectError`, `spotInstrumentSelect`, `spotDefaultMissing`, `spotSetDefault`, `spotXYHint`, `spotPrompt`, `spotLastError`, `spotLogContainer`, `spotLog`, `btnSpotStart`, `btnSpotCalibrate`, `btnSpotTrigger`, `btnSpotStop`, `spotLastSample`, `spotLastEmpty`, `spotLabL`, `spotLabA`, `spotLabB`, `spotXYZ`, `spotSwatch`, `spotDeltaE`, `spotLastInstrument`, `spotLabImplausible`, `spotHistoryTable`, `spotHistoryEmpty`, `spotHistoryRow-{uuid}`, `btnSpotCopyLab`, `btnSpotExportCsv`, `btnViewGamut`, `gamutView`, `gamutStatusText`, `gamutNoticeText`, `btnResetGamutCamera`, `gamutLayer-sRGB`, `gamutLayer-profile`, `gamutLayer-compare`, `btnGamutAddCompare`, `btnGamutOpenGam`, `btnGamutOpenProfile`, `btnGamutRemoveCompare`, `btnGamutSampleTiff`, `gamutInspectPanel`, `gamutInspectIdle`, `gamutInspectL`, `gamutInspectA`, `gamutInspectB`, `gamutInspect-sRGB`, `gamutInspect-profile`, `gamutInspect-compare`, `gamutInspectSwatch`, `gamutInspectApprox`, `gamutLabEntryL`, `gamutLabEntryA`, `gamutLabEntryB`, `btnGamutInspectLab`, `gamutTiffPreview`, `btnCloseGamutTiffPreview`, `gamutViewerUnavailable`. +`openSettingsBtn`, `openAboutBtn`, `btnSavePresetModal`, `btnOpenPresetsDialog`, `presetSelect`, `btnCalibratePrinter`, `calStatusChip`, `wizardNotification`, `wizardNotificationIcon`, `wizardNotificationText`, `wizardNotificationClose`, `stage-cal`, `calApplyToggleDash`, `calRgbHint`, `calSteps`, `calInkExplore`, `calNeutralEmphasis`, `btnCalGenerate`, `btnCalLayout`, `btnCalMeasure`, `calCurrentFile`, `btnCalLoad`, `btnCalLibrary`, `btnCalClear`, `calSavedSelect`, `btnCalCompute`, `calCurveSvg`, `calCurveLegend`, `calTacValue`, `calTacOverride`, `calInkLimitControls`, `calRecommendedPower`, `btnCalBackToWizard`, `calLogContainer`, `calLog`, `stage-1`, `btnToggleAllHelp`, `calStage1Recommend`, `btnCalRecalibrate`, `stage1FormContainer`, `patchCountPreset`, `patchCountCustom`, `whitePatches`, `blackPatches`, `btn-import-dataset`, `btnOpenExisting`, `targetBasename`, `btnBrowse`, `selectedPathDisplay`, `targenAdvancedDetails`, `targenPrecondProfile`, `btnBrowsePrecondProfile`, `targenNeutralSteps`, `targenNeutralConcentration`, `targenNeutralConcVal`, `targenGreySteps`, `targenSingleChannelSteps`, `targenAdaptation`, `targenAdaptationVal`, `targenDarkEmphasis`, `targenDarkEmphasisVal`, `targenDevicePower`, `targenInkLimitGroup`, `targenInkLimit`, `targenAlgorithm`, `targenHighQuality`, `btnGenerate`, `targenLogContainer`, `targenLog`, `stage-2`, `cmWarningBanner`, `instrumentSelect`, `pageSizeSelect`, `customPageSizeRow`, `customPageW`, `customPageH`, `tiffDpi`, `printtargLayoutOrder`, `printtargCustomSeedGroup`, `printtargCustomSeed`, `btnToggleLabelEdit`, `targetMetadataPrinter`, `targetMetadataInkSet`, `targetMetadataDriverPaper`, `targetMetadataActualPaper`, `targetLabelPreview`, `btnCreateLayout`, `printtargLogContainer`, `printtargLog`, `tiffGallery`, `galleryInfo`, `galleryGrid`, `rawPrintPanel`, `printNotification`, `printNotificationIcon`, `printNotificationText`, `printerSelect`, `btnRefreshPrinters`, `btnPrinterProperties`, `printerStatusBadge`, `cupsOptionsGroup`, `chkPpdFallback`, `printerTraySelect`, `mediaTypeGroup`, `printerMediaTypeSelect`, `btnOrientPortrait`, `btnOrientLandscape`, `btnPrintAll`, `btnAdvanceToStage3`, `stage-3`, `stage3LoadedTargetBanner`, `stage3TargetBasename`, `stage3TargetMeta`, `stage3TargetBadge`, `chartreadInstrumentSelect`, `btnDetectInstruments`, `xyTableHint`, `xyTablePanel`, `xyTableActiveStepBadge`, `xyStepPlace`, `xyStepAlign`, `xyStepScan`, `xyStepRemove`, `chartreadState`, `chartreadPrompt`, `btnStartRead`, `btnCalibrate`, `btnDoneRead`, `btnAccept`, `btnRetry`, `btnUndo`, `btnSkip`, `btnCancel`, `readProgressContainer`, `readProgress`, `readProgressText`, `readStats`, `swatchGrid`, `chartreadAveragingPanel`, `passCounterBadge`, `passesList`, `btnMeasureAnotherSheet`, `btnFinishAndAverage`, `chartreadLogContainer`, `chartreadLog`, `stage-4`, `colprofQuality`, `colprofDescription`, `colprofCopyright`, `colprofAlgorithm`, `colprofFwa`, `colprofCustomSpRow`, `colprofCustomSpPath`, `btnBrowseCustomSp`, `colprofIlluminant`, `colprofObserver`, `colprofInputViewCond`, `colprofOutputViewCond`, `btnCreateProfile`, `colprofSpinnerContainer`, `colprofStageLabel`, `colprofSuccessCard`, `colprofSuccessInfo`, `btnGoToVerify`, `colprofLogContainer`, `colprofLog`, `stage-5`, `btnVerify`, `btnInstallProfile`, `profcheckReportCard`, `profcheckBadge`, `profcheckAvgDe`, `profcheckMaxDe`, `profcheckRmsDe`, `driftHistorySection`, `driftAlertCard`, `driftAlertIcon`, `driftAlertText`, `btnDriftRecalibrate`, `driftFilterRow`, `driftPrinterFilter`, `driftChartWrap`, `driftTrendChart`, `driftEmptyState`, `verificationHistoryTable`, `verificationHistoryTbody`, `btnExportHistoryCsv`, `btnClearHistory`, `gamutViewerWrap`, `gamutViewerContainer`, `gamutControlsPanel`, `chkProfileGamut`, `rngProfileOpacity`, `chkSrgbReference`, `rngSrgbOpacity`, `chkLabAxes`, `rngAxisOpacity`, `btnGamutResetCamera`, `profcheckLogContainer`, `profcheckLog`, `settingsDialog`, `argyll_binary_dir`, `default_instrument`, `enable_i1pro2_leds`, `deltaEGoodMax`, `deltaEWarningMax`, `deltaEThresholdError`, `calibrationStaleDays`, `defaultInstallLocation`, `askBeforeOverwriteProfile`, `openColorPanelAfterInstall`, `logLevelSelect`, `btnOpenLogFolder`, `btnCopyLogPath`, `btnCopyLogExcerpt`, `logPathDisplay`, `saveSettingsBtn`, `closeSettingsBtn`, `calCollisionDialog`, `calCollisionMessage`, `calOverwriteBtn`, `calRenameBtn`, `calCancelCollisionBtn`, `profileInstallCollisionDialog`, `profileInstallCollisionMessage`, `profileOverwriteBtn`, `profileRenameBtn`, `profileCancelCollisionBtn`, `aboutDialog`, `aboutVersion`, `aboutBuildDate`, `closeAboutBtn`, `savePresetDialog`, `savePresetName`, `savePresetDesc`, `btnConfirmSavePreset`, `btnCloseSavePresetDialog`, `managePresetsDialog`, `managePresetsList`, `btnExportActivePreset`, `btnImportPreset`, `btnCloseManagePresetsDialog`, `mediaSelect`, `mediaRecipeStale`, `btnMediaLibraryCapture`, `btnMediaLibraryManage`, `saveMediaRecipeDialog`, `saveMediaName`, `saveMediaNotes`, `saveMediaPaper`, `saveMediaInk`, `saveMediaPrinter`, `saveMediaPreset`, `saveMediaColourSpace`, `saveMediaCal`, `saveMediaApplyCal`, `btnConfirmSaveMedia`, `btnCloseSaveMediaDialog`, `manageMediaDialog`, `mediaLibraryList`, `mediaLibraryEmpty`, `mediaRow-{id}`, `btnMediaLibraryApply-{id}`, `btnMediaLibraryDelete-{id}`, `btnMediaLibraryApply`, `btnMediaLibraryCaptureFromManage`, `btnCloseManageMediaDialog`, `btnSpotRead`, `spotReadView`, `btnCloseSpotRead`, `spotSidecarMissing`, `btnSpotDetectInstruments`, `spotDetectError`, `spotInstrumentSelect`, `spotDefaultMissing`, `spotSetDefault`, `spotXYHint`, `spotPrompt`, `spotLastError`, `spotLogContainer`, `spotLog`, `btnSpotStart`, `btnSpotCalibrate`, `btnSpotTrigger`, `btnSpotStop`, `spotLastSample`, `spotLastEmpty`, `spotLabL`, `spotLabA`, `spotLabB`, `spotXYZ`, `spotSwatch`, `spotDeltaE`, `spotLastInstrument`, `spotLabImplausible`, `spotHistoryTable`, `spotHistoryEmpty`, `spotHistoryRow-{uuid}`, `btnSpotCopyLab`, `btnSpotExportCsv`, `btnViewGamut`, `gamutView`, `gamutStatusText`, `gamutNoticeText`, `btnResetGamutCamera`, `gamutLayer-sRGB`, `gamutLayer-profile`, `gamutLayer-compare`, `btnGamutAddCompare`, `btnGamutOpenGam`, `btnGamutOpenProfile`, `btnGamutRemoveCompare`, `btnGamutSampleTiff`, `gamutInspectPanel`, `gamutInspectIdle`, `gamutInspectL`, `gamutInspectA`, `gamutInspectB`, `gamutInspect-sRGB`, `gamutInspect-profile`, `gamutInspect-compare`, `gamutInspectSwatch`, `gamutInspectApprox`, `gamutLabEntryL`, `gamutLabEntryA`, `gamutLabEntryB`, `btnGamutInspectLab`, `gamutTiffPreview`, `btnCloseGamutTiffPreview`, `gamutViewerUnavailable`, `menuProjectNew`, `menuProjectOpen`, `menuProjectRecents`, `projectRecent-{id}`, `menuProjectRecentsClear`, `menuProjectSave`, `menuProjectSaveAs`, `menuProjectReport`, `menuProjectClose`, `projectChip`, `projectChipName`, `projectChipPath`, `projectChipStale`, `btnProjectOpen`, `btnProjectSave`, `btnProjectReveal`, `projectNewAlert`, `btnProjectNewCancel`, `btnProjectNewConfirm`, `btnProjectDirtySave`, `btnProjectDirtyDiscard`, `btnProjectDirtyCancel`, `projectRelocateSheet`, `btnProjectRelocate`, `btnProjectRelocateCancel`.