diff --git a/BUILD-PLAN.md b/BUILD-PLAN.md index e2e4565..049fa24 100644 --- a/BUILD-PLAN.md +++ b/BUILD-PLAN.md @@ -1,6 +1,6 @@ # BUILD-PLAN.md — ICCery v2 Mac -Spec snapshot: `docs/`. Source of tickets: Gitea milestones M1–M6 + Later. +Spec snapshot: `docs/`. Source of tickets: Gitea milestones M1–M6 + M10 (id 32) + Later. ## Sprint rule Do not start milestone N+1 implementation until milestone N **CI/mock gate** is green. @@ -17,9 +17,14 @@ Hardware gates block *release of that sprint*, not filing, and not starting codi | M5 | Profile / verify / install | 23–27 | colprof → `.icc`; profcheck parse; atomic history; install into temp dir | Full `.ti1`→`.icc`; profile visible in ColorSync Utility | | M6 | Gamut, Stage 0, CGATS, release | 28–32 | `.gam` fixtures; cal argv; CGATS round-trip; signed sidecars; dmgbuild | Stage 0 on a real printer; gamut of a real profile | | M7 | Deduplicate & consolidate | 79–86 | Shared runner loop; JSONFileStore; preset↔config maps; Notice/log helper; ProcessManager factory; PrintSession VM; identity + colour-type cleanup | N/A | +| M8 | Deduplicate & consolidate | 79–86 | (already shipped on `develop`) | N/A | +| M9 | macOS 12 / Xcode 14.2 retarget | (milestone/m9-monterey, PR #145) | XCTest + ObservableObject + macos-12 CI | N/A | +| M10 | Studio workflow | 146–149 | Media library + spot-read + gamut compare + project file unit/UI smoke | Real printer+paper+.cal; live spot-read; two `.gam`; reopen `.icceryproj` | | Later | Quartz / TargetPrint | 16 | `ICCeryPrintKit` standalone + seam test | 1:1 on paper vs TIFF | -Issue **16 is not an M3 or M6 exit gate.** +M8 and M9 merged to `develop` via PR #104 / #145; M10 starts from `800c980`. + +Issue **16 is not an M3, M6, or M10 exit gate.** ## Branch taxonomy diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/ArgyllRunner.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/ArgyllRunner.swift index fbbfb9e..df8b192 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/ArgyllRunner.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/ArgyllRunner.swift @@ -742,6 +742,148 @@ public struct ArgyllRunner: Sendable { } } + // MARK: - spotread (spot-read console, issue #148) + + /// Runs `spotread` and returns an `AsyncStream` of typed events. + /// + /// Same subscribe-before-spawn shape as `runChartread`, but there is + /// no artefact: the stream ends with `.exit(code)`. The single-lease + /// process id is `ProcessID.spotread` — never `chartread_{basename}`. + /// Missing sidecar surfaces as `.failed`; there is no `$PATH` or + /// `chartread` fallback (#116, R14/R21). + public func runSpotread(config: SpotReadConfig) -> AsyncStream { + let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory) + let args = SpotReadArgs.build(config: config) + let binaryURL = binaryResolver.resolve("spotread") + let processId = ProcessID.spotread + let processManager = self.processManager + let isXY = config.isXY + let instrumentName = config.instrumentName + let instrumentPort = config.instrumentPort + + return AsyncStream { continuation in + let task = Task { + await ensureNotRunning(id: processId) + let events = processManager.events() + + // XY parking hook before any kill, same as chartread. + await processManager.setPreKillHook(id: processId) { [processManager] in + if isXY { + try? await processManager.sendStdin(id: processId, bytes: ChartreadInput.quit.bytes) + try? await Task.sleep(nanoseconds: Self.testAwareDelay(500_000_000)) + } + } + + guard binaryResolver.exists(binaryURL) else { + continuation.yield(.failed(ArgyllRunnerError.toolFailed( + tool: "spotread", code: -1, + logs: ["spotread sidecar missing — run fetch-argyll"]))) + continuation.finish() + return + } + + do { + try await processManager.runStreaming( + id: processId, + binary: binaryURL, + arguments: args, + workingDirectory: cwd + ) + } catch { + continuation.yield(.failed(ArgyllRunnerError.toolFailed( + tool: "spotread", code: -1, logs: [error.localizedDescription]))) + continuation.finish() + return + } + + var state: ChartreadState = .idle + var pendingLogs: [String] = [] + var lastFlush = Date() + var exitCode: Int32? + + func flushLogs() { + guard !pendingLogs.isEmpty else { return } + let batch = pendingLogs + pendingLogs.removeAll(keepingCapacity: true) + continuation.yield(.log(batch)) + } + + for await event in events { + guard event.id == processId else { continue } + + switch event { + case .stdout(_, let line): + if let parsed = SpotReadParser.parse(line: line) { + continuation.yield(.sample(SpotReadSample( + lab: parsed.lab, + xyz: parsed.xyz, + instrumentName: instrumentName, + port: instrumentPort, + rawLine: line + ))) + } + let classified = SpotReadClassifier.classify( + line: line, previousState: state) + if classified.state != state + || classified.requestedWarningKey != nil { + state = classified.state + continuation.yield(.prompt(classified)) + } + pendingLogs.append(line) + + case .stderr(_, let line): + pendingLogs.append(line) + + case .jsonRow: + // spotread is never run with `-u`. + break + + case .error(_, let message): + pendingLogs.append("Error: \(message)") + + case .exit(_, let code): + exitCode = code + } + + if exitCode == nil, + pendingLogs.count >= 20 || Date().timeIntervalSince(lastFlush) >= 0.1 { + flushLogs() + lastFlush = Date() + } + + if exitCode != nil { + flushLogs() + break + } + } + + continuation.yield(.exit(exitCode ?? -1)) + continuation.finish() + } + + continuation.onTermination = { _ in + task.cancel() + Task { + await processManager.kill(id: processId) + } + } + } + } + + /// Send input bytes to the running `spotread` child. Reuses + /// `ChartreadInput` — the stdin protocol is identical. + public func sendSpotreadInput(_ input: ChartreadInput) async throws { + try await processManager.sendStdin(id: ProcessID.spotread, bytes: input.bytes) + } + + /// Terminate a running `spotread` child. The XY park (`q\n` + + /// ~500 ms) runs in the pre-kill hook registered by `runSpotread`. + public func cancelSpotread() { + Task { + await processManager.kill(id: ProcessID.spotread) + } + } + // MARK: - Stage 0 calibration /// Generates a calibration wedge `.ti1`. @@ -828,6 +970,20 @@ public enum ChartreadEvent: Sendable { case failed(ArgyllRunnerError) } +/// Events emitted by a running `spotread` session (issue #148). +public enum SpotReadEvent: Sendable { + /// Classified prompt / state update (reuses `ChartreadState`). + case prompt(ChartreadClassifyResult) + /// A parsed `Result is …` sample line. + case sample(SpotReadSample) + /// A batched log chunk (stdout + stderr lines). + case log([String]) + /// Process exited with the given code. + case exit(Int32) + /// Failure (missing sidecar, spawn error). + case failed(ArgyllRunnerError) +} + /// Exact bytes sent to `chartread` stdin. public enum ChartreadInput: Sendable { case trigger // " \n" diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/SpotReadArgs.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/SpotReadArgs.swift new file mode 100644 index 0000000..f454672 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/SpotReadArgs.swift @@ -0,0 +1,28 @@ +import Foundation + +/// Pure argv builder for Argyll's `spotread` tool (issue #148). +public enum SpotReadArgs { + + /// Builds `spotread` argv per the Gronod fork protocol. + /// + /// - Always `-v -e` (paper / reflective; never display `-d`). + /// - `-c N` is emitted only for `selectedPort != nil` and `N > 1` + /// (Auto and port 1 omit it, #111). + /// - `-Y l` (letter L) is emitted only when `enableLEDs` is `true` (#204). + /// - Never `-u`: the v2.0 `-u` policy covers printtarg + chartread + + /// profcheck only. + /// - No basename — `spotread` writes no artefact. + public static func build(config: SpotReadConfig) -> [String] { + var args: [String] = ["-v", "-e"] + + if let port = config.selectedPort, port > 1 { + args.append(contentsOf: ["-c", "\(port)"]) + } + + if config.enableLEDs { + args.append(contentsOf: ["-Y", "l"]) + } + + return args + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/SpotReadConfig.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/SpotReadConfig.swift new file mode 100644 index 0000000..2d815e2 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/SpotReadConfig.swift @@ -0,0 +1,37 @@ +import Foundation + +/// Configuration for a `spotread` invocation (issue #148). +/// +/// `spotread` writes no artefact; `workingDirectory` is still required +/// for the spawn (#59 — empty cwd is illegal). +public struct SpotReadConfig: Codable, Equatable, Sendable { + public var workingDirectory: URL? + /// Communication port for `spotread -c`. + /// `nil` means omit `-c` (Auto or port 1, #111). Never an array index. + public var selectedPort: Int? + /// Enable i1Pro 2 visual LEDs (`-Y l`, #204). + public var enableLEDs: Bool + /// Whether the selected instrument is an XY table — controls the + /// `q\n` + ~500 ms park before kill on cancel. + public var isXY: Bool + /// Display name stamped onto each `SpotReadSample`. + public var instrumentName: String + /// Instrument port stamped onto each sample (nil for Auto). + public var instrumentPort: Int? + + public init( + workingDirectory: URL? = nil, + selectedPort: Int? = nil, + enableLEDs: Bool = false, + isXY: Bool = false, + instrumentName: String = "", + instrumentPort: Int? = nil + ) { + self.workingDirectory = workingDirectory + self.selectedPort = selectedPort + self.enableLEDs = enableLEDs + self.isXY = isXY + self.instrumentName = instrumentName + self.instrumentPort = instrumentPort + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Gamut/ApproximateLab.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Gamut/ApproximateLab.swift new file mode 100644 index 0000000..9310082 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Gamut/ApproximateLab.swift @@ -0,0 +1,64 @@ +import Foundation + +/// Approximate sRGB → CIELab D50 conversion for the gamut inspect panel +/// (issue #147). +/// +/// This is a fixed-matrix helper, **not** a colour-management module: it +/// never touches ICC profiles, ColorSync, or lcms. The UI labels its +/// output "approx. Lab, not ColorSync". +public enum ApproximateLab { + + /// linear-sRGB → XYZ (D65) matrix, IEC 61966-2-1. + private static let srgbToXYZ: [[Double]] = [ + [0.4124, 0.3576, 0.1805], + [0.2126, 0.7152, 0.0722], + [0.0193, 0.1192, 0.9505], + ] + + /// 8-bit sRGB triple → Lab D50 (approximate). + public static func srgb8ToLab(r: Int, g: Int, b: Int) -> LabColor { + srgbToLab(DisplayRGB( + r: Double(r) / 255.0, + g: Double(g) / 255.0, + b: Double(b) / 255.0)) + } + + /// 0–1 sRGB triple → Lab D50 (approximate). + public static func srgbToLab(_ rgb: DisplayRGB) -> LabColor { + func linear(_ c: Double) -> Double { + c <= 0.04045 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4) + } + let v = [linear(rgb.r), linear(rgb.g), linear(rgb.b)] + // 0–1 XYZ D65 → the 0–100 scale `LabColorMath` works in. + let xyz65 = XYZColor( + x: (srgbToXYZ[0][0] * v[0] + srgbToXYZ[0][1] * v[1] + srgbToXYZ[0][2] * v[2]) * 100, + y: (srgbToXYZ[1][0] * v[0] + srgbToXYZ[1][1] * v[1] + srgbToXYZ[1][2] * v[2]) * 100, + z: (srgbToXYZ[2][0] * v[0] + srgbToXYZ[2][1] * v[1] + srgbToXYZ[2][2] * v[2]) * 100) + return LabColorMath.xyzToLab(adaptD65ToD50(xyz65)) + } + + /// Bradford D65 → D50 chromatic adaptation — the mirror of + /// `LabColorMath.adaptD50ToD65`. + private static func adaptD65ToD50(_ xyz: XYZColor) -> XYZColor { + let m = LabColorMath.bradford + let inv = LabColorMath.bradfordInv + let d65 = LabColorMath.d65White + let d50 = LabColorMath.d50White + let source = multiply(m, [xyz.x, xyz.y, xyz.z]) + let srcWhite = multiply(m, [d65.X, d65.Y, d65.Z]) + let dstWhite = multiply(m, [d50.X, d50.Y, d50.Z]) + let scaled = [ + source[0] * (dstWhite[0] / srcWhite[0]), + source[1] * (dstWhite[1] / srcWhite[1]), + source[2] * (dstWhite[2] / srcWhite[2]), + ] + return XYZColor( + x: inv[0][0] * scaled[0] + inv[0][1] * scaled[1] + inv[0][2] * scaled[2], + y: inv[1][0] * scaled[0] + inv[1][1] * scaled[1] + inv[1][2] * scaled[2], + z: inv[2][0] * scaled[0] + inv[2][1] * scaled[1] + inv[2][2] * scaled[2]) + } + + private static func multiply(_ m: [[Double]], _ v: [Double]) -> [Double] { + m.map { row in zip(row, v).reduce(0) { $0 + $1.0 * $1.1 } } + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Gamut/GamutContainment.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Gamut/GamutContainment.swift new file mode 100644 index 0000000..9202712 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Gamut/GamutContainment.swift @@ -0,0 +1,115 @@ +import Foundation +import simd + +/// Result of a point-in-gamut test (issue #147). +public enum GamutContainment: String, Sendable, Equatable { + /// The point lies inside the mesh volume. + case inside + /// The point lies outside the mesh volume. + case outside + /// The mesh has no faces to test against. + case unknown +} + +/// Point-in-mesh containment and volume estimation for ``GamutMesh``. +/// +/// Both tests run on the scene-space positions stored on +/// ``GamutVertex/position`` (`x = a*`, `y = L*`, `z = b*`), the same +/// mapping the SceneKit viewer uses. No colour management is involved. +public enum GamutGeometry { + + /// Whether `lab` is inside `mesh`. + /// + /// Ray-casts through the face list: an odd crossing count means the + /// point is inside a closed surface. A ray that grazes a vertex or + /// edge gives an ambiguous count, so the test retries with off-axis + /// directions before answering. Meshes without faces report + /// ``GamutContainment/unknown``. + public static func containment(of lab: LabColor, in mesh: GamutMesh) -> GamutContainment { + guard !mesh.faces.isEmpty else { return .unknown } + let origin = SIMD3(lab.a, lab.l, lab.b) + for direction in rayDirections { + if let inside = castRay(from: origin, direction: direction, mesh: mesh) { + return inside ? .inside : .outside + } + } + return .unknown + } + + /// Approximate mesh volume in Lab-cubic units. + /// + /// Sums signed tetrahedra from the vertex centroid to each face; for + /// a closed surface the magnitude equals the enclosed volume + /// regardless of face winding. Returns 0 for empty or face-less + /// meshes. + public static func volume(of mesh: GamutMesh) -> Double { + guard !mesh.faces.isEmpty, !mesh.vertices.isEmpty else { return 0 } + var centroid = SIMD3.zero + for vertex in mesh.vertices { + centroid += SIMD3(vertex.position) + } + centroid /= Double(mesh.vertices.count) + + var sum = 0.0 + let count = mesh.vertices.count + for face in mesh.faces { + guard Int(face.a) < count, Int(face.b) < count, Int(face.c) < count else { + continue + } + let a = SIMD3(mesh.vertices[Int(face.a)].position) - centroid + let b = SIMD3(mesh.vertices[Int(face.b)].position) - centroid + let c = SIMD3(mesh.vertices[Int(face.c)].position) - centroid + sum += simd_dot(a, simd_cross(b, c)) / 6.0 + } + return abs(sum) + } + + // MARK: - Ray casting + + /// Primary +X ray, then off-axis retries for degenerate edge hits. + private static let rayDirections: [SIMD3] = [ + SIMD3(1, 0, 0), + simd_normalize(SIMD3(0.71, 1.0, 0.53)), + simd_normalize(SIMD3(0.53, 0.71, 1.0)), + ] + + /// Möller–Trumbore crossing count. Returns `nil` when a crossing + /// lands on a triangle edge or vertex (ambiguous parity) so the + /// caller can retry with a different direction. + private static func castRay( + from origin: SIMD3, + direction dir: SIMD3, + mesh: GamutMesh + ) -> Bool? { + let epsilon = 1e-9 + var crossings = 0 + let count = mesh.vertices.count + for face in mesh.faces { + guard Int(face.a) < count, Int(face.b) < count, Int(face.c) < count else { + continue + } + let va = SIMD3(mesh.vertices[Int(face.a)].position) + let vb = SIMD3(mesh.vertices[Int(face.b)].position) + let vc = SIMD3(mesh.vertices[Int(face.c)].position) + + let e1 = vb - va + let e2 = vc - va + let p = simd_cross(dir, e2) + let det = simd_dot(e1, p) + if abs(det) < 1e-12 { continue } // ray parallel to face + let inv = 1.0 / det + let tvec = origin - va + let u = simd_dot(tvec, p) * inv + let q = simd_cross(tvec, e1) + let v = simd_dot(dir, q) * inv + let t = simd_dot(e2, q) * inv + + guard t > epsilon else { continue } + if u < -epsilon || v < -epsilon || u + v > 1 + epsilon { continue } + // Crossing on an edge or vertex — parity is ambiguous. + if u < epsilon || v < epsilon || u + v > 1 - epsilon { return nil } + crossings += 1 + } + return crossings % 2 == 1 + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Gamut/NamedGamut.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Gamut/NamedGamut.swift new file mode 100644 index 0000000..57f3a79 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Gamut/NamedGamut.swift @@ -0,0 +1,39 @@ +import Foundation + +/// A parsed gamut mesh plus the display metadata the compare viewer +/// needs (issue #147). +/// +/// `displayName` is user-derived (a file name); views must render it +/// through `Text` only (#114). +public struct NamedGamut: Sendable, Equatable, Identifiable { + + /// What the layer is used for in the compare UI. + public enum Role: String, Sendable, Equatable { + /// Bundled reference space (sRGB). Cannot be removed, only hidden. + case reference + /// The workflow's own profile gamut. + case profileA + /// The user-added compare gamut. Replaced, never stacked. + case profileB + } + + public var id: String + public var displayName: String + public var role: Role + public var mesh: GamutMesh + public var sourceURL: URL + + public init( + id: String, + displayName: String, + role: Role, + mesh: GamutMesh, + sourceURL: URL + ) { + self.id = id + self.displayName = displayName + self.role = role + self.mesh = mesh + self.sourceURL = sourceURL + } +} 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/MediaLibraryStore.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Library/MediaLibraryStore.swift new file mode 100644 index 0000000..d84165c --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Library/MediaLibraryStore.swift @@ -0,0 +1,117 @@ +import Foundation + +/// Persistence for `MediaRecipe` entries (issue #146). +/// +/// `media_library.json` is a sibling of `settings.json`, never a field +/// inside it. Writes are atomic via `JSONFileStore` → `AtomicFileWriter` +/// (`.tmp` + rename, #213). A corrupt file throws on load/upsert and is +/// never overwritten — the view model turns the throw into an empty +/// list plus a persistent warning banner. +public actor MediaLibraryStore { + + /// Default cap. + public static let defaultCapacity = 200 + + /// Path to the JSON store. + public let url: URL + + /// In-memory cache, kept in sync with disk. + private var recipes: [MediaRecipe] = [] + + /// Explicit load flag — an empty file is still "loaded". + private var loaded = false + + private let capacity: Int + private let fileStore: JSONFileStore<[MediaRecipe]> + + public init( + url: URL = AppPaths.appDataDir.appendingPathComponent("media_library.json"), + capacity: Int = defaultCapacity + ) { + self.url = url + self.capacity = capacity + self.fileStore = JSONFileStore( + fileURL: url, + corrupt: .throwCorrupt, + defaultValue: { [] }, + dateEncoding: .iso8601, + dateDecoding: .iso8601 + ) + } + + /// Loads recipes 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 and `loaded` stays false + /// so the next call re-reads. + public func load() throws -> [MediaRecipe] { + guard !loaded else { return recipes } + guard FileManager.default.fileExists(atPath: url.path) else { + loaded = true + return [] + } + recipes = try fileStore.load() + loaded = true + return recipes + } + + /// Returns all cached recipes. + public func all() -> [MediaRecipe] { + recipes + } + + /// Inserts or replaces a recipe matched by `id`, then writes + /// atomically. Replacement preserves `created` and bumps `updated`; + /// inserts beyond `capacity` throw `.capacityReached` — no silent + /// eviction. + /// + /// Loads the existing library first and propagates any load error + /// so an unparseable file is never overwritten. + @discardableResult + public func upsert(_ recipe: MediaRecipe) throws -> [MediaRecipe] { + let validated = try recipe.validated() + try load() + + var updated = recipes + if let index = updated.firstIndex(where: { $0.id == validated.id }) { + var existing = validated + existing.created = updated[index].created + existing.updated = Date() + updated[index] = existing + } else { + guard updated.count < capacity else { + throw MediaLibraryError.capacityReached(capacity) + } + updated.append(validated) + } + + try fileStore.save(updated) + recipes = updated + return updated + } + + /// Removes a recipe by id and writes atomically. Returns false when + /// no recipe with that id exists. + @discardableResult + public func delete(id: String) throws -> Bool { + try load() + let before = recipes.count + let updated = recipes.filter { $0.id != id } + guard updated.count != before else { return false } + try fileStore.save(updated) + recipes = updated + return true + } + + public enum MediaLibraryError: LocalizedError, Equatable { + case capacityReached(Int) + + public var errorDescription: String? { + switch self { + case .capacityReached(let cap): + return "Media library is full (\(cap)). Delete a recipe first." + } + } + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Library/MediaRecipe.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Library/MediaRecipe.swift new file mode 100644 index 0000000..bd29a40 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Library/MediaRecipe.swift @@ -0,0 +1,146 @@ +import Foundation + +/// A media recipe: a named binding of CUPS queue + paper + ink set + +/// optional `.cal` to a `ProfilingPreset` (issue #146, docs/22 §Media +/// library). +/// +/// snake_case keys match the v1 JSON schema so `media_library.json` +/// stays import/export compatible. Identity + binding fields are +/// required; every other field is optional-defaulted. Unknown keys are +/// ignored on decode; missing required fields fail the whole array +/// decode (corrupt-file policy, never silently dropped). +public struct MediaRecipe: Codable, Equatable, Sendable, Identifiable { + + /// `"recipe-"`, never user-typed. + public var id: String + public var name: String + public var notes: String + /// CUPS queue id (`Printer.name` — `Printer` has no `id` member). + public var printerID: String + /// Human label from `Printer.displayName`. + public var printerDisplayName: String + /// Library metadata only — never written to targen `-P`/`-I` flags. + public var paperName: String + /// Last captured CUPS `media_type`, read-only. + public var driverMediaType: String? + /// Free text: `"PK"`, `"MK"`, `"Photo Black"`, … + public var inkSet: String + /// `"rgb"` | `"cmyk"` — must match the bound preset. + public var colourSpace: String + /// `ProfilingPreset.id` (built-in or custom). + public var presetID: String + /// Absolute `.cal` path stored verbatim; `nil` = none. + public var calibrationURL: String? + public var applyCalibration: Bool + public var created: Date + public var updated: Date + + public init( + id: String, + name: String, + notes: String = "", + printerID: String, + printerDisplayName: String = "", + paperName: String = "", + driverMediaType: String? = nil, + inkSet: String = "", + colourSpace: String, + presetID: String, + calibrationURL: String? = nil, + applyCalibration: Bool = false, + created: Date = Date(), + updated: Date = Date() + ) { + self.id = id + self.name = name + self.notes = notes + self.printerID = printerID + self.printerDisplayName = printerDisplayName + self.paperName = paperName + self.driverMediaType = driverMediaType + self.inkSet = inkSet + self.colourSpace = colourSpace + self.presetID = presetID + self.calibrationURL = calibrationURL + self.applyCalibration = applyCalibration + self.created = created + self.updated = updated + } + + enum CodingKeys: String, CodingKey { + case id, name, notes + case printerID = "printer_id" + case printerDisplayName = "printer_display_name" + case paperName = "paper_name" + case driverMediaType = "driver_media_type" + case inkSet = "ink_set" + case colourSpace = "colour_space" + case presetID = "preset_id" + case calibrationURL = "calibration_url" + case applyCalibration = "apply_calibration" + case created, updated + } + + /// Strict decode: required identity + binding fields must be + /// present; optionals default. Unknown keys are ignored. + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + name = try c.decode(String.self, forKey: .name) + notes = try c.decodeIfPresent(String.self, forKey: .notes) ?? "" + printerID = try c.decode(String.self, forKey: .printerID) + printerDisplayName = try c.decodeIfPresent(String.self, forKey: .printerDisplayName) ?? "" + paperName = try c.decodeIfPresent(String.self, forKey: .paperName) ?? "" + driverMediaType = try c.decodeIfPresent(String.self, forKey: .driverMediaType) + inkSet = try c.decodeIfPresent(String.self, forKey: .inkSet) ?? "" + colourSpace = try c.decode(String.self, forKey: .colourSpace) + presetID = try c.decode(String.self, forKey: .presetID) + calibrationURL = try c.decodeIfPresent(String.self, forKey: .calibrationURL) + applyCalibration = try c.decodeIfPresent(Bool.self, forKey: .applyCalibration) ?? false + created = try c.decodeIfPresent(Date.self, forKey: .created) ?? Date() + updated = try c.decodeIfPresent(Date.self, forKey: .updated) ?? Date() + } + + public enum ValidationError: LocalizedError, Equatable { + case emptyName + case emptyPrinterID + case invalidColourSpace(String) + case emptyPresetID + case invalidCalibrationURL(String) + + public var errorDescription: String? { + switch self { + case .emptyName: return "Media recipe is missing a name." + case .emptyPrinterID: return "Media recipe is missing a printer." + case .invalidColourSpace(let v): + return "colour_space must be \"rgb\" or \"cmyk\", got \"\(v)\"." + case .emptyPresetID: return "Media recipe is missing a preset." + case .invalidCalibrationURL(let v): + return "calibration_url must be an absolute path without \"..\" or NUL, got \"\(v)\"." + } + } + } + + /// Validates the binding fields. `colourSpace` is normalized to + /// lowercase before comparison. `CAL_` cal names are **not** + /// rejected — that is an apply-time policy, not schema. + @discardableResult + public func validated() throws -> MediaRecipe { + var r = self + r.id = id.trimmingCharacters(in: .whitespacesAndNewlines) + r.name = name.trimmingCharacters(in: .whitespacesAndNewlines) + r.colourSpace = colourSpace.lowercased() + guard !r.name.isEmpty else { throw ValidationError.emptyName } + guard !r.printerID.isEmpty else { throw ValidationError.emptyPrinterID } + guard r.colourSpace == "rgb" || r.colourSpace == "cmyk" else { + throw ValidationError.invalidColourSpace(colourSpace) + } + guard !r.presetID.isEmpty else { throw ValidationError.emptyPresetID } + if let cal = r.calibrationURL, !cal.isEmpty { + guard cal.hasPrefix("/"), !cal.contains(".."), !cal.contains("\0") else { + throw ValidationError.invalidCalibrationURL(cal) + } + } + return r + } +} 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/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/SpotReadClassifier.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/SpotReadClassifier.swift new file mode 100644 index 0000000..8a99e02 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/SpotReadClassifier.swift @@ -0,0 +1,48 @@ +import Foundation + +/// Line classifier for `spotread` stdout (issue #148). +/// +/// `spotread` shares `chartread`'s white-tile calibration phrasing, so +/// this wraps `ChartreadClassifier` and only intercepts the lines that +/// would otherwise misclassify: +/// +/// - `… and then hit any key to continue,` / `or hit Esc or Q to abort:` +/// continuation lines that trail the calibration and spot prompts — +/// sticky to the current prompt state instead of `PROMPT_CONTINUE`. +/// - `Place instrument on a spot to be measured,` / +/// `and hit a key to take a reading,` → `AWAITING_STRIP` (the Read +/// prompt; the generic chartread matcher does not know "take a +/// reading"). +/// +/// Sample lines (`Result is XYZ: …, D50 Lab: …`) are parsed by +/// `SpotReadParser`, not classified here. +public enum SpotReadClassifier { + + public static func classify( + line: String, + previousState: ChartreadState + ) -> ChartreadClassifyResult { + let text = line.lowercased() + + // Spot-read prompt continuations keep the current prompt state. + if previousState == .calibrating || previousState == .awaitingStrip { + if text.contains("hit any key") + || text.contains("hit space") + || text.contains("esc or") + || text.contains("abort") + || text.contains("to abort") { + return ChartreadClassifyResult(state: previousState) + } + } + + // "Place instrument on a spot to be measured," / + // " and hit a key to take a reading," — the Read trigger prompt. + if text.contains("spot to be measured") + || text.contains("take a reading") + || text.contains("measure the spot") { + return ChartreadClassifyResult(state: .awaitingStrip) + } + + return ChartreadClassifier.classify(line: line, previousState: previousState) + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/SpotReadSample.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/SpotReadSample.swift new file mode 100644 index 0000000..836221c --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/SpotReadSample.swift @@ -0,0 +1,80 @@ +import Foundation + +/// One patch measurement emitted by a running `spotread` child (#148). +public struct SpotReadSample: Codable, Sendable, Equatable, Identifiable { + public var id: UUID + public var timestamp: Date + /// D50 Lab (always present — derived from XYZ when needed). + public var lab: LabColor + /// XYZ on the 0–100 scale used by the fork, when the line carried it. + public var xyz: XYZColor? + public var instrumentName: String + public var port: Int? + /// Diagnostics only — never rendered as HTML or shown in the table. + public var rawLine: String + + public init( + id: UUID = UUID(), + timestamp: Date = Date(), + lab: LabColor, + xyz: XYZColor? = nil, + instrumentName: String = "", + port: Int? = nil, + rawLine: String = "" + ) { + self.id = id + self.timestamp = timestamp + self.lab = lab + self.xyz = xyz + self.instrumentName = instrumentName + self.port = port + self.rawLine = rawLine + } +} + +/// Parses `spotread` result lines into Lab / XYZ triples. +/// +/// The fork's line shape is the upstream +/// `Result is XYZ: , D50 Lab: `; a Lab-only line +/// also parses, and an XYZ-only line derives Lab via +/// `LabColorMath.xyzToLab` (D50). +public enum SpotReadParser { + + public static func parse(line: String) -> (xyz: XYZColor?, lab: LabColor)? { + guard line.range(of: "result is", options: .caseInsensitive) != nil + || line.range(of: #"\bLab\b"#, options: .regularExpression) != nil + || line.range(of: #"\bXYZ\b"#, options: .regularExpression) != nil + else { return nil } + + var xyz: XYZColor? + var lab: LabColor? + + if let m = triple(#"\bXYZ\b[:\s]"#, in: line) { + xyz = XYZColor(x: m.0, y: m.1, z: m.2) + } + if let m = triple(#"\bLab\b[:\s]"#, in: line) { + lab = LabColor(l: m.0, a: m.1, b: m.2) + } + if lab == nil, let xyz { + lab = LabColorMath.xyzToLab(xyz) + } + guard let lab else { return nil } + return (xyz, lab) + } + + private static func triple(_ marker: String, in line: String) -> (Double, Double, Double)? { + let pattern = marker + #"\s*(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)"# + guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive), + let match = regex.firstMatch( + in: line, options: [], range: NSRange(line.startIndex..., in: line)), + match.numberOfRanges == 4, + let r1 = Range(match.range(at: 1), in: line), + let r2 = Range(match.range(at: 2), in: line), + let r3 = Range(match.range(at: 3), in: line), + let a = Double(line[r1]), + let b = Double(line[r2]), + let c = Double(line[r3]) + else { return nil } + return (a, b, c) + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessID.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessID.swift index 80d6242..8c2d562 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessID.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessID.swift @@ -4,6 +4,8 @@ import Foundation /// filter events on `id` — historical bug #56 was an id mismatch. public enum ProcessID { public static let instlist = "instlist" + /// Spot-read console (issue #148) — single lease, like `instlist`. + public static let spotread = "spotread" public static func targen(_ basename: String) -> String { "targen_\(basename)" } public static func printtarg(_ basename: String) -> String { "printtarg_\(basename)" } diff --git a/README.md b/README.md index 59df0be..61ffb5e 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,13 @@ All measurement, chart generation, and profile mathematics live in the [Gronod A |---|---| | Product | ICCery v2 for macOS | | Bundle | `com.gronod.iccery2` | -| Floor | macOS 14 Sonoma, universal `arm64` + `x86_64` | +| Floor | macOS 12.0 Monterey, universal `arm64` + `x86_64` | | Default branch | `develop` | | M6 | Stage 0 calibration, CGATS import, SceneKit gamut viewer, packaging — shipped on `develop` | | M7 | Pre-UAT hardening & baseline consolidation — shipped on `develop` | -| M8 | Deduplication/consolidation contracts & UAT-ready hardening (#79–#86) — in flight on `milestone/m8-consolidation` | +| M8 | Deduplication/consolidation contracts & UAT-ready hardening (#79–#86) — shipped on `develop` | +| M9 | macOS 12 / Xcode 14.2 retarget — shipped on `develop` (PR #145) | +| M10 | Studio workflow (#146–#149) — in flight on `milestone/m10-studio` | | Licence | Proprietary source in [`LICENCE.md`](LICENCE.md); bundled Argyll sidecars remain AGPLv3 | ## What it does @@ -176,11 +178,11 @@ Agent / branch rules: [`AGENTS.md`](AGENTS.md), [`BUILD-PLAN.md`](BUILD-PLAN.md) ``` develop - └── milestone/m8-consolidation # integration branch + └── milestone/m10-studio # M10 integration branch └── feat/- # one issue per branch ``` -Feature PRs target the current milestone branch, not `develop`. The milestone branch merges to `develop` when its issues are green. Completion PRs for issues #79–#86 target `milestone/m8-consolidation`; `milestone/m8-consolidation` merges into `develop` once all milestone gates pass. Do not open umbrella "bugfix" branches that mix tickets. +Feature PRs target the current milestone branch, not `develop`. The milestone branch merges to `develop` when its issues are green. Completion PRs for issues #146–#149 target `milestone/m10-studio`; `milestone/m10-studio` merges into `develop` once all milestone gates pass. Do not open umbrella "bugfix" branches that mix tickets. ## Licence diff --git a/Sources/ICCery/AppEnvironment.swift b/Sources/ICCery/AppEnvironment.swift index f32dfef..88d0bd5 100644 --- a/Sources/ICCery/AppEnvironment.swift +++ b/Sources/ICCery/AppEnvironment.swift @@ -13,6 +13,8 @@ struct AppEnvironment: Sendable { let runner: ArgyllRunner let cupsService: CupsService let historyStore: VerificationHistoryStore + let mediaStore: MediaLibraryStore + let recentProjectsStore: RecentProjectsStore static func live( environment: [String: String] = ProcessInfo.processInfo.environment @@ -20,11 +22,15 @@ struct AppEnvironment: Sendable { let settingsStore = SettingsStore() var overrideDir = settingsStore.load().argyllBinaryDir .map { URL(fileURLWithPath: $0) } + var bundledRoot = AppPaths.bundledArgyllDir var cupsDir = URL(fileURLWithPath: "/usr/bin") #if DEBUG if let dir = environment["ICCERY_ARGYLL_BINARY_DIR"], !dir.isEmpty { overrideDir = URL(fileURLWithPath: dir) } + if let dir = environment["ICCERY_ARGYLL_BUNDLED_ROOT"], !dir.isEmpty { + bundledRoot = URL(fileURLWithPath: dir) + } if let dir = environment["ICCERY_CUPS_BIN_DIR"], !dir.isEmpty { cupsDir = URL(fileURLWithPath: dir) } @@ -35,12 +41,15 @@ struct AppEnvironment: Sendable { presetStore: PresetStore(settingsStore: settingsStore), runner: ArgyllRunner( processManager: .shared, - binaryResolver: BinaryResolver(overrideDir: overrideDir) + binaryResolver: BinaryResolver( + bundledRoot: bundledRoot, overrideDir: overrideDir) ), cupsService: CupsService( processManager: .shared, binaryDir: cupsDir), - historyStore: VerificationHistoryStore() + historyStore: VerificationHistoryStore(), + mediaStore: MediaLibraryStore(), + recentProjectsStore: RecentProjectsStore() ) } } @@ -74,6 +83,26 @@ enum UITestHooks { static var presetImportURL: URL? { url("ICCERY_TEST_PRESET_IMPORT") } /// Preset export destination. static var presetExportURL: URL? { url("ICCERY_TEST_PRESET_EXPORT") } + /// Spot-read CSV export destination (`selectCsvSavePath`, #148). + static var csvExportURL: URL? { url("ICCERY_TEST_CSV_EXPORT") } + /// `.gam` compare picker result (gamut sheet, #147). Unset → cancel. + static var gamutFileURL: URL? { url("ICCERY_TEST_GAMUT_FILE") } + /// `.icc/.icm` compare picker result (gamut sheet, #147). Unset → cancel. + 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") } + /// Forces the gamut sheet into its no-Metal fallback even on a GPU + /// host (#147). Set per-test only — never in a default launch env, + /// or CI's future GPU run would skip SceneKit too. + static var skipSceneKit: Bool { + isEnabled && env["ICCERY_TEST_SKIP_SCENEKIT"] == "1" + } // MARK: - Print panel / CUPS stubs (issue 13/17) diff --git a/Sources/ICCery/FileDialogService.swift b/Sources/ICCery/FileDialogService.swift index 5852e93..7b30144 100644 --- a/Sources/ICCery/FileDialogService.swift +++ b/Sources/ICCery/FileDialogService.swift @@ -70,6 +70,19 @@ final class FileDialogService { message: "Choose a calibration file (.cal)") } + /// `selectGamutFile` — `.gam` surface mesh for the compare slot (#147). + func selectGamutFile(startingAt start: URL? = nil) -> URL? { + open(extensions: ["gam"], startingAt: start, + message: "Choose a .gam surface mesh", + allowsOtherFileTypes: false) + } + + /// `selectTiffFile` — `.tif`/`.tiff` target page for gamut sampling (#147). + func selectTiffFile(startingAt start: URL? = nil) -> URL? { + open(extensions: ["tif", "tiff"], startingAt: start, + message: "Choose a target TIFF page") + } + /// `btnImportPreset` — open a `.json` preset file. func selectPresetFile(startingAt start: URL? = nil) -> URL? { open(extensions: ["json"], startingAt: start, @@ -82,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( @@ -102,16 +132,19 @@ final class FileDialogService { private func open( extensions: [String], startingAt start: URL?, - message: String? + message: String?, + title: String? = nil, + allowsOtherFileTypes: Bool = true ) -> URL? { let panel = NSOpenPanel() panel.canChooseDirectories = false panel.canChooseFiles = true panel.allowsMultipleSelection = false panel.allowedContentTypes = utTypes(extensions) - panel.allowsOtherFileTypes = true + 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/GamutView.swift b/Sources/ICCery/GamutView.swift index aa3debb..63bfdcf 100644 --- a/Sources/ICCery/GamutView.swift +++ b/Sources/ICCery/GamutView.swift @@ -1,5 +1,6 @@ import SwiftUI import SceneKit +import Metal import ICCeryCore import simd @@ -62,72 +63,322 @@ internal struct GamutSceneGeometryBuilder { } } -/// Native SceneKit 3D gamut viewer. +/// Native SceneKit 3D gamut viewer (issues #28, #147). /// -/// Displays a profile gamut mesh and the bundled `sRGB.gam` reference. Uses -/// the CIELAB coordinate convention `x = a*`, `y = L*`, `z = b*` so that the -/// a* (green-red) axis is horizontal, L* (lightness) is vertical, and b* -/// (blue-yellow) is depth. +/// Displays the bundled `sRGB.gam` reference plus up to two profile +/// meshes with independent visibility toggles, a status line, and an +/// inspect panel (click a mesh, type a Lab value, or sample a TIFF +/// pixel). Uses the CIELAB coordinate convention `x = a*`, `y = L*`, +/// `z = b*`. struct GamutView: View { @StateObject private var viewModel: GamutViewModel @State private var pause: () -> Void = {} @FocusState private var isFocused: Bool + @Environment(\.dismiss) private var dismiss + @Binding var showingAllHelp: Bool - init(profileGamURL: URL? = nil) { - _viewModel = StateObject(wrappedValue: GamutViewModel(profileGamURL: profileGamURL)) + init( + environment: AppEnvironment, + profileGamURL: URL? = nil, + showingAllHelp: Binding + ) { + _viewModel = StateObject(wrappedValue: GamutViewModel( + environment: environment, profileGamURL: profileGamURL)) + _showingAllHelp = showingAllHelp } var body: some View { - ZStack { - GamutSceneView( - profileMesh: viewModel.profileMesh, - referenceMesh: viewModel.sRGBMesh, - onReset: $viewModel.resetCamera, - onPause: $pause - ) - .focusable() - .focused($isFocused) - .onAppear { isFocused = true } - - VStack { - HStack { - Spacer() - Button(action: { viewModel.resetCamera() }) { - Text("Reset view") - } - .accessibilityIdentifier("btnResetGamutCamera") - .padding(8) - } - Spacer() - HStack { - Text(viewModel.status) - .font(.caption) - .padding(8) - .background(.thinMaterial) - .cornerRadius(6) - .accessibilityIdentifier("gamutStatusText") - Spacer() - } - .padding(8) - } + VStack(spacing: 0) { + toolbar + Divider().overlay(Theme.border) + sceneArea + Divider().overlay(Theme.border) + statusLine + inspectPanel + Divider().overlay(Theme.border) + footer } - .frame(minWidth: 500, minHeight: 400) + .frame(minWidth: 720, minHeight: 520) + .background(Theme.background) .onDisappear { pause() } .accessibilityElement(children: .contain) .accessibilityIdentifier("gamutView") + .sheet(isPresented: $viewModel.showingTiffPreview) { + tiffPreviewSheet + } + } + + // MARK: - Toolbar + + private var toolbar: some View { + HStack(spacing: 12) { + layerToggle(id: GamutViewModel.srgbLayerID, fallback: "sRGB") + layerToggle(id: GamutViewModel.profileLayerID, fallback: "Profile") + layerToggle(id: GamutViewModel.compareLayerID, fallback: "Compare") + Spacer() + addCompareMenu + Button("Remove compare") { viewModel.removeCompare() } + .disabled(viewModel.layer(id: GamutViewModel.compareLayerID) == nil) + .accessibilityIdentifier("btnGamutRemoveCompare") + Button("Sample TIFF…") { viewModel.openTiffSample() } + .accessibilityIdentifier("btnGamutSampleTiff") + .helpOverlay( + "Sample a colour from a target TIFF page.", + showing: $showingAllHelp) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + + private func layerToggle(id: String, fallback: String) -> some View { + let layer = viewModel.layer(id: id) + return Toggle(isOn: Binding( + get: { layer != nil && viewModel.visibleIDs.contains(id) }, + set: { on in + if on { + viewModel.visibleIDs.insert(id) + } else { + viewModel.visibleIDs.remove(id) + } + } + )) { + Text(layer?.displayName ?? fallback) + } + .toggleStyle(.checkbox) + .disabled(layer == nil) + .help(layer.map { $0.sourceURL.lastPathComponent } ?? "No profile .gam loaded") + // macOS 12 puts the identifier on the Toggle's container, an + // element that never reports isEnabled — combine so the a11y + // leaf is the checkbox itself. + .accessibilityElement(children: .combine) + .accessibilityIdentifier("gamutLayer-\(id)") + } + + private var addCompareMenu: some View { + Menu("Add compare…") { + Button("Open .gam…") { viewModel.openCompareGam() } + .accessibilityIdentifier("btnGamutOpenGam") + Button("Open profile…") { viewModel.openCompareProfile() } + .accessibilityIdentifier("btnGamutOpenProfile") + } + .accessibilityIdentifier("btnGamutAddCompare") + .helpOverlay( + "Add a second profile or .gam mesh to compare against.", + showing: $showingAllHelp) + } + + // MARK: - Scene + + private var sceneArea: some View { + ZStack(alignment: .topTrailing) { + if viewModel.viewerUnavailable { + Text("3D gamut viewer is unavailable on this Mac; the rest of ICCery still works.") + .font(.callout) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .accessibilityIdentifier("gamutViewerUnavailable") + } else { + GamutSceneView( + layers: viewModel.layers, + visibleIDs: viewModel.visibleIDs, + onReset: $viewModel.resetCamera, + onPause: $pause, + onUnavailable: { viewModel.viewerUnavailable = true }, + onInspect: { point, layerID in + if let layerID { + viewModel.inspectSceneHit(world: point, layerID: layerID) + } else { + viewModel.clearInspect() + } + } + ) + .focusable() + .focused($isFocused) + .onAppear { isFocused = true } + } + Button(action: { viewModel.resetCamera() }) { + Text("Reset view") + } + .accessibilityIdentifier("btnResetGamutCamera") + .padding(8) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Status + + private var statusLine: some View { + HStack(spacing: 10) { + Text(viewModel.status) + .font(.caption) + .foregroundStyle(Theme.text) + .accessibilityIdentifier("gamutStatusText") + if let notice = viewModel.noticeText { + Text(notice) + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityIdentifier("gamutNoticeText") + } + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + } + + // MARK: - Inspect panel + + private var inspectPanel: some View { + HStack(spacing: 12) { + if let lab = viewModel.inspectLab { + inspectSwatch + labReadout(lab) + containmentColumn + if viewModel.inspectIsApproximate { + Text("approx. Lab, not ColorSync") + .font(.caption2) + .foregroundStyle(.secondary) + .accessibilityIdentifier("gamutInspectApprox") + } + } else { + Text("Click the mesh, or enter Lab, to inspect.") + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityIdentifier("gamutInspectIdle") + } + Spacer() + labEntryFields + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(minHeight: 56) + .background(Theme.panel) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("gamutInspectPanel") + .helpOverlay( + "Inspect a Lab point against each loaded gamut.", + showing: $showingAllHelp) + } + + @ViewBuilder + private var inspectSwatch: some View { + if let swatch = viewModel.inspectSwatch { + Color(red: swatch.r, green: swatch.g, blue: swatch.b) + .frame(width: 16, height: 16) + .clipShape(RoundedRectangle(cornerRadius: 2)) + .overlay(RoundedRectangle(cornerRadius: 2).stroke(Theme.border)) + .accessibilityIdentifier("gamutInspectSwatch") + } + } + + private func labReadout(_ lab: LabColor) -> some View { + HStack(spacing: 10) { + Text(String(format: "L %.1f", lab.l)) + .accessibilityIdentifier("gamutInspectL") + Text(String(format: "a %.1f", lab.a)) + .accessibilityIdentifier("gamutInspectA") + Text(String(format: "b %.1f", lab.b)) + .accessibilityIdentifier("gamutInspectB") + } + .font(.caption.monospacedDigit()) + .foregroundStyle(Theme.text) + } + + private var containmentColumn: some View { + HStack(spacing: 10) { + ForEach(viewModel.inspectResults, id: \.id) { result in + Text("\(result.name) \(containmentWord(result.containment))") + .font(.caption) + .foregroundStyle(Theme.text) + .accessibilityIdentifier("gamutInspect-\(result.id)") + } + } + } + + private func containmentWord(_ containment: GamutContainment) -> String { + switch containment { + case .inside: return "in" + case .outside: return "out" + case .unknown: return "?" + } + } + + private var labEntryFields: some View { + HStack(spacing: 6) { + Text("Lab 0–100 · ±128") + .font(.caption2) + .foregroundStyle(.secondary) + TextField("L", text: $viewModel.labEntryL) + .textFieldStyle(.roundedBorder) + .frame(width: 56) + .accessibilityIdentifier("gamutLabEntryL") + TextField("a", text: $viewModel.labEntryA) + .textFieldStyle(.roundedBorder) + .frame(width: 56) + .accessibilityIdentifier("gamutLabEntryA") + TextField("b", text: $viewModel.labEntryB) + .textFieldStyle(.roundedBorder) + .frame(width: 56) + .accessibilityIdentifier("gamutLabEntryB") + Button("Inspect") { viewModel.inspectEnteredLab() } + .disabled(!viewModel.canInspectLab) + .accessibilityIdentifier("btnGamutInspectLab") + } + } + + // MARK: - Footer + + /// Always-visible Close (#147) — the fallback banner keeps it + /// reachable and Escape works via `.cancelAction` without SceneKit. + private var footer: some View { + HStack { + Spacer() + Button("Close") { dismiss() } + .keyboardShortcut(.cancelAction) + .accessibilityIdentifier("btnCloseGamut") + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + + // MARK: - TIFF sample sheet + + private var tiffPreviewSheet: some View { + VStack(spacing: 12) { + Text("Click a pixel to sample its colour.") + .font(.headline) + .foregroundStyle(Theme.text) + if let png = viewModel.tiffPreviewPNG { + TiffSampleImageView(pngData: png) { r, g, b in + viewModel.sampleTiffPixel(r: r, g: g, b: b) + } + .frame(minWidth: 320, minHeight: 240) + } + HStack { + Spacer() + Button("Cancel") { viewModel.showingTiffPreview = false } + .accessibilityIdentifier("btnCloseGamutTiffPreview") + } + } + .padding(16) + .background(Theme.background) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("gamutTiffPreview") } } -/// `NSViewRepresentable` wrapper around an `SCNView` that builds the scene from -/// one or two ``GamutMesh`` values. +/// `NSViewRepresentable` wrapper around an `SCNView` rendering one node +/// per ``NamedGamut`` layer. /// -/// Scene construction and camera reset are coordinated through a typed callback -/// binding owned by the view model. +/// Layer toggles hide/show `SCNNode`s — the scene is built once and the +/// camera is only reset through the explicit reset path, never on a +/// mesh or visibility update. private struct GamutSceneView: NSViewRepresentable { - var profileMesh: GamutMesh? - var referenceMesh: GamutMesh? + var layers: [NamedGamut] + var visibleIDs: Set var onReset: Binding<() -> Void> var onPause: Binding<() -> Void> + var onUnavailable: () -> Void + var onInspect: (SIMD3, String?) -> Void func makeNSView(context: Context) -> SCNView { let scnView = SCNView() @@ -142,14 +393,25 @@ private struct GamutSceneView: NSViewRepresentable { context.coordinator.scnView = scnView context.coordinator.scene = scene - context.coordinator.buildScene(profile: profileMesh, reference: referenceMesh) + context.coordinator.onInspect = onInspect + context.coordinator.buildSceneOnce() + context.coordinator.syncLayers(layers, visibleIDs: visibleIDs) context.coordinator.installKeyMonitor() + context.coordinator.installClickGesture() + + // Safety net only — the primary no-Metal check is + // `GamutSceneAvailability.isAvailable`, evaluated before this + // view is mounted. Never respawn the view in a loop. + if MTLCreateSystemDefaultDevice() == nil { + DispatchQueue.main.async { onUnavailable() } + } return scnView } func updateNSView(_ nsView: SCNView, context: Context) { - context.coordinator.buildScene(profile: profileMesh, reference: referenceMesh) + context.coordinator.onInspect = onInspect + context.coordinator.syncLayers(layers, visibleIDs: visibleIDs) } func makeCoordinator() -> Coordinator { @@ -165,6 +427,9 @@ private struct GamutSceneView: NSViewRepresentable { static func dismantleNSView(_ nsView: SCNView, coordinator: Coordinator) { coordinator.removeKeyMonitor() + if let click = coordinator.clickGesture { + nsView.removeGestureRecognizer(click) + } nsView.isPlaying = false } @@ -172,11 +437,14 @@ private struct GamutSceneView: NSViewRepresentable { final class Coordinator: NSObject { weak var scnView: SCNView? weak var scene: SCNScene? + var onInspect: (SIMD3, String?) -> Void = { _, _ in } + private(set) var clickGesture: NSClickGestureRecognizer? private var keyMonitor: Any? - private let profileNode = SCNNode() - private let referenceGroup = SCNNode() + /// One `SCNNode` per loaded layer, keyed by `NamedGamut.id`. + private var layerNodes: [String: SCNNode] = [:] private let axisNode = SCNNode() + private let layerGroup = SCNNode() private let cameraNode: SCNNode = { let node = SCNNode() node.camera = SCNCamera() @@ -184,33 +452,97 @@ private struct GamutSceneView: NSViewRepresentable { return node }() - func buildScene(profile: GamutMesh?, reference: GamutMesh?) { - guard let scene else { return } + /// Builds the static scene furniture exactly once — axis + /// scaffold, lights, camera home. Layer content lives under + /// `layerGroup` and is managed by `syncLayers`. + func buildSceneOnce() { + guard let scene, scene.rootNode.childNodes.isEmpty else { return } - // Rebuild from scratch on every mesh change to avoid stale geometry. - scene.rootNode.childNodes.forEach { $0.removeFromParentNode() } scene.rootNode.addChildNode(axisNode) - scene.rootNode.addChildNode(profileNode) - scene.rootNode.addChildNode(referenceGroup) + scene.rootNode.addChildNode(layerGroup) scene.rootNode.addChildNode(cameraNode) buildAxisScaffold() - - if let profile { - profileNode.addChildNode(profileMeshNode(profile, name: "profile")) - } else { - profileNode.childNodes.forEach { $0.removeFromParentNode() } - } - - if let reference { - referenceGroup.childNodes.forEach { $0.removeFromParentNode() } - referenceGroup.addChildNode(referenceMeshNode(reference)) - } - addLights(to: scene) resetCamera() } + /// Reconciles the node set with `layers` and `visibleIDs`. + /// + /// New layers get a node; removed layers lose theirs; hidden + /// layers keep their mesh (`isHidden` only). Never rebuilds the + /// scene, so the camera is untouched by a checkbox toggle. + func syncLayers(_ layers: [NamedGamut], visibleIDs: Set) { + guard scene != nil else { return } + let wanted = Set(layers.map { $0.id }) + for (id, node) in layerNodes where !wanted.contains(id) { + node.removeFromParentNode() + layerNodes.removeValue(forKey: id) + } + for layer in layers { + if layerNodes[layer.id] == nil { + let node = makeLayerNode(for: layer) + layerNodes[layer.id] = node + layerGroup.addChildNode(node) + } + layerNodes[layer.id]?.isHidden = !visibleIDs.contains(layer.id) + } + } + + private func makeLayerNode(for layer: NamedGamut) -> SCNNode { + let node: SCNNode + switch layer.role { + case .reference: + node = referenceMeshNode(layer.mesh) + case .profileA: + node = profileMeshNode(layer.mesh) + case .profileB: + node = compareMeshNode(layer.mesh) + } + node.name = layer.id + return node + } + + // MARK: - Click inspect (#147) + + /// Click (not drag) hit-tests the scene. `NSClickGestureRecognizer` + /// only fires on a press+release in place, so orbit drags are + /// untouched. + func installClickGesture() { + guard let scnView, clickGesture == nil else { return } + let gesture = NSClickGestureRecognizer(target: self, action: #selector(handleClick(_:))) + scnView.addGestureRecognizer(gesture) + clickGesture = gesture + } + + @objc private func handleClick(_ gesture: NSClickGestureRecognizer) { + guard let scnView else { return } + let point = gesture.location(in: scnView) + for hit in scnView.hitTest(point, options: nil) { + if let layerID = layerID(for: hit.node) { + let world = hit.worldCoordinates + onInspect( + SIMD3(Float(world.x), Float(world.y), Float(world.z)), + layerID) + return + } + } + // Axis scaffold / empty background → back to idle. + onInspect(.zero, nil) + } + + /// Walks the hit node's ancestor chain looking for a layer node. + private func layerID(for node: SCNNode) -> String? { + var current: SCNNode? = node + while let node = current { + if let name = node.name, layerNodes[name] != nil { return name } + current = node.parent + } + return nil + } + + // MARK: - Scene furniture (unchanged from #28) + private func addLights(to scene: SCNScene) { let ambient = SCNNode() ambient.light = SCNLight() @@ -351,7 +683,8 @@ private struct GamutSceneView: NSViewRepresentable { return SCNNode(geometry: geometry) } - private func profileMeshNode(_ mesh: GamutMesh, name: String) -> SCNNode { + /// Profile A: solid vertex-coloured surface. + private func profileMeshNode(_ mesh: GamutMesh) -> SCNNode { let (geometry, _) = scnGeometry(for: mesh) let material = SCNMaterial() @@ -361,11 +694,26 @@ private struct GamutSceneView: NSViewRepresentable { material.isDoubleSided = true geometry.materials = [material] - let node = SCNNode(geometry: geometry) - node.name = name - return node + return SCNNode(geometry: geometry) } + /// Compare profile B: same vertex colours at ~30 % opacity so + /// overlaps with A and the sRGB reference stay readable. + private func compareMeshNode(_ mesh: GamutMesh) -> SCNNode { + let (geometry, _) = scnGeometry(for: mesh) + + let material = SCNMaterial() + material.lightingModel = .lambert + material.diffuse.contents = NSColor.white + material.transparency = 0.30 + material.isDoubleSided = true + material.writesToDepthBuffer = false + geometry.materials = [material] + + return SCNNode(geometry: geometry) + } + + /// Bundled sRGB reference: faint fill + structural edge lines. private func referenceMeshNode(_ mesh: GamutMesh) -> SCNNode { let (geometry, _) = scnGeometry(for: mesh) @@ -488,3 +836,81 @@ private struct GamutSceneView: NSViewRepresentable { } } } + +/// Click-to-sample image view for the TIFF preview sheet (#147). +/// +/// The TIFF is already decoded to PNG on the host side (#58); the view +/// reports 8-bit sRGB pixel values at the clicked point — the Lab +/// conversion is the documented approximate matrix helper, not a CMM. +private struct TiffSampleImageView: NSViewRepresentable { + let pngData: Data + var onSample: (Int, Int, Int) -> Void + + func makeNSView(context: Context) -> TiffSampleNSView { + let view = TiffSampleNSView() + view.image = NSImage(data: pngData) + view.onSample = onSample + return view + } + + func updateNSView(_ nsView: TiffSampleNSView, context: Context) { + nsView.onSample = onSample + } +} + +private final class TiffSampleNSView: NSView { + var image: NSImage? { + didSet { + bitmapRep = image?.cgImage(forProposedRect: nil, context: nil, hints: nil) + .flatMap { NSBitmapImageRep(cgImage: $0) } + invalidateIntrinsicContentSize() + needsDisplay = true + } + } + var onSample: ((Int, Int, Int) -> Void)? + private var bitmapRep: NSBitmapImageRep? + + override var intrinsicContentSize: NSSize { + image?.size ?? NSSize(width: 320, height: 240) + } + + override var acceptsFirstResponder: Bool { true } + + override func draw(_ dirtyRect: NSRect) { + NSColor(red: 0.055, green: 0.055, blue: 0.078, alpha: 1).setFill() + dirtyRect.fill() + guard let image else { return } + image.draw(in: imageRect()) + } + + override func mouseUp(with event: NSEvent) { + guard let rep = bitmapRep else { return } + let rect = imageRect() + let location = convert(event.locationInWindow, from: nil) + guard rect.contains(location), rect.width > 0, rect.height > 0 else { return } + + let x = Int((location.x - rect.minX) / rect.width * CGFloat(rep.pixelsWide)) + // This view is not flipped: y grows up, bitmap rows grow down. + let y = rep.pixelsHigh - 1 + - Int((location.y - rect.minY) / rect.height * CGFloat(rep.pixelsHigh)) + guard x >= 0, x < rep.pixelsWide, y >= 0, y < rep.pixelsHigh else { return } + + guard let color = rep.colorAt(x: x, y: y)?.usingColorSpace(.sRGB) else { return } + onSample?( + Int((color.redComponent * 255).rounded()), + Int((color.greenComponent * 255).rounded()), + Int((color.blueComponent * 255).rounded())) + } + + /// Aspect-fit rect of the image inside `bounds`. + private func imageRect() -> NSRect { + guard let image, image.size.width > 0, image.size.height > 0 else { return .zero } + let scale = min(bounds.width / image.size.width, bounds.height / image.size.height) + let size = NSSize(width: image.size.width * scale, height: image.size.height * scale) + return NSRect( + x: (bounds.width - size.width) / 2, + y: (bounds.height - size.height) / 2, + width: size.width, + height: size.height) + } +} diff --git a/Sources/ICCery/GamutViewModel.swift b/Sources/ICCery/GamutViewModel.swift index 6e99565..f7f7e75 100644 --- a/Sources/ICCery/GamutViewModel.swift +++ b/Sources/ICCery/GamutViewModel.swift @@ -1,53 +1,318 @@ import Combine import Foundation import ICCeryCore +import Metal +import simd -/// View model for the native SceneKit gamut viewer. +/// Whether the SceneKit gamut scene can render on this host (#147). /// -/// Loads the bundled `sRGB.gam` reference immediately and, optionally, a -/// printer/profile `.gam` from the current working directory. +/// Checked **before** `GamutSceneView` is mounted — constructing an +/// `SCNView` on a Metal-less machine can wedge the main thread, which +/// also stalls app quit behind the open sheet. +enum GamutSceneAvailability { + static var isAvailable: Bool { + if UITestHooks.skipSceneKit { return false } + return MTLCreateSystemDefaultDevice() != nil + } +} + +/// View model for the native SceneKit gamut viewer (issues #28, #147). +/// +/// Loads the bundled `sRGB.gam` reference immediately, the workflow's own +/// profile `.gam` when one exists, and an optional compare mesh the user +/// adds from the sheet toolbar. `iccgamut` failure is an in-sheet info +/// notice, never fatal (#24). @MainActor final class GamutViewModel: ObservableObject { - /// Parsed reference sRGB gamut mesh. - @Published var sRGBMesh: GamutMesh? + /// Stable layer ids — also the `gamutLayer-` a11y suffixes. + static let srgbLayerID = "sRGB" + static let profileLayerID = "profile" + static let compareLayerID = "compare" - /// Parsed printer/profile gamut mesh. - @Published var profileMesh: GamutMesh? + /// Loaded meshes: bundled sRGB plus up to two profiles. + @Published var layers: [NamedGamut] = [] - /// User-facing status line. + /// Layer ids currently shown in the scene. Toggling never unloads + /// the mesh — the `SCNNode` is hidden only. + @Published var visibleIDs: Set = [srgbLayerID] + + /// User-facing status line (`gamutStatusText`). Always non-empty + /// once set — `Milestone6GamutUITests` asserts it. @Published var status = "Loading gamut…" + /// In-sheet info line (`gamutNoticeText`). The main `NoticeBanner` + /// sits behind the sheet, so notices surface here instead. + @Published var noticeText: String? + + /// Set when `SCNView` cannot create a render context; the scene is + /// replaced by the docs/18 fallback text (`gamutViewerUnavailable`). + @Published var viewerUnavailable = false + /// Closure injected into the SceneKit view to request a camera reset. @Published var resetCamera: () -> Void = {} - private let profileGamURL: URL? + // MARK: - Inspect panel - init(profileGamURL: URL? = nil) { + /// Lab point currently inspected, or `nil` for the idle state. + @Published var inspectLab: LabColor? + + /// Swatch colour: the hit vertex's `rgb`, or the approximate sRGB of + /// the inspected Lab. + @Published var inspectSwatch: DisplayRGB? + + /// `true` when the swatch/Lab came from the approximate helper or a + /// typed value — drives the "approx. Lab, not ColorSync" caption. + @Published var inspectIsApproximate = false + + /// Per-layer containment for `inspectLab`, in layer order. + @Published var inspectResults: [(id: String, name: String, containment: GamutContainment)] = [] + + /// Manual Lab entry fields (`gamutLabEntry*`). + @Published var labEntryL = "" + @Published var labEntryA = "" + @Published var labEntryB = "" + + // MARK: - TIFF sampling + + /// PNG bytes for the preview sheet (`gamutTiffPreview`). + @Published var tiffPreviewPNG: Data? + @Published var showingTiffPreview = false + + private let environment: AppEnvironment + private let profileGamURL: URL? + private let fileDialogs = FileDialogService.shared + + init(environment: AppEnvironment, profileGamURL: URL? = nil) { + self.environment = environment self.profileGamURL = profileGamURL - Task { await load() } + // Never let the view mount an SCNView without Metal (#147). + viewerUnavailable = !GamutSceneAvailability.isAvailable + loadTask = Task { await load() } } + private var loadTask: Task? + + /// Awaits the initial sRGB/profile load — used by tests. + func awaitInitialLoad() async { + await loadTask?.value + } + + func layer(id: String) -> NamedGamut? { + layers.first { $0.id == id } + } + + // MARK: - Initial load + private func load() async { do { - let referenceURL = BinaryResolver().referenceGamut("sRGB") + let referenceURL = environment.runner.binaryResolver.referenceGamut("sRGB") let reference = try await parse(url: referenceURL) - sRGBMesh = reference - - if let profileGamURL { - let profile = try await parse(url: profileGamURL) - profileMesh = profile - status = "Profile gamut (\(profile.faces.count) faces) vs sRGB reference" - } else { - status = "sRGB reference gamut (\(reference.faces.count) faces)" - } + layers.append(NamedGamut( + id: Self.srgbLayerID, + displayName: "sRGB", + role: .reference, + mesh: reference, + sourceURL: referenceURL)) + visibleIDs.insert(Self.srgbLayerID) } catch { status = "Could not load gamut: \(error.localizedDescription)" + return + } + + if let profileGamURL { + do { + let profile = try await parse(url: profileGamURL) + layers.append(NamedGamut( + id: Self.profileLayerID, + displayName: profileGamURL.deletingPathExtension().lastPathComponent, + role: .profileA, + mesh: profile, + sourceURL: profileGamURL)) + visibleIDs.insert(Self.profileLayerID) + } catch { + // #24 — a missing/unparseable profile mesh is info, not fatal. + noticeText = "Profile gamut could not be loaded: \(error.localizedDescription)" + } + } + refreshStatus() + } + + // MARK: - Compare slot (profile B) + + /// `btnGamutOpenGam` — pick an existing `.gam` for the compare slot. + func openCompareGam() { + let url = UITestHooks.isEnabled + ? UITestHooks.gamutFileURL + : fileDialogs.selectGamutFile() + guard let url else { return } + Task { await loadCompareGam(url: url) } + } + + /// `btnGamutOpenProfile` — pick `.icc/.icm`; uses a sibling `.gam` + /// when present, otherwise runs bundled `iccgamut -v -d 10` (#24). + func openCompareProfile() { + let url = UITestHooks.isEnabled + ? UITestHooks.gamutProfileURL + : fileDialogs.selectProfileFile() + guard let url else { return } + Task { await loadCompareProfile(url: url) } + } + + /// `btnGamutRemoveCompare` — drops layer B, leaves sRGB + A. + func removeCompare() { + layers.removeAll { $0.id == Self.compareLayerID } + visibleIDs.remove(Self.compareLayerID) + refreshStatus() + } + + /// Parses `url` into the compare slot. Internal for tests — the UI + /// reaches it through `openCompareGam` / `openCompareProfile`. + func loadCompareGam(url: URL) async { + do { + let mesh = try await parse(url: url) + installCompare(NamedGamut( + id: Self.compareLayerID, + displayName: url.deletingPathExtension().lastPathComponent, + role: .profileB, + mesh: mesh, + sourceURL: url)) + } catch { + noticeText = "Could not load compare gamut: \(error.localizedDescription)" } } - /// Parses a `.gam` file off the main actor so large meshes do not stall - /// the UI. + /// `.icc/.icm` → sibling `.gam` or `iccgamut` → compare slot. + /// Internal for tests. + func loadCompareProfile(url: URL) async { + let gamURL = url.deletingPathExtension().appendingPathExtension("gam") + do { + if !FileManager.default.fileExists(atPath: gamURL.path) { + _ = try await environment.runner.runIccgamut( + config: IccgamutConfig(profileURL: url)) + } + await loadCompareGam(url: gamURL) + } catch { + noticeText = "Gamut extraction failed: \(error.localizedDescription)" + } + } + + /// A third profile replaces B — the compare slot never stacks and + /// sRGB is never touched. + private func installCompare(_ gamut: NamedGamut) { + if layers.contains(where: { $0.id == gamut.id }) { + noticeText = "Compare slot holds one profile. The previous compare mesh was replaced." + } + layers.removeAll { $0.id == gamut.id } + layers.append(gamut) + visibleIDs.insert(gamut.id) + refreshStatus() + } + + // MARK: - Status line + + /// `sRGB 448v / 892 faces · Profile 1024v / 2048 faces · vol 62% of sRGB`. + /// Unloaded layers are omitted; the volume clause appears only when + /// both volumes are finite and positive. + private func refreshStatus() { + var clauses = layers.map { + "\($0.displayName) \($0.mesh.vertices.count)v / \($0.mesh.faces.count) faces" + } + if let srgb = layer(id: Self.srgbLayerID), + let profile = layer(id: Self.profileLayerID) { + let srgbVolume = GamutGeometry.volume(of: srgb.mesh) + let profileVolume = GamutGeometry.volume(of: profile.mesh) + if srgbVolume.isFinite, srgbVolume > 0, + profileVolume.isFinite, profileVolume > 0 { + clauses.append("vol \(Int((profileVolume / srgbVolume * 100).rounded()))% of sRGB") + } + } + status = clauses.isEmpty ? "No gamut loaded" : clauses.joined(separator: " · ") + } + + // MARK: - Inspect + + /// Whether every manual Lab field parses as a number; the Inspect + /// button is disabled while this is false. + var canInspectLab: Bool { + [labEntryL, labEntryA, labEntryB].allSatisfy { Double($0) != nil } + } + + /// Runs containment for a Lab point and publishes the inspect row. + func inspect(lab: LabColor, swatch: DisplayRGB?, isApproximate: Bool) { + inspectLab = lab + inspectSwatch = swatch ?? LabColorMath.labToSRGB(lab) + inspectIsApproximate = isApproximate + inspectResults = layers.map { + ($0.id, $0.displayName, GamutGeometry.containment(of: lab, in: $0.mesh)) + } + } + + /// Click on the axis scaffold or empty background returns the panel + /// to idle. + func clearInspect() { + inspectLab = nil + inspectSwatch = nil + inspectIsApproximate = false + inspectResults = [] + } + + /// SceneKit hit callback: world `(x, y, z)` → Lab `(x→a*, y→L*, z→b*)`. + /// The swatch is the nearest vertex colour of the hit layer's mesh. + func inspectSceneHit(world: SIMD3, layerID: String) { + let lab = LabColor(l: Double(world.y), a: Double(world.x), b: Double(world.z)) + var swatch: DisplayRGB? + var approximate = true + if let mesh = layer(id: layerID)?.mesh, + let nearest = mesh.vertices.min(by: { + simd_distance($0.position, world) < simd_distance($1.position, world) + }) { + swatch = nearest.rgb + approximate = false + } + inspect(lab: lab, swatch: swatch, isApproximate: approximate) + } + + /// `btnGamutInspectLab` — typed L*a*b* path. No clamping; out-of-axis + /// values still run containment and report `?` outside every hull. + func inspectEnteredLab() { + guard let l = Double(labEntryL), + let a = Double(labEntryA), + let b = Double(labEntryB) else { return } + inspect(lab: LabColor(l: l, a: a, b: b), swatch: nil, isApproximate: true) + } + + // MARK: - TIFF sampling + + /// `btnGamutSampleTiff` — pick a target TIFF, decode a host-side PNG + /// preview (#58), and open the click-to-sample sheet. + func openTiffSample() { + let url = UITestHooks.isEnabled + ? UITestHooks.gamutTiffURL + : fileDialogs.selectTiffFile() + guard let url else { return } + guard let png = TiffPreview.previewPNG(tiff: url) else { + noticeText = "Could not decode TIFF preview." + return + } + tiffPreviewPNG = png + showingTiffPreview = true + } + + /// Pixel tap inside the preview sheet: sRGB8 → approximate Lab D50. + func sampleTiffPixel(r: Int, g: Int, b: Int) { + inspect( + lab: ApproximateLab.srgb8ToLab(r: r, g: g, b: b), + swatch: DisplayRGB( + r: Double(r) / 255.0, + g: Double(g) / 255.0, + b: Double(b) / 255.0), + isApproximate: true) + showingTiffPreview = false + } + + /// Parses a `.gam` file off the main actor so large meshes do not + /// stall the UI. private func parse(url: URL) async throws -> GamutMesh { try await Task.detached { try GamutMeshParser.parse(url: url) 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/MediaLibraryDialogs.swift b/Sources/ICCery/MediaLibraryDialogs.swift new file mode 100644 index 0000000..988effe --- /dev/null +++ b/Sources/ICCery/MediaLibraryDialogs.swift @@ -0,0 +1,269 @@ +import SwiftUI +import ICCeryCore + +/// `#saveMediaRecipeDialog` — capture the current printer + paper + +/// ink + `.cal` bound to the selected preset (issue #146). Clones +/// `SavePresetDialog` chrome; names render via `Text` only (#114). +struct SaveMediaRecipeDialog: View { + @ObservedObject var workflow: TargetWorkflowViewModel + /// Observed directly: nested ObservableObjects are not tracked + /// through the parent's `objectWillChange`. + @ObservedObject private var media: MediaLibraryViewModel + @ObservedObject private var printSession: PrintSessionViewModel + + init(workflow: TargetWorkflowViewModel) { + self.workflow = workflow + self._media = ObservedObject(wrappedValue: workflow.media) + self._printSession = ObservedObject(wrappedValue: workflow.print) + } + + private var printerCaption: String { + let queue = printSession.selectedPrinter + guard !queue.isEmpty else { return "None" } + let display = printSession.printers + .first { $0.name == queue }?.displayName ?? queue + return "\(display) (\(queue))" + } + + private func captureRow( + _ label: String, value: String, identifier: String + ) -> some View { + HStack { + Text(label).foregroundStyle(.secondary) + Spacer() + Text(value) + .foregroundStyle(Theme.text) + .lineLimit(1) + .truncationMode(.middle) + .accessibilityIdentifier(identifier) + } + } + + private var saveDisabled: Bool { + media.saveMediaName.trimmingCharacters(in: .whitespaces).isEmpty + || media.saveMediaPaper.trimmingCharacters(in: .whitespaces).isEmpty + || media.saveMediaInk.trimmingCharacters(in: .whitespaces).isEmpty + || media.captureColourSpaceMismatch + } + + // Swift 5.7 (Xcode 14.2 CI runner) caps a ViewBuilder body at 10 + // children (#146); Group blocks are layout-transparent, so field + // order and every docs/21 id are unchanged. + private var fields: some View { + Group { + TextField("Name", text: $media.saveMediaName) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("saveMediaName") + TextField("Notes (optional)", text: $media.saveMediaNotes) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("saveMediaNotes") + TextField("Paper", text: $media.saveMediaPaper) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("saveMediaPaper") + TextField("Ink set", text: $media.saveMediaInk) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("saveMediaInk") + } + } + + private var readOnlyRows: some View { + Group { + captureRow("Printer", value: printerCaption, + identifier: "saveMediaPrinter") + captureRow("Preset", + value: workflow.selectedPreset?.name ?? "No preset", + identifier: "saveMediaPreset") + captureRow("Colour space", + value: workflow.colourSpace.rawValue.uppercased(), + identifier: "saveMediaColourSpace") + captureRow("Calibration", + value: workflow.profile.calibrationFile.isEmpty + ? "None" : workflow.profile.calibrationFile, + identifier: "saveMediaCal") + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Save Media Recipe").font(.title3).foregroundStyle(Theme.text) + fields + readOnlyRows + Toggle("Apply calibration to profile", + isOn: $media.saveMediaApplyCal) + .disabled(!media.calApplyable) + .accessibilityIdentifier("saveMediaApplyCal") + + if media.captureColourSpaceMismatch { + Text("Colour space does not match the selected preset.") + .font(.caption).foregroundStyle(.orange) + } + if let error = media.saveMediaError { + Text(error).font(.caption).foregroundStyle(.orange) + } + + HStack { + Spacer() + Button("Cancel") { workflow.showingSaveMedia = false } + .accessibilityIdentifier("btnCloseSaveMediaDialog") + Button("Save") { + Task { + if await media.captureFromSession() { + workflow.showingSaveMedia = false + } + } + } + .disabled(saveDisabled) + .accessibilityIdentifier("btnConfirmSaveMedia") + } + } + .padding(20) + .frame(width: 380) + .background(Theme.background) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("saveMediaRecipeDialog") + } +} + +/// `#manageMediaDialog` — list, apply, delete, capture (issue #146). +/// `List`, not `Table` — macOS 12 target. Clones `ManagePresetsDialog`. +struct ManageMediaDialog: View { + @ObservedObject var workflow: TargetWorkflowViewModel + /// Observed directly: nested ObservableObjects are not tracked + /// through the parent's `objectWillChange`. + @ObservedObject private var media: MediaLibraryViewModel + @State private var selection: String? + @State private var pendingDelete: MediaRecipe? + + init(workflow: TargetWorkflowViewModel) { + self.workflow = workflow + self._media = ObservedObject(wrappedValue: workflow.media) + } + + private func presetCaption(for recipe: MediaRecipe) -> String { + workflow.presets.first { $0.id == recipe.presetID }?.name + ?? "Missing preset" + } + + private func calCaption(for recipe: MediaRecipe) -> String { + if media.staleReasons[recipe.id]?.contains(.calibration) == true { + return "Stale" + } + if let days = media.calAgeDays[recipe.id] { + return "Cal \(days)d" + } + return "No cal" + } + + private func applyAndDismiss(_ recipe: MediaRecipe) { + Task { + if await media.apply(recipe) { + workflow.showingManageMedia = false + } + } + } + + private func presetMissing(_ recipe: MediaRecipe) -> Bool { + !workflow.presets.contains { $0.id == recipe.presetID } + } + + private func calStale(_ recipe: MediaRecipe) -> Bool { + media.staleReasons[recipe.id]?.contains(.calibration) == true + } + + @ViewBuilder + private func row(_ recipe: MediaRecipe) -> some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(recipe.name).foregroundStyle(Theme.text) + Text("\(recipe.printerDisplayName) · \(recipe.paperName) · \(recipe.inkSet)") + .font(.caption).foregroundStyle(.secondary) + HStack(spacing: 8) { + Text(presetCaption(for: recipe)) + .font(.caption) + .foregroundStyle(presetMissing(recipe) ? .orange : .secondary) + Text(calCaption(for: recipe)) + .font(.caption) + .foregroundStyle(calStale(recipe) ? .orange : .secondary) + } + } + Spacer() + Button("Apply") { applyAndDismiss(recipe) } + .accessibilityIdentifier("btnMediaLibraryApply-\(recipe.id)") + Button("Delete", role: .destructive) { + pendingDelete = recipe + } + .accessibilityIdentifier("btnMediaLibraryDelete-\(recipe.id)") + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("mediaRow-\(recipe.id)") + .tag(recipe.id) + .contentShape(Rectangle()) + .simultaneousGesture( + TapGesture(count: 2).onEnded { applyAndDismiss(recipe) } + ) + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Manage Media Recipes").font(.title3).foregroundStyle(Theme.text) + + List(selection: $selection) { + if media.recipes.isEmpty { + Text("No media recipes yet. Capture the current printer, paper and preset.") + .font(.callout).foregroundStyle(.secondary) + .accessibilityIdentifier("mediaLibraryEmpty") + } + ForEach(media.recipes) { recipe in + row(recipe) + } + } + .accessibilityIdentifier("mediaLibraryList") + .frame(minHeight: 260) + + HStack { + Button("Apply selected") { + if let id = selection, + let recipe = media.recipes.first(where: { $0.id == id }) { + applyAndDismiss(recipe) + } + } + .disabled(selection == nil) + .keyboardShortcut(.defaultAction) + .accessibilityIdentifier("btnMediaLibraryApply") + Button("Capture current…") { + media.captureAfterManageDismiss = true + workflow.showingManageMedia = false + } + .accessibilityIdentifier("btnMediaLibraryCaptureFromManage") + Spacer() + Button("Close") { workflow.showingManageMedia = false } + .accessibilityIdentifier("btnCloseManageMediaDialog") + } + } + .padding(20) + .frame(width: 640) + .background(Theme.background) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("manageMediaDialog") + .onAppear { + media.reload() + media.refreshStaleness() + } + .alert( + "Delete media recipe?", + isPresented: Binding( + get: { pendingDelete != nil }, + set: { if !$0 { pendingDelete = nil } } + ), + presenting: pendingDelete + ) { recipe in + Button("Cancel", role: .cancel) { pendingDelete = nil } + Button("Delete", role: .destructive) { + media.delete(recipe) + pendingDelete = nil + } + } message: { recipe in + Text("Delete \"\(recipe.name)\"? This does not delete the .cal or the preset.") + } + } +} diff --git a/Sources/ICCery/MediaLibraryViewModel.swift b/Sources/ICCery/MediaLibraryViewModel.swift new file mode 100644 index 0000000..1b11532 --- /dev/null +++ b/Sources/ICCery/MediaLibraryViewModel.swift @@ -0,0 +1,432 @@ +import Combine +import Foundation +import ICCeryCore + +/// Media recipe library: capture / apply / staleness for issue #146. +/// +/// A recipe binds a CUPS queue + paper + ink + `.cal` to a +/// `ProfilingPreset`. Applying a recipe goes through the existing +/// `applyPreset` (#82) path — there is no second Stage 1 form. Paper +/// and ink are library metadata only; they are never written to the +/// targen label fields. +@MainActor +final class MediaLibraryViewModel: ObservableObject { + + /// Why a recipe row is flagged stale. + struct StaleReason: OptionSet { + let rawValue: Int + static let calibration = StaleReason(rawValue: 1 << 0) + static let printer = StaleReason(rawValue: 1 << 1) + } + + let workflow: TargetWorkflowViewModel + let environment: AppEnvironment + private let store: MediaLibraryStore + private var cancellables = Set() + + @Published var recipes: [MediaRecipe] = [] + /// Sidebar picker selection; `"none"` = no media recipe. Written + /// only on a successful apply so a failed apply snaps back. + @Published var selectedRecipeID = "none" + /// `recipe.id` → stale reasons for the sidebar badge / manage sheet. + @Published var staleReasons: [String: StaleReason] = [:] + /// `recipe.id` → whole days since the bound `.cal` was created. + @Published var calAgeDays: [String: Int] = [:] + + // Save-sheet state (mirrors savePresetName/savePresetDesc). + @Published var saveMediaName = "" + @Published var saveMediaNotes = "" + @Published var saveMediaPaper = "" + @Published var saveMediaInk = "" + @Published var saveMediaApplyCal = false + /// Inline caption inside the capture sheet (no a11y id — roster complete). + @Published var saveMediaError: String? + + /// Pure flow flag — the manage sheet's "Capture current…" asks the + /// sheet's `onDismiss` to open the capture sheet, avoiding a + /// present-while-dismissing race. + var captureAfterManageDismiss = false + + init(workflow: TargetWorkflowViewModel, environment: AppEnvironment) { + self.workflow = workflow + self.environment = environment + self.store = environment.mediaStore + + reload() + refreshStaleness() + // The library needs queues enumerated at launch so Capture can + // enable and the not-installed badge is computable; Stage 2 only + // enumerates when a printtarg manifest exists. + if workflow.print.printers.isEmpty { + workflow.print.refreshPrinters() + } + + NotificationCenter.default + .publisher(for: SettingsStore.settingsDidChange) + .sink { [weak self] _ in self?.refreshStaleness() } + .store(in: &cancellables) + workflow.print.$printers + .sink { [weak self] _ in self?.refreshStaleness() } + .store(in: &cancellables) + workflow.print.$selectedPrinter + .sink { [weak self] _ in self?.refreshStaleness() } + .store(in: &cancellables) + } + + deinit { cancellables.removeAll() } + + // MARK: - Load / corrupt + + func reload() { + Task { await reloadAsync() } + } + + func reloadAsync() async { + do { + recipes = try await store.load() + } catch { + // Corrupt-file policy: keep the file, keep the cache, + // persistent warning; the picker falls back to "none". + workflow.wizard.showNotice( + "Media library is unreadable — the existing file was kept.", + kind: .warning, + autoHideAfter: nil + ) + if recipes.isEmpty { selectedRecipeID = "none" } + } + } + + // MARK: - Selection / apply + + /// Sidebar `mediaSelect` binding. `"none"` clears the selection + /// without resetting any Stage 1/2/4 field — it is not "reset to + /// factory". + func selectRecipe(_ id: String) { + if id == "none" { + selectedRecipeID = "none" + return + } + guard let recipe = recipes.first(where: { $0.id == id }) else { return } + Task { _ = await apply(recipe) } + } + + /// The single apply path — sidebar picker, manage-row Apply, and + /// the manage footer all funnel here. + /// + /// Returns `false` when any bound resource is unresolved (missing + /// preset, colour-space mismatch, queue absent, missing/unparseable + /// `.cal`) so the manage sheet stays open and the picker reverts. + /// A `CAL_`-blocked calibration counts as applied (`true` — success + /// with warning; the refusal is permanent so re-clicking can't help). + @discardableResult + func apply(_ recipe: MediaRecipe) async -> Bool { + guard let r = try? recipe.validated() else { + workflow.wizard.showNotice( + "Media recipe is invalid — not applied.", kind: .error) + return false + } + guard let preset = environment.presetStore.all() + .first(where: { $0.id == r.presetID }) + else { + workflow.wizard.showNotice( + "Preset \(r.presetID) no longer exists — recipe not applied.", + kind: .error) + return false + } + guard preset.colourSpace.lowercased() == r.colourSpace.lowercased() else { + workflow.wizard.showNotice( + "Recipe colour space does not match its preset — not applied.", + kind: .error) + return false + } + + // Existing #82 mapping: presetSelect jumps, Stage 1/2/4 fields. + workflow.applyPreset(preset) + // Literal per issue: displayName, not the queue id. + workflow.wizard.printerName = r.printerDisplayName + + var succeeded = true + + // Queue: enumerate fresh via the session's serialized path — + // listPrinters uses fixed process ids, so an overlapping + // enumeration would throw duplicateID. An empty result is a + // valid list. + if let queues = await workflow.print.enumeratePrinters() { + if queues.contains(where: { $0.name == r.printerID }) { + workflow.print.selectedPrinter = r.printerID + await workflow.print.reloadSelectedCapabilities() + } else { + workflow.wizard.showNotice( + "Printer \(r.printerDisplayName) is not installed.", + kind: .warning) + succeeded = false + } + } else { + workflow.wizard.showNotice( + "Could not enumerate printers — queue left unchanged.", + kind: .warning) + succeeded = false + } + + // Calibration — the recipe is authoritative and runs after + // applyPreset so the preset's own cal fields don't win. + let calPath = r.calibrationURL?.trimmingCharacters(in: .whitespaces) ?? "" + let calStem = URL(fileURLWithPath: calPath) + .deletingPathExtension().lastPathComponent + let blocked = r.applyCalibration && !calPath.isEmpty + && (CalibrationIdentity.isCalibration(calStem) + || CalibrationIdentity.isCalibration(workflow.wizard.basename)) + + if blocked { + // Literal CAL_ refusal on both names (decision 1): keep the + // path for display but never let `printtarg -K` see it. + workflow.profile.applyCalibration = false + workflow.profile.calibrationFile = calPath + selectedRecipeID = r.id + refreshStaleness() + workflow.wizard.showNotice( + "Applied \(r.name) — CAL_ calibrations cannot enable printtarg -K.", + kind: .warning, + autoHideAfter: nil) + return true + } + + if r.applyCalibration && !calPath.isEmpty { + guard FileManager.default.fileExists(atPath: calPath) else { + workflow.profile.applyCalibration = false + workflow.profile.calibrationFile = calPath + workflow.wizard.showNotice( + "Calibration file is missing: \(calPath)", kind: .error) + refreshStaleness() + return false + } + do { + let staleDays = environment.settingsStore.load().calibrationStaleDays + let calStore = CalibrationStore(staleDays: staleDays) + try await calStore.load(url: URL(fileURLWithPath: calPath)) + workflow.profile.calibrationFile = calPath + workflow.profile.applyCalibration = true + // Age check only — the .cal DESCRIPTOR is free text, not + // a queue id, so a name compare false-positives. + if await calStore.isStale() { + workflow.wizard.showNotice( + "Applied \(r.name) — calibration is stale.", + kind: .warning) + } + } catch { + workflow.profile.applyCalibration = false + workflow.wizard.showNotice( + "Could not load calibration: \(error.localizedDescription)", + kind: .error) + refreshStaleness() + return false + } + } else { + workflow.profile.applyCalibration = false + workflow.profile.calibrationFile = calPath + } + + if succeeded { + selectedRecipeID = r.id + workflow.wizard.showNotice("Applied \(r.name)") + } + refreshStaleness() + return succeeded + } + + // MARK: - Capture + + /// Whether the live `profile.calibrationFile` may be applied: + /// non-empty, not a `CAL_` stem, and present on disk. + var calApplyable: Bool { + let path = workflow.profile.calibrationFile + guard !path.isEmpty else { return false } + let stem = URL(fileURLWithPath: path) + .deletingPathExtension().lastPathComponent + guard !CalibrationIdentity.isCalibration(stem) else { return false } + return FileManager.default.fileExists(atPath: path) + } + + /// A bound preset whose colour space disagrees with the live form — + /// the sheet shows the mismatch caption and disables Save. + var captureColourSpaceMismatch: Bool { + guard let preset = workflow.selectedPreset else { return false } + return preset.colourSpace.lowercased() != workflow.colourSpace.rawValue + } + + /// Opens the capture sheet, prefilled from the selected recipe else + /// the most recently captured one ("last recipe … or empty"). + func beginCapture() { + let source = recipes.first(where: { $0.id == selectedRecipeID }) + ?? recipes.last + saveMediaPaper = source?.paperName ?? "" + saveMediaInk = source?.inkSet ?? "" + saveMediaName = "" + saveMediaNotes = "" + saveMediaError = nil + saveMediaApplyCal = workflow.profile.applyCalibration && calApplyable + if workflow.print.printers.isEmpty { + workflow.print.refreshPrinters() + } + workflow.showingSaveMedia = true + } + + /// Save button — the sheet closes only on `true`. + func captureFromSession() async -> Bool { + let name = saveMediaName.trimmingCharacters(in: .whitespacesAndNewlines) + let paper = saveMediaPaper.trimmingCharacters(in: .whitespacesAndNewlines) + let ink = saveMediaInk.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty, !paper.isEmpty, !ink.isEmpty else { + saveMediaError = "Name, paper and ink are required." + return false + } + + let queue = workflow.print.selectedPrinter + guard !queue.isEmpty else { + saveMediaError = "Select a printer in Stage 2 first." + return false + } + + // Preset binding: the selected preset when its colour space + // matches the live form; otherwise auto-snapshot the live form + // as a custom preset (decision 2). + let presetID: String + if let bound = workflow.selectedPreset { + guard bound.colourSpace.lowercased() == workflow.colourSpace.rawValue else { + saveMediaError = "Colour space does not match the selected preset." + return false + } + presetID = bound.id + } else { + let snapshot = ProfilingPreset( + id: "custom-\(UUID().uuidString.lowercased())", + name: name, + description: "Auto-saved for media recipe", + targen: workflow.buildTargenConfig(), + printtarg: workflow.buildPrinttargConfig(), + colprof: workflow.profile.buildColprofConfig(), + calibrationFile: nil, + applyCalibration: nil + ) + do { + try environment.presetStore.saveCustom(snapshot) + workflow.reloadPresets() + workflow.selectedPresetID = snapshot.id + } catch { + saveMediaError = "Could not save preset: \(error.localizedDescription)" + return false + } + presetID = snapshot.id + } + + // The cal path is stored verbatim; applyCalibration is forced + // off for CAL_/missing paths via calApplyable. + let calPath = workflow.profile.calibrationFile + let printer = workflow.print.printers.first { $0.name == queue } + let now = Date() + let recipe = MediaRecipe( + id: "recipe-\(UUID().uuidString.lowercased())", + name: name, + notes: saveMediaNotes.trimmingCharacters(in: .whitespacesAndNewlines), + printerID: queue, + printerDisplayName: printer?.displayName ?? queue, + paperName: paper, + driverMediaType: workflow.print.selectedMediaType, + inkSet: ink, + colourSpace: workflow.colourSpace.rawValue, + presetID: presetID, + calibrationURL: calPath.isEmpty ? nil : calPath, + applyCalibration: saveMediaApplyCal && calApplyable, + created: now, + updated: now + ) + + do { + let validated = try recipe.validated() + try await store.upsert(validated) + await reloadAsync() + selectedRecipeID = validated.id + workflow.wizard.showNotice("Media recipe saved: \(validated.name)") + return true + } catch let error as MediaLibraryStore.MediaLibraryError { + saveMediaError = error.errorDescription + return false + } catch { + saveMediaError = "Could not save: \(error.localizedDescription)" + return false + } + } + + // MARK: - Delete + + func delete(_ recipe: MediaRecipe) { + Task { + do { + try await store.delete(id: recipe.id) + await reloadAsync() + if selectedRecipeID == recipe.id { + selectedRecipeID = "none" + } + } catch { + workflow.wizard.showNotice( + "Could not delete: \(error.localizedDescription)", + kind: .error) + } + } + } + + // MARK: - Staleness + + func refreshStaleness() { + Task { await refreshStalenessAsync() } + } + + /// Recomputes `staleReasons` + `calAgeDays` for every recipe. + /// + /// `.printer` fires only when `printerID` is absent from a + /// **non-empty** enumerated queue list — an un-enumerated list is + /// indeterminate, not stale (decision 5). `.calibration` is a pure + /// age check (`isStale()` with no `comparedTo:`) — the `.cal` + /// DESCRIPTOR is free text, not a queue id. + func refreshStalenessAsync() async { + let staleDays = environment.settingsStore.load().calibrationStaleDays + let queues = workflow.print.printers + let now = Date() + let calStore = CalibrationStore(staleDays: staleDays) + + var reasons: [String: StaleReason] = [:] + var ages: [String: Int] = [:] + for r in recipes { + var flags: StaleReason = [] + if !queues.isEmpty, !queues.contains(where: { $0.name == r.printerID }) { + flags.insert(.printer) + } + if let raw = r.calibrationURL?.trimmingCharacters(in: .whitespaces), + !raw.isEmpty, + FileManager.default.fileExists(atPath: raw), + (try? await calStore.load(url: URL(fileURLWithPath: raw))) != nil, + let created = await calStore.data?.created { + ages[r.id] = Calendar.current + .dateComponents([.day], from: created, to: now).day ?? 0 + if await calStore.isStale() { + flags.insert(.calibration) + } + } + if !flags.isEmpty { reasons[r.id] = flags } + } + staleReasons = reasons + calAgeDays = ages + } + + // MARK: - Manage sheet flow + + /// Called from the manage sheet's `onDismiss`. A deferred capture + /// request opens the save sheet only now, after the manage sheet has + /// fully dismissed. + func manageDismissed() { + if captureAfterManageDismiss { + captureAfterManageDismiss = false + beginCapture() + } + } +} diff --git a/Sources/ICCery/Print/PrintSessionViewModel.swift b/Sources/ICCery/Print/PrintSessionViewModel.swift index e8e4ed3..3eabd2f 100644 --- a/Sources/ICCery/Print/PrintSessionViewModel.swift +++ b/Sources/ICCery/Print/PrintSessionViewModel.swift @@ -24,24 +24,41 @@ final class PrintSessionViewModel: ObservableObject { self.environment = environment } + private var printerEnumTask: Task<[Printer]?, Never>? + func refreshPrinters() { - let cups = environment.cupsService - Task { @MainActor in + Task { @MainActor in _ = await enumeratePrinters() } + } + + /// Serialized queue enumeration — `listPrinters` uses fixed process + /// ids, so overlapping calls would throw `duplicateID`. Concurrent + /// callers coalesce onto the in-flight task (#146). + @discardableResult + func enumeratePrinters() async -> [Printer]? { + if let pending = printerEnumTask { return await pending.value } + let task = Task { @MainActor [weak self] () -> [Printer]? in + guard let self else { return nil } do { - let list = try await cups.listPrinters() - printers = list - if !list.contains(where: { $0.name == selectedPrinter }) { - selectedPrinter = list.first { $0.isDefault }?.name + let list = try await self.environment.cupsService.listPrinters() + self.printers = list + if !list.contains(where: { $0.name == self.selectedPrinter }) { + self.selectedPrinter = list.first { $0.isDefault }?.name ?? list.first?.name ?? "" } - await reloadSelectedCapabilities() + await self.reloadSelectedCapabilities() + return list } catch { - printNotice = Notice( + self.printNotice = Notice( kind: .error, text: "Could not list printers: \(error.localizedDescription)" ) + return nil } } + printerEnumTask = task + let result = await task.value + printerEnumTask = nil + return result } func reloadSelectedCapabilities() async { 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 aa67a55..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 { @@ -56,6 +58,25 @@ struct RootView: View { .sheet(isPresented: $workflow.showingManagePresets) { ManagePresetsDialog(workflow: workflow) } + // Media library sheets live on RootView, never inside the + // 270 pt sidebar column (#146). + .sheet(isPresented: $workflow.showingSaveMedia) { + SaveMediaRecipeDialog(workflow: workflow) + } + .sheet( + isPresented: $workflow.showingManageMedia, + onDismiss: { workflow.media.manageDismissed() } + ) { + ManageMediaDialog(workflow: workflow) + } + // Spot-read console sheet (issue #148). Dismiss runs the same + // `q\n` + ~500 ms + kill path as the sheet's Stop button. + .sheet( + isPresented: $workflow.showingSpotRead, + onDismiss: { workflow.spotRead.sheetClosed() } + ) { + SpotReadView(model: workflow.spotRead) + } .sheet(isPresented: $showingAbout) { AboutView { showingAbout = false } } @@ -63,7 +84,41 @@ struct RootView: View { get: { workflow.wizard.showingGamutViewer }, set: { workflow.wizard.showingGamutViewer = $0 } )) { - GamutView(profileGamURL: workflow.wizard.gamutProfileURL) + GamutView( + environment: workflow.environment, + 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/SettingsView.swift b/Sources/ICCery/SettingsView.swift index f55a0b7..6625f68 100644 --- a/Sources/ICCery/SettingsView.swift +++ b/Sources/ICCery/SettingsView.swift @@ -58,7 +58,7 @@ struct SettingsView: View { Text($0.label).tag($0.code) } } - Text("Display-only — Stage 2's instrument select is used for actual runs.") + Text("Seeds Spot Read and Stage 3 when the instrument is plugged in. printtarg -i is still chosen on Stage 2.") .font(.caption) .foregroundStyle(.secondary) diff --git a/Sources/ICCery/SidebarView.swift b/Sources/ICCery/SidebarView.swift index 0c4d3c1..4d937dc 100644 --- a/Sources/ICCery/SidebarView.swift +++ b/Sources/ICCery/SidebarView.swift @@ -9,6 +9,10 @@ struct SidebarView: View { /// through the parent's `objectWillChange`. @ObservedObject private var model: WizardViewModel @ObservedObject private var profile: ProfileWorkflowViewModel + @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 @@ -22,6 +26,10 @@ struct SidebarView: View { self.workflow = workflow self._model = ObservedObject(wrappedValue: workflow.wizard) self._profile = ObservedObject(wrappedValue: workflow.profile) + 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 @@ -29,6 +37,21 @@ struct SidebarView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { + header + presetBlock + mediaBlock + studioButtons + stepperAndProject + } + .frame(width: Theme.Metrics.sidebarWidth) + .background(Theme.panel) + } + + // Swift 5.7 (Xcode 14.2 CI runner) caps a ViewBuilder body at 10 + // children (#146); these Group blocks are layout-transparent, so + // visual order, ids and the 270 pt column are unchanged. + private var header: some View { + Group { HStack { Image("ICCery-logo") .resizable() @@ -57,7 +80,11 @@ struct SidebarView: View { .padding(12) Divider().overlay(Theme.border) + } + } + private var presetBlock: some View { + Group { // Preset select (`#presetSelect`) — issue #11. Selection // applies the preset immediately; names render via Text only. Picker("Preset", selection: Binding( @@ -89,7 +116,65 @@ struct SidebarView: View { } .padding(.horizontal, 12) .padding(.bottom, 8) + } + } + private var mediaBlock: some View { + Group { + // Media library (`#mediaSelect`) — issue #146. Selection + // applies the recipe immediately, like presets; names render + // via Text only (#114). Never reuses `presetSelect` (#137). + Picker("Media", selection: Binding( + get: { media.selectedRecipeID }, + set: { media.selectRecipe($0) } + )) { + Text("No media recipe").tag("none") + ForEach(media.recipes) { recipe in + Text(recipe.name).tag(recipe.id) + } + } + .pickerStyle(.menu) + .accessibilityIdentifier("mediaSelect") + .padding(.horizontal, 12) + .padding(.vertical, 8) + .helpOverlay( + "Saved printer + paper + ink + .cal bound to a preset.", + showing: $showingAllHelp) + + if let reasons = media.staleReasons[media.selectedRecipeID], + !reasons.isEmpty { + Text(reasons.contains(.printer) + ? "Printer not installed" : "Calibration stale") + .font(.caption) + .foregroundStyle(.orange) + .padding(.horizontal, 12) + .accessibilityIdentifier("mediaRecipeStale") + .helpOverlay( + "Re-run Stage 0 or pick a different recipe.", + showing: $showingAllHelp) + } + + HStack(spacing: 8) { + Button("Capture") { media.beginCapture() } + .disabled(printSession.selectedPrinter.isEmpty) + .accessibilityIdentifier("btnMediaLibraryCapture") + .helpOverlay( + "Select a printer in Stage 2 first", + showing: $showingAllHelp) + Button("Manage") { workflow.showingManageMedia = true } + .accessibilityIdentifier("btnMediaLibraryManage") + .helpOverlay( + "Apply or delete saved media recipes.", + showing: $showingAllHelp) + Spacer() + } + .padding(.horizontal, 12) + .padding(.bottom, 8) + } + } + + private var studioButtons: some View { + Group { // Calibrate Printer (`#btnCalibratePrinter`). Button(action: { model.enterCalibration() }) { Label("Calibrate Printer", systemImage: "slider.horizontal.3") @@ -104,9 +189,36 @@ struct SidebarView: View { .frame(maxWidth: .infinity) } .controlSize(.large) + .helpOverlay( + "View the profile gamut in 3D against sRGB.", + showing: $showingAllHelp) .accessibilityIdentifier("btnViewGamut") .padding(.horizontal, 12) + // Spot Read sheet (`#btnSpotRead`) — issue #148. Enabled + // only with a working folder (#59) and while no Stage 3 + // chartread child is live; opening never kills + // `chartread_{basename}`. + Button(action: { workflow.showingSpotRead = true }) { + Label("Spot Read", systemImage: "eyedropper") + .frame(maxWidth: .infinity) + } + .controlSize(.large) + .disabled(model.workingDirectory == nil || measurement.isChartreadRunning) + .helpOverlay( + model.workingDirectory == nil + ? "Set a working folder in Stage 1 first." + : (measurement.isChartreadRunning + ? "Stop the Stage 3 chart read first." + : "Read a single patch as Lab/XYZ from the instrument."), + showing: $showingAllHelp) + .accessibilityIdentifier("btnSpotRead") + .padding(.horizontal, 12) + } + } + + private var stepperAndProject: some View { + Group { Divider().overlay(Theme.border) .padding(.vertical, 8) @@ -126,9 +238,13 @@ 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/SpotReadView.swift b/Sources/ICCery/SpotReadView.swift new file mode 100644 index 0000000..9d5aea1 --- /dev/null +++ b/Sources/ICCery/SpotReadView.swift @@ -0,0 +1,360 @@ +import SwiftUI +import ICCeryCore + +/// Spot Read sheet (issue #148) — one patch Lab/XYZ from the live +/// instrument. A `RootView` sheet, not a wizard stage and not a Stage 3 +/// tab; all identifiers are `spot*` — Stage 3 `chartread` ids are never +/// reused here. +struct SpotReadView: View { + @ObservedObject var model: SpotReadViewModel + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + header + if !model.sidecarAvailable { + missingSidecar + } else { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + instrumentCard + promptLine + transport + lastSampleCard + historySection + } + } + } + footer + } + .padding(16) + .frame(width: 560, height: 640) + .background(Theme.background) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("spotReadView") + .onAppear { model.sheetOpened() } + .onDisappear { model.sheetClosed() } + } + + // MARK: - Header / missing sidecar + + private var header: some View { + HStack(alignment: .firstTextBaseline) { + Text("Spot Read") + .font(.title3) + .foregroundStyle(Theme.text) + Spacer() + if model.isRunning { + ProgressView() + .scaleEffect(0.8) + } + } + } + + private var missingSidecar: some View { + VStack(alignment: .leading, spacing: 12) { + Text("spotread sidecar missing — run fetch-argyll") + .foregroundStyle(Theme.text) + .accessibilityIdentifier("spotSidecarMissing") + Spacer() + } + } + + // MARK: - Instrument card (clones Stage 3 look, own ids) + + private var instrumentCard: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Instrument") + .font(.headline) + .foregroundStyle(Theme.text) + Spacer() + Button(action: { model.detectInstruments() }) { + Image(systemName: "arrow.clockwise") + } + .disabled(!model.canDetect) + .accessibilityIdentifier("btnSpotDetectInstruments") + } + + if let error = model.detectionError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + .accessibilityIdentifier("spotDetectError") + } + + Picker("Instrument", selection: Binding( + get: { instrumentTag }, + set: { newTag in + if newTag.isEmpty { + model.selectedInstrument = .auto + } else if let device = model.instruments.first(where: { "\($0.port)" == newTag }) { + model.selectedInstrument = .device(device) + } + } + )) { + Text("Auto (first available port)").tag("") + ForEach(model.instruments) { device in + Text(device.displayName).tag("\(device.port)") + } + } + .pickerStyle(.menu) + .disabled(model.isRunning) + .accessibilityIdentifier("spotInstrumentSelect") + + if model.defaultMissing { + Text("Saved default instrument not present") + .font(.caption) + .foregroundStyle(.orange) + .accessibilityIdentifier("spotDefaultMissing") + } + + Toggle("Also set as default instrument", isOn: Binding( + get: { model.setAsDefault }, + set: { model.applyDefaultToggle($0) } + )) + .accessibilityIdentifier("spotSetDefault") + + if model.selectedInstrument.isXY { + Text("XY tables use Stage 3. Spot Read is a handheld / reflective probe.") + .font(.caption) + .foregroundStyle(Theme.accent) + .accessibilityIdentifier("spotXYHint") + } + } + .padding(16) + .background(Theme.panel) + } + + private var instrumentTag: String { + switch model.selectedInstrument { + case .auto: + return "" + case .device(let device): + return "\(device.port)" + } + } + + // MARK: - Prompt line + + private var promptLine: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Status") + .font(.headline) + .foregroundStyle(Theme.text) + Spacer() + Text(model.prompt) + .font(.callout) + .foregroundStyle(Theme.text) + .accessibilityIdentifier("spotPrompt") + } + if let error = model.lastError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + .accessibilityIdentifier("spotLastError") + .accessibilityValue(error) + } + if !model.log.isEmpty { + ProcessLogView( + lines: model.log, + minHeight: 60, + maxHeight: 100, + containerId: "spotLogContainer", + logId: "spotLog" + ) + } + } + .padding(16) + .background(Theme.panel) + } + + // MARK: - Transport + + private var transport: some View { + HStack(spacing: 12) { + if !model.isRunning { + Button("Start") { model.start() } + .disabled(!model.canStart) + .accessibilityIdentifier("btnSpotStart") + } else { + switch model.state { + case .calibrating: + Button("Calibrate") { model.calibrate() } + .accessibilityIdentifier("btnSpotCalibrate") + case .awaitingStrip: + Button("Read") { model.trigger() } + .accessibilityIdentifier("btnSpotTrigger") + default: + EmptyView() + } + Button("Stop") { model.stopIfNeeded() } + .accessibilityIdentifier("btnSpotStop") + } + Spacer() + } + .padding(.horizontal, 4) + } + + // MARK: - Last sample + + @ViewBuilder + private var lastSampleCard: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Last sample") + .font(.headline) + .foregroundStyle(Theme.text) + + if let sample = model.displayedSample { + HStack(spacing: 16) { + let rgb = LabColorMath.labToSRGB(sample.lab) + RoundedRectangle(cornerRadius: 4) + .fill(Color(red: rgb.r, green: rgb.g, blue: rgb.b)) + .frame(width: 32, height: 32) + .overlay(RoundedRectangle(cornerRadius: 4).stroke(Theme.border)) + .accessibilityIdentifier("spotSwatch") + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 12) { + Text(String(format: "L* %.1f", sample.lab.l)) + .accessibilityIdentifier("spotLabL") + Text(String(format: "a* %.1f", sample.lab.a)) + .accessibilityIdentifier("spotLabA") + Text(String(format: "b* %.1f", sample.lab.b)) + .accessibilityIdentifier("spotLabB") + } + .font(.callout) + .foregroundStyle(Theme.text) + + if let xyz = sample.xyz { + Text(String(format: "XYZ %.2f %.2f %.2f", xyz.x, xyz.y, xyz.z)) + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityIdentifier("spotXYZ") + } + + HStack(spacing: 8) { + Text(sample.port.map { "\(sample.instrumentName) · port \($0)" } + ?? sample.instrumentName) + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityIdentifier("spotLastInstrument") + + if let de = model.displayedDeltaE { + HStack(spacing: 6) { + Circle() + .fill(deltaEColor) + .frame(width: 8, height: 8) + Text(String(format: "ΔE %.2f", de)) + .font(.caption) + .foregroundStyle(.secondary) + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("spotDeltaE") + } + } + + if model.isDisplayedLabImplausible { + Text("Implausible L*") + .font(.caption) + .foregroundStyle(.orange) + .accessibilityIdentifier("spotLabImplausible") + } + } + Spacer() + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("spotLastSample") + } else { + Text("No readings yet.") + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityIdentifier("spotLastEmpty") + } + } + .padding(16) + .background(Theme.panel) + } + + private var deltaEColor: Color { + switch model.deltaEClassification { + case .good, nil: return .green + case .warning: return .orange + case .bad: return .red + } + } + + // MARK: - History + + @ViewBuilder + private var historySection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("History") + .font(.headline) + .foregroundStyle(Theme.text) + + if model.samples.isEmpty { + Text("No history.") + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityIdentifier("spotHistoryEmpty") + } else { + List { + ForEach(Array(model.samples.enumerated()), id: \.element.id) { index, sample in + historyRow(index: index, sample: sample) + } + } + .frame(minHeight: 120) + .accessibilityIdentifier("spotHistoryTable") + } + } + .padding(16) + .background(Theme.panel) + } + + private func historyRow(index: Int, sample: SpotReadSample) -> some View { + let previous = index + 1 < model.samples.count ? model.samples[index + 1] : nil + let deltaE = previous.map { ColorDifference.deltaE00($0.lab, sample.lab) } + return Button(action: { model.selectFromHistory(sample) }) { + HStack(spacing: 10) { + Text(sample.timestamp, style: .time) + .frame(width: 70, alignment: .leading) + Text(String(format: "%.1f", sample.lab.l)) + .frame(width: 44, alignment: .trailing) + Text(String(format: "%.1f", sample.lab.a)) + .frame(width: 44, alignment: .trailing) + Text(String(format: "%.1f", sample.lab.b)) + .frame(width: 44, alignment: .trailing) + Text(deltaE.map { String(format: "%.2f", $0) } ?? "") + .frame(width: 44, alignment: .trailing) + Text(sample.instrumentName) + .lineLimit(1) + Spacer() + } + .font(.caption) + .foregroundStyle(Theme.text) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier("spotHistoryRow-\(sample.id.uuidString)") + } + + // MARK: - Footer + + private var footer: some View { + HStack(spacing: 12) { + Button("Copy Lab") { model.copyLab() } + .disabled(model.displayedSample == nil) + .accessibilityIdentifier("btnSpotCopyLab") + Button("Export CSV…") { model.exportCsv() } + .disabled(model.samples.isEmpty) + .accessibilityIdentifier("btnSpotExportCsv") + Spacer() + Button("Close") { dismiss() } + .keyboardShortcut(.cancelAction) + .accessibilityIdentifier("btnCloseSpotRead") + } + .padding(.top, 4) + } +} diff --git a/Sources/ICCery/SpotReadViewModel.swift b/Sources/ICCery/SpotReadViewModel.swift new file mode 100644 index 0000000..19d311b --- /dev/null +++ b/Sources/ICCery/SpotReadViewModel.swift @@ -0,0 +1,420 @@ +import AppKit +import Combine +import Foundation +import ICCeryCore + +/// Spot-read console state and interaction (issue #148). +/// +/// Runs the bundled `spotread` sidecar under the single-lease process id +/// `spotread`; Stage 3 `chartread` is untouched. `defaultInstrument` +/// seeds the instrument picker on sheet open — it is never written into +/// `printtarg -i` or `targen` argv (R15). +@MainActor +final class SpotReadViewModel: ObservableObject { + + let workflow: TargetWorkflowViewModel + let environment: AppEnvironment + private let fileDialogs = FileDialogService.shared + + // MARK: - Instrument card + + @Published var instruments: [InstrumentDevice] = [] + @Published var selectedInstrument: InstrumentSelection = .auto + @Published var isDetecting = false + @Published var detectionError: String? + /// `spotDefaultMissing` — set when `defaultInstrument` is saved but + /// no detected device matches it. + @Published var defaultMissing = false + /// `spotSetDefault` toggle state. + @Published var setAsDefault = false + + // MARK: - Session + + @Published var isRunning = false + @Published var state: ChartreadState = .idle + @Published var prompt = "Press Start to open the instrument." + @Published var lastError: String? + @Published var log: [String] = [] + + // MARK: - Samples / history (in-memory, cap 50, newest first) + + @Published private(set) var samples: [SpotReadSample] = [] + @Published private(set) var displayedSample: SpotReadSample? + @Published private(set) var displayedDeltaE: Double? + + private let historyLimit = 50 + private var streamTask: Task? + + init(workflow: TargetWorkflowViewModel, environment: AppEnvironment) { + self.workflow = workflow + self.environment = environment + } + + // MARK: - Derived state + + /// Whether the bundled `spotread` sidecar resolves to an executable. + /// `BinaryResolver` only — never `$PATH`, never `chartread`. + var sidecarAvailable: Bool { + let url = environment.runner.binaryResolver.resolve("spotread") + return environment.runner.binaryResolver.exists(url) + } + + var isChartreadRunning: Bool { workflow.measurement.isChartreadRunning } + + var canStart: Bool { + sidecarAvailable && !isDetecting && !isRunning && !isChartreadRunning + && workflow.wizard.effectiveWorkingDirectory != nil + } + + var canDetect: Bool { !isDetecting && !isRunning } + + var deltaEClassification: SwatchClassification? { + guard let de = displayedDeltaE else { return nil } + let settings = environment.settingsStore.load() + return ColorDifference.classify( + deltaE: de, + goodMax: settings.deltaEGoodMax, + warningMax: settings.deltaEWarningMax) + } + + /// `spotLabImplausible` — L* outside 0…100 still displays, unclamped. + var isDisplayedLabImplausible: Bool { + guard let l = displayedSample?.lab.l else { return false } + return l < 0 || l > 100 + } + + // MARK: - Sheet lifecycle + + /// Called from `SpotReadView.onAppear`. Resets the in-memory session + /// and runs detection once; a missing sidecar gets a wizard notice. + func sheetOpened() { + samples = [] + displayedSample = nil + displayedDeltaE = nil + log = [] + lastError = nil + state = .idle + prompt = "Press Start to open the instrument." + + guard sidecarAvailable else { + workflow.wizard.showNotice( + "spotread sidecar missing — run fetch-argyll", kind: .error) + return + } + detectInstruments() + } + + /// Called from `onDisappear` *and* the sheet's `onDismiss` — clearing + /// the flag alone is not enough; a live child must be quit and + /// killed (R14). + func sheetClosed() { + stopIfNeeded() + samples = [] + displayedSample = nil + displayedDeltaE = nil + log = [] + defaultMissing = false + } + + // MARK: - Detection + + func detectInstruments() { + guard canDetect else { return } + isDetecting = true + detectionError = nil + + Task { @MainActor [weak self] in + guard let self else { return } + // `instlist` is an exclusive lease (#116) — never spawn a + // second one; surface the busy state instead. + if await self.environment.runner.processManager.isRunning(ProcessID.instlist) { + self.detectionError = "Instrument detection is already running." + self.isDetecting = false + return + } + do { + let devices = try await self.environment.runner.detectInstruments() + self.instruments = devices + self.seedDefault(from: devices) + if case .device(let selected) = self.selectedInstrument, + !devices.contains(where: { $0.port == selected.port }) { + self.selectedInstrument = .auto + } + } catch { + self.detectionError = error.localizedDescription + } + self.isDetecting = false + } + } + + /// Seed the picker from `AppSettings.defaultInstrument`; no match → + /// `.auto` + `spotDefaultMissing`. + private func seedDefault(from devices: [InstrumentDevice]) { + guard let code = environment.settingsStore.load().defaultInstrument, + !code.isEmpty else { + defaultMissing = false + return + } + if let match = devices.first(where: { Self.matches(code: code, device: $0) }) { + selectedInstrument = .device(match) + defaultMissing = false + } else { + selectedInstrument = .auto + defaultMissing = true + } + } + + /// Whether an `instlist` device corresponds to a `printtarg -i` / + /// settings instrument code (`i1`, `CM`, `p3`, `SS`, `20`/`22`/`41`/`51`). + static func matches(code: String, device: InstrumentDevice) -> Bool { + let haystack = "\(device.name) \(device.type)".lowercased() + switch code { + case "i1": return haystack.contains("i1pro") && !haystack.contains("i1pro 3") && !haystack.contains("i1pro3") + case "p3": return haystack.contains("i1pro 3") || haystack.contains("i1pro3") + case "CM": return haystack.contains("colormunki") + case "SS": return haystack.contains("specbos") || haystack.contains("spectraval") || haystack.contains("spectroscan") || haystack.contains("spectro scan") + case "20": return haystack.contains("display 2") + case "22": return haystack.contains("display") + case "41": return haystack.contains("spyder 4") || haystack.contains("spyder 5") || haystack.contains("spyder4") || haystack.contains("spyder5") + case "51": return haystack.contains("spyder x") + default: return false + } + } + + /// Reverse of `matches` — most specific codes first. + static func code(for device: InstrumentDevice) -> String? { + for code in ["p3", "51", "41", "22", "20", "CM", "SS", "i1"] + where matches(code: code, device: device) { + return code + } + return nil + } + + /// `spotSetDefault` — writes `AppSettings.defaultInstrument` only. + /// Never touches `printtarg -i` or `targen`. + func applyDefaultToggle(_ on: Bool) { + setAsDefault = on + var settings = environment.settingsStore.load() + if on, case .device(let device) = selectedInstrument { + settings.defaultInstrument = Self.code(for: device) + } else if !on { + settings.defaultInstrument = nil + } + try? environment.settingsStore.save(settings) + } + + // MARK: - Session control + + func start() { + guard sidecarAvailable else { + lastError = "spotread sidecar missing — run fetch-argyll" + return + } + guard !isChartreadRunning else { + lastError = "Stop the Stage 3 chart read first." + return + } + guard let cwd = workflow.wizard.effectiveWorkingDirectory else { + lastError = "Set a working folder in Stage 1 first." + return + } + Task { @MainActor [weak self] in + guard let self else { return } + // `spotread` is an exclusive lease — a second Start while a + // child is live is an error, not a kill + respawn (#116). + if await self.environment.runner.processManager.isRunning(ProcessID.spotread) { + self.lastError = "A spotread session is already running." + return + } + self.begin(config: self.buildConfig(cwd: cwd)) + } + } + + private func buildConfig(cwd: URL) -> SpotReadConfig { + let port: Int? + let name: String + switch selectedInstrument { + case .auto: + port = nil + name = "Auto" + case .device(let device): + port = device.port + name = device.name + } + return SpotReadConfig( + workingDirectory: cwd, + selectedPort: selectedInstrument.chartreadPort, + enableLEDs: environment.settingsStore.load().enableI1Pro2Leds, + isXY: selectedInstrument.isXY, + instrumentName: name, + instrumentPort: port + ) + } + + private func begin(config: SpotReadConfig) { + isRunning = true + state = .idle + lastError = nil + prompt = "Waiting for a reading…" + + let stream = environment.runner.runSpotread(config: config) + streamTask = Task { @MainActor [weak self] in + guard let self else { return } + for await event in stream { + self.handle(event: event) + } + self.isRunning = false + self.state = .idle + if self.lastError == nil { + self.prompt = "Press Start to open the instrument." + } + } + } + + private func handle(event: SpotReadEvent) { + switch event { + case .prompt(let result): + state = result.state + prompt = promptText(for: result.state) + + case .sample(let sample): + let previous = samples.first + samples.insert(sample, at: 0) + if samples.count > historyLimit { + samples.removeLast() + } + displayedSample = sample + displayedDeltaE = previous.map { + ColorDifference.deltaE00($0.lab, sample.lab) + } + + case .log(let batch): + log.append(contentsOf: batch) + + case .exit(let code): + if code != 0 { + lastError = "spotread exited with code \(code)" + } + + case .failed(let error): + lastError = error.localizedDescription + } + } + + private func promptText(for state: ChartreadState) -> String { + switch state { + case .calibrating: + return "Place the instrument on the calibration tile, then Calibrate." + case .awaitingStrip: + return "Place on the patch, then Read." + case .reading, .promptContinue: + return "Waiting for a reading…" + case .warning: + return "Instrument warning — stop and restart if it persists." + case .error: + return "Read error — Stop, then Start again." + default: + return "Waiting for a reading…" + } + } + + // MARK: - Transport + + /// `btnSpotCalibrate` — same bytes Stage 3 sends for calibrate. + func calibrate() { + send(.trigger) + } + + /// `btnSpotTrigger` — the Read key (`" \n"`). + func trigger() { + send(.trigger) + } + + private func send(_ input: ChartreadInput) { + Task { @MainActor [weak self] in + guard let self, self.isRunning else { return } + try? await self.environment.runner.sendSpotreadInput(input) + } + } + + /// `btnSpotStop` / sheet dismiss: `q\n`, ~500 ms, then kill if the + /// child is still live. + func stopIfNeeded() { + guard isRunning else { return } + streamTask?.cancel() + streamTask = nil + let processManager = environment.runner.processManager + Task { @MainActor in + try? await processManager.sendStdin( + id: ProcessID.spotread, bytes: ChartreadInput.quit.bytes) + try? await Task.sleep(nanoseconds: 500_000_000) + await processManager.kill(id: ProcessID.spotread) + } + isRunning = false + state = .idle + prompt = "Press Start to open the instrument." + } + + // MARK: - History / export + + /// Click a history row: copies that sample into the last-sample card. + /// Never re-triggers the instrument. + func selectFromHistory(_ sample: SpotReadSample) { + displayedSample = sample + if let index = samples.firstIndex(of: sample), index + 1 < samples.count { + displayedDeltaE = ColorDifference.deltaE00(samples[index + 1].lab, sample.lab) + } else { + displayedDeltaE = nil + } + } + + /// `btnSpotCopyLab` — `L* a* b*` of the displayed sample as plain + /// text (`50.0 1.2 -3.4`). + func copyLab() { + guard let sample = displayedSample else { return } + let text = String(format: "%.1f %.1f %.1f", sample.lab.l, sample.lab.a, sample.lab.b) + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } + + /// `btnSpotExportCsv` — RFC-4180 via `selectCsvSavePath`. Cancel is + /// a no-op. Rows are newest-first, matching the history list. + func exportCsv() { + guard !samples.isEmpty else { return } + let url = UITestHooks.isEnabled + ? UITestHooks.csvExportURL + : fileDialogs.selectCsvSavePath() + guard let url else { return } + + var out = "timestamp,L,a,b,dE00,instrument,port\r\n" + for (index, sample) in samples.enumerated() { + let deltaE = index + 1 < samples.count + ? String(format: "%.2f", ColorDifference.deltaE00(samples[index + 1].lab, sample.lab)) + : "" + out += "\(csvField(iso8601(sample.timestamp))),\(f1(sample.lab.l)),\(f1(sample.lab.a)),\(f1(sample.lab.b)),\(deltaE),\(csvField(sample.instrumentName)),\(sample.port.map(String.init) ?? "")\r\n" + } + + do { + try out.write(to: url, atomically: true, encoding: .utf8) + workflow.wizard.showNotice("Spot readings exported: \(url.lastPathComponent)") + } catch { + workflow.wizard.showNotice( + "Export failed: \(error.localizedDescription)", kind: .error) + } + } + + private func f1(_ value: Double) -> String { + String(format: "%.1f", value) + } + + private func iso8601(_ date: Date) -> String { + ISO8601DateFormatter().string(from: date) + } + + private func csvField(_ text: String) -> String { + guard text.contains(",") || text.contains("\"") || text.contains("\n") else { + return text + } + return "\"\(text.replacingOccurrences(of: "\"", with: "\"\""))\"" + } +} diff --git a/Sources/ICCery/TargetWorkflowViewModel.swift b/Sources/ICCery/TargetWorkflowViewModel.swift index f89d37e..2e9af7f 100644 --- a/Sources/ICCery/TargetWorkflowViewModel.swift +++ b/Sources/ICCery/TargetWorkflowViewModel.swift @@ -97,6 +97,16 @@ final class TargetWorkflowViewModel: ObservableObject { @Published var savePresetName = "" @Published var savePresetDesc = "" + // MARK: - Media library (issue #146) + + @Published var showingSaveMedia = false + @Published var showingManageMedia = false + + // MARK: - Spot read (issue #148) + + /// `RootView` sheet binding for the spot-read console. + @Published var showingSpotRead = false + /// Stage 3 measurement workflow, owned at the app level so it persists /// across stage switches and can observe settings changes. @Published var measurement: MeasurementWorkflowViewModel @@ -107,6 +117,13 @@ final class TargetWorkflowViewModel: ObservableObject { @Published var calibration: CalibrationViewModel! /// Stage 2 unmanaged print session. @Published var print: PrintSessionViewModel! + /// Media recipe library — needs a complete `self`. + @Published var media: MediaLibraryViewModel! + /// 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 @@ -126,6 +143,18 @@ final class TargetWorkflowViewModel: ObservableObject { profile: self.profile, environment: environment ) + self.media = MediaLibraryViewModel( + workflow: self, + environment: environment + ) + self.spotRead = SpotReadViewModel( + workflow: self, + environment: environment + ) + self.project = ProjectSession( + workflow: self, + environment: environment + ) reloadPresets() } diff --git a/Tests/ICCeryCoreTests/ApproximateLabTests.swift b/Tests/ICCeryCoreTests/ApproximateLabTests.swift new file mode 100644 index 0000000..e0e65c3 --- /dev/null +++ b/Tests/ICCeryCoreTests/ApproximateLabTests.swift @@ -0,0 +1,39 @@ +import Foundation +import XCTest +@testable import ICCeryCore + +/// ``ApproximateLab`` sanity tests (issue #147). +/// +/// These are loose sanity checks on a fixed-matrix approximation — not +/// ColorSync goldens. +final class ApproximateLabTests: XCTestCase { + + func testWhiteMapsToHighLStar() { + let lab = ApproximateLab.srgb8ToLab(r: 255, g: 255, b: 255) + XCTAssertGreaterThan(lab.l, 95) + XCTAssertEqual(lab.a, 0, accuracy: 2) + XCTAssertEqual(lab.b, 0, accuracy: 2) + } + + func testBlackMapsToZeroLStar() { + let lab = ApproximateLab.srgb8ToLab(r: 0, g: 0, b: 0) + XCTAssertEqual(lab.l, 0, accuracy: 1) + } + + func testPureRedIsChromatic() { + let lab = ApproximateLab.srgb8ToLab(r: 255, g: 0, b: 0) + // sRGB red ≈ Lab D50 (54, 81, 70) — loose bounds only. + XCTAssertGreaterThan(lab.l, 40) + XCTAssertLessThan(lab.l, 65) + XCTAssertGreaterThan(lab.a, 60) + XCTAssertGreaterThan(lab.b, 40) + } + + func testMidGreyIsNeutral() { + let lab = ApproximateLab.srgb8ToLab(r: 128, g: 128, b: 128) + XCTAssertGreaterThan(lab.l, 45) + XCTAssertLessThan(lab.l, 65) + XCTAssertEqual(lab.a, 0, accuracy: 1) + XCTAssertEqual(lab.b, 0, accuracy: 1) + } +} diff --git a/Tests/ICCeryCoreTests/GamutContainmentTests.swift b/Tests/ICCeryCoreTests/GamutContainmentTests.swift new file mode 100644 index 0000000..c69cf8b --- /dev/null +++ b/Tests/ICCeryCoreTests/GamutContainmentTests.swift @@ -0,0 +1,85 @@ +import Foundation +import XCTest +@testable import ICCeryCore + +/// ``GamutGeometry`` containment and volume goldens on the bundled +/// `sRGB.gam` reference mesh (issue #147). No GPU involved. +final class GamutContainmentTests: XCTestCase { + + /// Returns the bundled real `sRGB.gam` in `Resources/Argyll/reference_gamuts`. + private var bundledSRGBGamURL: URL { + let bundle = Bundle.main + let resource = bundle.resourceURL ?? bundle.bundleURL + return resource.appendingPathComponent("Argyll/reference_gamuts/sRGB.gam") + } + + private func loadSRGB() throws -> GamutMesh { + try GamutMeshParser.parse(url: bundledSRGBGamURL) + } + + func testNeutralMidGreyIsInsideSRGB() throws { + let mesh = try loadSRGB() + XCTAssertEqual( + GamutGeometry.containment(of: LabColor(l: 50, a: 0, b: 0), in: mesh), + .inside) + } + + func testSaturatedColourIsOutsideSRGB() throws { + let mesh = try loadSRGB() + XCTAssertEqual( + GamutGeometry.containment(of: LabColor(l: 50, a: 80, b: 80), in: mesh), + .outside) + } + + func testVolumeIsFiniteAndPositiveOnBundledSRGB() throws { + let mesh = try loadSRGB() + let volume = GamutGeometry.volume(of: mesh) + XCTAssertTrue(volume.isFinite) + XCTAssertGreaterThan(volume, 0) + } + + func testVertexOnlyMeshReportsUnknown() { + let mesh = GamutMesh( + vertices: [ + GamutVertex(lab: LabColor(l: 50, a: 0, b: 0), rgb: DisplayRGB(r: 0.5, g: 0.5, b: 0.5)), + ], + faces: []) + XCTAssertEqual( + GamutGeometry.containment(of: LabColor(l: 50, a: 0, b: 0), in: mesh), + .unknown) + XCTAssertEqual(GamutGeometry.volume(of: mesh), 0) + } + + func testKnownCubeFixture() throws { + // Unit cube centred at Lab (50, 0, 0): a*,b* ∈ ±10, L* ∈ 40...60. + // Two triangles per face, outward winding. + let lab = { (l: Double, a: Double, b: Double) in + GamutVertex(lab: LabColor(l: l, a: a, b: b), rgb: DisplayRGB(r: 0, g: 0, b: 0)) + } + // Corners in (a, L, b) space. + let c = [ + lab(40, -10, -10), lab(40, 10, -10), lab(40, 10, 10), lab(40, -10, 10), // bottom + lab(60, -10, -10), lab(60, 10, -10), lab(60, 10, 10), lab(60, -10, 10), // top + ] + let quad = { (a: UInt32, b: UInt32, c: UInt32, d: UInt32) in + [GamutTriangle(a: a, b: b, c: c), GamutTriangle(a: a, b: c, c: d)] + } + var faces: [GamutTriangle] = [] + faces += quad(0, 3, 2, 1) // bottom (y=40) + faces += quad(4, 5, 6, 7) // top (y=60) + faces += quad(0, 1, 5, 4) // z=-10 + faces += quad(3, 7, 6, 2) // z=+10 + faces += quad(1, 2, 6, 5) // x=+10 + faces += quad(0, 4, 7, 3) // x=-10 + let mesh = GamutMesh(vertices: c, faces: faces) + + XCTAssertEqual( + GamutGeometry.containment(of: LabColor(l: 50, a: 0, b: 0), in: mesh), + .inside) + XCTAssertEqual( + GamutGeometry.containment(of: LabColor(l: 50, a: 20, b: 0), in: mesh), + .outside) + // 20 × 20 × 20 Lab-cube. + XCTAssertEqual(GamutGeometry.volume(of: mesh), 8000, accuracy: 1) + } +} diff --git a/Tests/ICCeryCoreTests/GamutViewModelTests.swift b/Tests/ICCeryCoreTests/GamutViewModelTests.swift new file mode 100644 index 0000000..f93108b --- /dev/null +++ b/Tests/ICCeryCoreTests/GamutViewModelTests.swift @@ -0,0 +1,137 @@ +import Foundation +import Metal +import XCTest +@testable import ICCeryCore +@testable import ICCery + +/// Issue #147 — `GamutViewModel` compare-slot behaviour: failed or +/// missing compare meshes are info, never fatal (#24); sRGB always stays. +@MainActor +final class GamutViewModelTests: XCTestCase { + + /// Bundled root that has `reference_gamuts/sRGB.gam` but **no** tool + /// binaries, so `iccgamut` spawns deterministically fail. + private func bundledRootWithoutTools() throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-gamut-vm-\(UUID().uuidString)") + let gamutDir = root.appendingPathComponent("reference_gamuts") + try FileManager.default.createDirectory( + at: gamutDir, withIntermediateDirectories: true) + let bundle = Bundle.main.resourceURL ?? Bundle.main.bundleURL + try FileManager.default.copyItem( + at: bundle.appendingPathComponent("Argyll/reference_gamuts/sRGB.gam"), + to: gamutDir.appendingPathComponent("sRGB.gam")) + return root + } + + private func makeViewModel(root: URL? = nil) throws -> GamutViewModel { + let env = try TestAppEnvironment.make(bundledArgyllRoot: root) + return GamutViewModel(environment: env.environment) + } + + func testInitialLoadHasSRGBAndFacesStatus() async throws { + let vm = try makeViewModel() + await vm.awaitInitialLoad() + + XCTAssertNotNil(vm.layer(id: GamutViewModel.srgbLayerID)) + XCTAssertTrue(vm.status.contains("faces"), "status: \(vm.status)") + } + + /// #147 — `viewerUnavailable` is decided before any `SCNView` is + /// mounted: it must exactly mirror Metal presence on this host. + func testViewerUnavailableMirrorsMetalAvailability() async throws { + let vm = try makeViewModel() + XCTAssertEqual( + vm.viewerUnavailable, + MTLCreateSystemDefaultDevice() == nil) + } + + func testMissingCompareGamLeavesSRGBAndSetsNotice() async throws { + let vm = try makeViewModel() + await vm.awaitInitialLoad() + + await vm.loadCompareGam( + url: URL(fileURLWithPath: "/nonexistent/compare.gam")) + + XCTAssertNotNil(vm.layer(id: GamutViewModel.srgbLayerID)) + XCTAssertNil(vm.layer(id: GamutViewModel.compareLayerID)) + XCTAssertNotNil(vm.noticeText) + } + + func testFailedIccgamutLeavesSRGBAndSetsNotice() async throws { + // bundledRootWithoutTools has no macos-universal/iccgamut. + let vm = try makeViewModel(root: bundledRootWithoutTools()) + await vm.awaitInitialLoad() + XCTAssertNotNil(vm.layer(id: GamutViewModel.srgbLayerID)) + + let profile = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-gamut-icc-\(UUID().uuidString).icc") + try Data("MOCK_ICC".utf8).write(to: profile) + defer { try? FileManager.default.removeItem(at: profile) } + + await vm.loadCompareProfile(url: profile) + + XCTAssertNotNil(vm.layer(id: GamutViewModel.srgbLayerID)) + XCTAssertNil(vm.layer(id: GamutViewModel.compareLayerID)) + XCTAssertNotNil(vm.noticeText) + } + + func testThirdProfileReplacesCompareSlot() async throws { + let vm = try makeViewModel() + await vm.awaitInitialLoad() + + let bundle = Bundle.main.resourceURL ?? Bundle.main.bundleURL + let srgb = bundle.appendingPathComponent("Argyll/reference_gamuts/sRGB.gam") + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-gamut-cmp-\(UUID().uuidString)") + try FileManager.default.createDirectory( + at: dir, withIntermediateDirectories: true) + let first = dir.appendingPathComponent("first.gam") + let second = dir.appendingPathComponent("second.gam") + try FileManager.default.copyItem(at: srgb, to: first) + try FileManager.default.copyItem(at: srgb, to: second) + defer { try? FileManager.default.removeItem(at: dir) } + + await vm.loadCompareGam(url: first) + XCTAssertNil(vm.noticeText) + XCTAssertEqual(vm.layer(id: GamutViewModel.compareLayerID)?.displayName, "first") + + await vm.loadCompareGam(url: second) + XCTAssertEqual(vm.layer(id: GamutViewModel.compareLayerID)?.displayName, "second") + XCTAssertEqual( + vm.layers.filter { $0.role == .profileB }.count, 1, + "compare slot holds one profile") + XCTAssertNotNil(vm.noticeText) + XCTAssertNotNil(vm.layer(id: GamutViewModel.srgbLayerID)) + } + + func testRemoveCompareLeavesSRGB() async throws { + let vm = try makeViewModel() + await vm.awaitInitialLoad() + + let bundle = Bundle.main.resourceURL ?? Bundle.main.bundleURL + await vm.loadCompareGam( + url: bundle.appendingPathComponent("Argyll/reference_gamuts/sRGB.gam")) + XCTAssertNotNil(vm.layer(id: GamutViewModel.compareLayerID)) + + vm.removeCompare() + XCTAssertNil(vm.layer(id: GamutViewModel.compareLayerID)) + XCTAssertNotNil(vm.layer(id: GamutViewModel.srgbLayerID)) + } + + func testInspectLabRunsContainmentPerLayer() async throws { + let vm = try makeViewModel() + await vm.awaitInitialLoad() + + vm.labEntryL = "50" + vm.labEntryA = "0" + vm.labEntryB = "0" + XCTAssertTrue(vm.canInspectLab) + vm.inspectEnteredLab() + + let srgb = vm.inspectResults.first { $0.id == GamutViewModel.srgbLayerID } + XCTAssertEqual(srgb?.containment, .inside) + XCTAssertTrue(vm.inspectIsApproximate) + XCTAssertNotNil(vm.inspectSwatch) + } +} 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/MediaLibraryStoreTests.swift b/Tests/ICCeryCoreTests/MediaLibraryStoreTests.swift new file mode 100644 index 0000000..c72d108 --- /dev/null +++ b/Tests/ICCeryCoreTests/MediaLibraryStoreTests.swift @@ -0,0 +1,96 @@ +import Foundation +import XCTest +@testable import ICCeryCore + +/// Issue #146 — `MediaLibraryStore` persistence contract. +final class MediaLibraryStoreTests: XCTestCase { + + private var url: URL! + + override func setUp() { + url = FileManager.default.temporaryDirectory + .appendingPathComponent("media-lib-\(UUID().uuidString).json") + } + + override func tearDown() { + try? FileManager.default.removeItem(at: url) + url = nil + } + + private func recipe(id: String, name: String = "R") -> MediaRecipe { + MediaRecipe( + id: id, name: name, printerID: "q", + colourSpace: "rgb", presetID: "preset-std-rgb") + } + + func testRoundTrip() async throws { + let store = MediaLibraryStore(url: url) + let r = recipe(id: "recipe-1", name: "Epson Rag") + try await store.upsert(r) + let loaded = try await store.load() + XCTAssertEqual(loaded, [r]) + } + + func testCorruptFileThrowsAndPreservesBytes() async throws { + try "not json".write(to: url, atomically: true, encoding: .utf8) + let before = try Data(contentsOf: url) + + let store = MediaLibraryStore(url: url) + await assertAsyncThrows(expectedType: DecodingError.self) { + try await store.load() + } + // upsert must also propagate — a corrupt file is never wiped. + await assertAsyncThrows(expectedType: DecodingError.self) { + try await store.upsert(recipe(id: "x")) + } + XCTAssertEqual(try Data(contentsOf: url), before) + } + + func testDelete() async throws { + let store = MediaLibraryStore(url: url) + try await store.upsert(recipe(id: "a")) + try await store.upsert(recipe(id: "b")) + + let removed = try await store.delete(id: "a") + XCTAssertTrue(removed) + let remaining = try await store.load().map(\.id) + XCTAssertEqual(remaining, ["b"]) + + let again = try await store.delete(id: "a") + XCTAssertFalse(again) + } + + func testCapacityReached() async throws { + let store = MediaLibraryStore(url: url, capacity: 2) + try await store.upsert(recipe(id: "1")) + try await store.upsert(recipe(id: "2")) + await assertAsyncThrows( + expectedType: MediaLibraryStore.MediaLibraryError.self + ) { + try await store.upsert(recipe(id: "3")) + } errorHandler: { + XCTAssertEqual($0, .capacityReached(2)) + } + let stored = try await store.load() + XCTAssertEqual(stored.count, 2) + } + + func testUpsertPreservesCreatedBumpsUpdated() async throws { + let store = MediaLibraryStore(url: url) + var r = recipe(id: "recipe-1") + r.created = Date(timeIntervalSince1970: 1_000_000) + r.updated = Date(timeIntervalSince1970: 1_000_000) + try await store.upsert(r) + + var edit = r + edit.name = "Renamed" + edit.updated = Date(timeIntervalSince1970: 2_000_000) + try await store.upsert(edit) + + let loaded = try await store.load() + XCTAssertEqual(loaded.count, 1) + XCTAssertEqual(loaded[0].name, "Renamed") + XCTAssertEqual(loaded[0].created, r.created) + XCTAssertGreaterThan(loaded[0].updated, r.updated) + } +} diff --git a/Tests/ICCeryCoreTests/MediaLibraryViewModelTests.swift b/Tests/ICCeryCoreTests/MediaLibraryViewModelTests.swift new file mode 100644 index 0000000..d62e6ce --- /dev/null +++ b/Tests/ICCeryCoreTests/MediaLibraryViewModelTests.swift @@ -0,0 +1,406 @@ +import Foundation +import XCTest +@testable import ICCeryCore +@testable import ICCery + +/// Issue #146 — `MediaLibraryViewModel` apply / capture / staleness +/// under an isolated `TestAppEnvironment` with a mock CUPS `bin` dir. +@MainActor +final class MediaLibraryViewModelTests: XCTestCase { + + // CTI3 fixture mirrored from CalibrationStoreTests. + private static let sampleCal = """ + CTI3 + DESCRIPTOR "Test printer" + COLOR_REP "RGB" + DEVICE_CLASS "OUTPUT" + MAX_TAC "300" + NUMBER_OF_FIELDS 5 + NUMBER_OF_SETS 3 + BEGIN_DATA_FORMAT + SAMPLE_ID INPUT_VALUE R G B + END_DATA_FORMAT + BEGIN_DATA + 1 0 0 0 0 + 2 128 64 64 64 + 3 255 255 255 255 + END_DATA + """ + + /// Same fixture plus an old CREATED keyword so the age check fires. + private static let staleCal = """ + CTI3 + DESCRIPTOR "Test printer" + CREATED "2020-01-01T00:00:00Z" + COLOR_REP "RGB" + DEVICE_CLASS "OUTPUT" + NUMBER_OF_FIELDS 5 + NUMBER_OF_SETS 3 + BEGIN_DATA_FORMAT + SAMPLE_ID INPUT_VALUE R G B + END_DATA_FORMAT + BEGIN_DATA + 1 0 0 0 0 + 2 128 64 64 64 + 3 255 255 255 255 + END_DATA + """ + + private var env: TestAppEnvironment! + private var workflow: TargetWorkflowViewModel! + private var media: MediaLibraryViewModel! + + override func setUp() async throws { + env = try TestAppEnvironment.make() + try installMockCups() + workflow = TargetWorkflowViewModel(environment: env.environment) + media = workflow.media + await media.reloadAsync() + } + + override func tearDown() async throws { + env?.cleanup() + env = nil + workflow = nil + media = nil + } + + /// Mock `lpstat`/`lpoptions` inside the env's `cups-bin` (the + /// `CupsService.binaryDir` `TestAppEnvironment` points at). Queues: + /// `Mock_Queue` (default) and `Other_Queue`. + private func installMockCups() throws { + let bin = env.root.appendingPathComponent("cups-bin") + try FileManager.default.createDirectory( + at: bin, withIntermediateDirectories: true) + + let lpstat = """ + #!/bin/sh + case "$1" in + -e) printf 'Mock_Queue\\nOther_Queue\\n' ;; + -p) printf 'printer Mock_Queue is idle.\\nprinter Other_Queue is idle.\\n' ;; + -d) printf 'system default destination: Mock_Queue\\n' ;; + esac + exit 0 + """ + let lpoptions = """ + #!/bin/sh + list=0 + queue="" + for arg in "$@"; do + case "$arg" in + -l) list=1 ;; + -p) ;; + *) queue="$arg" ;; + esac + done + if [ "$list" = "1" ]; then + printf 'PageSize/Media Size: *A4 Letter\\n' + printf 'MediaType/Media Type: *Stationery Glossy\\n' + exit 0 + fi + printf "printer-info='Mock %s' printer-type=42\\n" "$queue" + exit 0 + """ + for (name, body) in ["lpstat": lpstat, "lpoptions": lpoptions] { + let url = bin.appendingPathComponent(name) + try body.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], ofItemAtPath: url.path) + } + } + + private func writeCal( + _ contents: String = MediaLibraryViewModelTests.sampleCal, + named name: String = "recipe.cal" + ) throws -> String { + let url = env.root.appendingPathComponent(name) + try contents.write(to: url, atomically: true, encoding: .utf8) + return url.path + } + + private func makeRecipe( + id: String = "recipe-t1", + printerID: String = "Mock_Queue", + colourSpace: String = "rgb", + presetID: String = "preset-std-rgb", + calibrationURL: String? = nil, + applyCalibration: Bool = false + ) -> MediaRecipe { + MediaRecipe( + id: id, + name: "Test Recipe", + printerID: printerID, + printerDisplayName: "Mock Queue Display", + paperName: "Rag", + inkSet: "PK", + colourSpace: colourSpace, + presetID: presetID, + calibrationURL: calibrationURL, + applyCalibration: applyCalibration) + } + + private func seed(_ recipe: MediaRecipe) async throws { + try await env.environment.mediaStore.upsert(recipe) + await media.reloadAsync() + } + + // MARK: - Apply + + func testApplyHappyPath() async throws { + let calPath = try writeCal() + let r = makeRecipe( + calibrationURL: calPath, applyCalibration: true) + try await seed(r) + + let applied = await media.apply(r) + + XCTAssertTrue(applied) + XCTAssertEqual(workflow.print.selectedPrinter, "Mock_Queue") + XCTAssertEqual(workflow.wizard.printerName, "Mock Queue Display") + XCTAssertTrue(workflow.profile.applyCalibration) + XCTAssertEqual(workflow.profile.calibrationFile, calPath) + XCTAssertEqual(media.selectedRecipeID, r.id) + XCTAssertEqual(workflow.selectedPresetID, "preset-std-rgb") + XCTAssertEqual( + workflow.buildPrinttargConfig().calibrationFile, calPath) + } + + func testApplyMissingPresetRefuses() async throws { + let r = makeRecipe(presetID: "preset-nonexistent") + try await seed(r) + + let applied = await media.apply(r) + + XCTAssertFalse(applied) + XCTAssertEqual(media.selectedRecipeID, "none") + XCTAssertEqual(workflow.selectedPresetID, "none") + XCTAssertTrue( + workflow.wizard.notice?.text.contains("no longer exists") == true) + } + + func testApplyColourSpaceMismatchRefuses() async throws { + let r = makeRecipe(colourSpace: "cmyk", presetID: "preset-std-rgb") + try await seed(r) + + let applied = await media.apply(r) + + XCTAssertFalse(applied) + XCTAssertEqual(media.selectedRecipeID, "none") + XCTAssertTrue( + workflow.wizard.notice?.text.contains("colour space") == true) + } + + func testApplyMissingCalFileFails() async throws { + let missing = env.root.appendingPathComponent("gone.cal").path + let r = makeRecipe( + calibrationURL: missing, applyCalibration: true) + try await seed(r) + + let applied = await media.apply(r) + + XCTAssertFalse(applied) + XCTAssertFalse(workflow.profile.applyCalibration) + XCTAssertEqual(workflow.profile.calibrationFile, missing) + XCTAssertEqual(workflow.wizard.notice?.kind, .error) + XCTAssertEqual(media.selectedRecipeID, "none") + } + + func testApplyCalPrefixedCalCannotArmK() async throws { + let calPath = try writeCal(named: "CAL_target.cal") + let r = makeRecipe( + calibrationURL: calPath, applyCalibration: true) + try await seed(r) + + let applied = await media.apply(r) + + // Success with warning — the refusal is permanent, re-clicking + // cannot unstick it (decision 6). + XCTAssertTrue(applied) + XCTAssertFalse(workflow.profile.applyCalibration) + XCTAssertEqual(workflow.profile.calibrationFile, calPath) + XCTAssertNil(workflow.buildPrinttargConfig().calibrationFile) + XCTAssertEqual(media.selectedRecipeID, r.id) + XCTAssertEqual(workflow.wizard.notice?.kind, .warning) + } + + func testApplyLiveCalBasenameBlocks() async throws { + let calPath = try writeCal() + let r = makeRecipe( + calibrationURL: calPath, applyCalibration: true) + try await seed(r) + workflow.wizard.basename = "CAL_live" + + let applied = await media.apply(r) + + XCTAssertTrue(applied) + XCTAssertFalse(workflow.profile.applyCalibration) + XCTAssertNil(workflow.buildPrinttargConfig().calibrationFile) + } + + func testApplyMissingQueueLeavesQueueUntouched() async throws { + workflow.print.selectedPrinter = "Other_Queue" + let r = makeRecipe(printerID: "No_Such_Queue") + try await seed(r) + + let applied = await media.apply(r) + + XCTAssertFalse(applied) + XCTAssertEqual(workflow.print.selectedPrinter, "Other_Queue") + XCTAssertTrue( + workflow.wizard.notice?.text.contains("is not installed") == true) + XCTAssertEqual(media.selectedRecipeID, "none") + } + + // MARK: - Capture + + func testCaptureCopiesPrinterAndPreset() async throws { + workflow.print.printers = [ + Printer(name: "Mock_Queue", displayName: "Mock Queue Display") + ] + workflow.print.selectedPrinter = "Mock_Queue" + workflow.selectedPresetID = "preset-std-rgb" + media.saveMediaName = "My Recipe" + media.saveMediaPaper = "Rag" + media.saveMediaInk = "PK" + + let saved = await media.captureFromSession() + + XCTAssertTrue(saved) + let stored = await env.environment.mediaStore.all() + XCTAssertEqual(stored.count, 1) + XCTAssertEqual(stored[0].printerID, "Mock_Queue") + XCTAssertEqual(stored[0].printerDisplayName, "Mock Queue Display") + XCTAssertEqual(stored[0].presetID, "preset-std-rgb") + XCTAssertEqual(stored[0].colourSpace, "rgb") + XCTAssertEqual(media.selectedRecipeID, stored[0].id) + } + + func testCaptureNoPresetAutoSnapshots() async throws { + workflow.print.printers = [ + Printer(name: "Mock_Queue", displayName: "Mock Queue Display") + ] + workflow.print.selectedPrinter = "Mock_Queue" + workflow.selectedPresetID = "none" + media.saveMediaName = "Snap" + media.saveMediaPaper = "Rag" + media.saveMediaInk = "MK" + + let saved = await media.captureFromSession() + + XCTAssertTrue(saved) + let stored = await env.environment.mediaStore.all() + XCTAssertEqual(stored.count, 1) + XCTAssertTrue(stored[0].presetID.hasPrefix("custom-")) + XCTAssertTrue( + env.environment.presetStore.customs() + .contains { $0.id == stored[0].presetID }) + XCTAssertEqual(workflow.selectedPresetID, stored[0].presetID) + } + + func testCaptureRequiresPrinter() async { + workflow.print.selectedPrinter = "" + media.saveMediaName = "n" + media.saveMediaPaper = "p" + media.saveMediaInk = "i" + + let saved = await media.captureFromSession() + + XCTAssertFalse(saved) + XCTAssertNotNil(media.saveMediaError) + } + + func testCaptureForcesOffCalToggleForCalFile() async throws { + let calPath = try writeCal(named: "CAL_target.cal") + workflow.profile.calibrationFile = calPath + workflow.profile.applyCalibration = true + workflow.print.printers = [Printer(name: "Mock_Queue")] + workflow.print.selectedPrinter = "Mock_Queue" + workflow.selectedPresetID = "preset-std-rgb" + media.saveMediaName = "n" + media.saveMediaPaper = "p" + media.saveMediaInk = "i" + media.saveMediaApplyCal = true // forced off by calApplyable + + XCTAssertFalse(media.calApplyable) + let saved = await media.captureFromSession() + + XCTAssertTrue(saved) + let stored = await env.environment.mediaStore.all() + XCTAssertEqual(stored[0].calibrationURL, calPath) // verbatim + XCTAssertFalse(stored[0].applyCalibration) + } + + // MARK: - Staleness + + func testStaleCalFlagged() async throws { + let calPath = try writeCal( + MediaLibraryViewModelTests.staleCal, named: "old.cal") + let r = makeRecipe(calibrationURL: calPath) + try await seed(r) + workflow.print.printers = [Printer(name: "Mock_Queue")] + + await media.refreshStalenessAsync() + + XCTAssertTrue( + media.staleReasons[r.id]?.contains(.calibration) == true) + XCTAssertNotNil(media.calAgeDays[r.id]) + } + + func testAbsentQueueFlaggedOnlyWhenListNonEmpty() async throws { + let r = makeRecipe(printerID: "No_Such_Queue") + try await seed(r) + + // Un-enumerated list is indeterminate → no flag. + workflow.print.printers = [] + await media.refreshStalenessAsync() + XCTAssertNil(media.staleReasons[r.id]) + + // Absent from a non-empty list → .printer. + workflow.print.printers = [Printer(name: "Other_Queue")] + await media.refreshStalenessAsync() + XCTAssertTrue( + media.staleReasons[r.id]?.contains(.printer) == true) + + // Present but unselected → no flag. + workflow.print.printers = [ + Printer(name: "Other_Queue"), Printer(name: "No_Such_Queue"), + ] + workflow.print.selectedPrinter = "Other_Queue" + await media.refreshStalenessAsync() + XCTAssertNil(media.staleReasons[r.id]) + } + + // MARK: - Delete + + func testDeleteResetsSelection() async throws { + let r = makeRecipe() + try await seed(r) + media.selectedRecipeID = r.id + + media.delete(r) + try await Task.sleep(nanoseconds: 200_000_000) + + XCTAssertEqual(media.selectedRecipeID, "none") + let stored = await env.environment.mediaStore.all() + XCTAssertTrue(stored.isEmpty) + } + + // MARK: - Corrupt library + + func testCorruptLibraryKeepsFileAndWarns() async throws { + // Fresh environment so the store's `loaded` flag is still false. + let env2 = try TestAppEnvironment.make() + defer { env2.cleanup() } + try "garbage".write( + to: env2.mediaLibraryURL, atomically: true, encoding: .utf8) + let workflow2 = TargetWorkflowViewModel( + environment: env2.environment) + await workflow2.media.reloadAsync() + + XCTAssertTrue(workflow2.media.recipes.isEmpty) + XCTAssertEqual(workflow2.media.selectedRecipeID, "none") + XCTAssertEqual(workflow2.wizard.notice?.kind, .warning) + XCTAssertEqual(try Data(contentsOf: env2.mediaLibraryURL), + "garbage".data(using: .utf8)) + } +} diff --git a/Tests/ICCeryCoreTests/MediaRecipeTests.swift b/Tests/ICCeryCoreTests/MediaRecipeTests.swift new file mode 100644 index 0000000..1c02039 --- /dev/null +++ b/Tests/ICCeryCoreTests/MediaRecipeTests.swift @@ -0,0 +1,126 @@ +import Foundation +import XCTest +@testable import ICCeryCore + +/// Issue #146 — `MediaRecipe` Codable + validation contract. +final class MediaRecipeTests: XCTestCase { + + private func makeRecipe() -> MediaRecipe { + MediaRecipe( + id: "recipe-abc", + name: "Epson Rag", + notes: "notes", + printerID: "epson_p900", + printerDisplayName: "Epson SureColor P900", + paperName: "Rag Photographique", + driverMediaType: "PhotographicGlossy", + inkSet: "PK", + colourSpace: "rgb", + presetID: "preset-std-rgb", + calibrationURL: "/tmp/prof.cal", + applyCalibration: true, + created: Date(timeIntervalSince1970: 1_700_000_000), + updated: Date(timeIntervalSince1970: 1_700_000_100) + ) + } + + func testRoundTripSnakeCase() throws { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(makeRecipe()) + let object = try JSONSerialization.jsonObject(with: data) as! [String: Any] + + for key in [ + "printer_id", "printer_display_name", "paper_name", + "driver_media_type", "ink_set", "colour_space", "preset_id", + "calibration_url", "apply_calibration", "created", "updated", + "id", "name", "notes", + ] { + XCTAssertNotNil(object[key], "missing key \(key)") + } + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let decoded = try decoder.decode(MediaRecipe.self, from: data) + XCTAssertEqual(decoded, makeRecipe()) + } + + func testMissingRequiredKeyThrows() { + for key in ["id", "name", "printer_id", "colour_space", "preset_id"] { + var dict: [String: Any] = [ + "id": "r1", "name": "n", "printer_id": "q", + "colour_space": "rgb", "preset_id": "p", + ] + dict.removeValue(forKey: key) + let data = try! JSONSerialization.data(withJSONObject: dict) + XCTAssertThrowsError( + try JSONDecoder().decode(MediaRecipe.self, from: data), + "expected throw without \(key)") + } + } + + func testUnknownKeysIgnored() throws { + let dict: [String: Any] = [ + "id": "r1", "name": "n", "printer_id": "q", + "colour_space": "rgb", "preset_id": "p", + "future_field": "ignored", + ] + let data = try JSONSerialization.data(withJSONObject: dict) + let recipe = try JSONDecoder().decode(MediaRecipe.self, from: data) + XCTAssertEqual(recipe.id, "r1") + XCTAssertEqual(recipe.notes, "") + XCTAssertFalse(recipe.applyCalibration) + } + + func testValidatedGoldens() { + var r = makeRecipe() + + r.name = " " + XCTAssertThrowsError(try r.validated()) { + XCTAssertEqual($0 as? MediaRecipe.ValidationError, .emptyName) + } + + r = makeRecipe() + r.printerID = "" + XCTAssertThrowsError(try r.validated()) { + XCTAssertEqual($0 as? MediaRecipe.ValidationError, .emptyPrinterID) + } + + r = makeRecipe() + r.presetID = "" + XCTAssertThrowsError(try r.validated()) { + XCTAssertEqual($0 as? MediaRecipe.ValidationError, .emptyPresetID) + } + + r = makeRecipe() + r.colourSpace = "lab" + XCTAssertThrowsError(try r.validated()) { + XCTAssertEqual( + $0 as? MediaRecipe.ValidationError, .invalidColourSpace("lab")) + } + + for bad in ["../evil.cal", "rel/path.cal", "/tmp/a\0b.cal"] { + r = makeRecipe() + r.calibrationURL = bad + XCTAssertThrowsError(try r.validated(), "expected throw for \(bad)") { + XCTAssertEqual( + $0 as? MediaRecipe.ValidationError, + .invalidCalibrationURL(bad)) + } + } + } + + func testColourSpaceNormalisedToLowercase() throws { + var r = makeRecipe() + r.colourSpace = "RGB" + let validated = try r.validated() + XCTAssertEqual(validated.colourSpace, "rgb") + } + + func testCalPrefixedCalNameIsSchemaValid() throws { + var r = makeRecipe() + // CAL_ refusal is an apply-time policy, not a schema error. + r.calibrationURL = "/tmp/CAL_target.cal" + XCTAssertNoThrow(try r.validated()) + } +} 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/SpotReadArgsTests.swift b/Tests/ICCeryCoreTests/SpotReadArgsTests.swift new file mode 100644 index 0000000..49cb326 --- /dev/null +++ b/Tests/ICCeryCoreTests/SpotReadArgsTests.swift @@ -0,0 +1,46 @@ +import XCTest +@testable import ICCeryCore + +/// `SpotReadArgs` goldens (issue #148): +/// `spotread -v -e [-c port] [-Y l]` — never `-u`, never a basename, +/// `-c` only for ports > 1, `-Y l` only when the LED setting is on. +final class SpotReadArgsTests: XCTestCase { + + func testAutoOmitsPort() { + let args = SpotReadArgs.build(config: SpotReadConfig()) + XCTAssertEqual(args, ["-v", "-e"]) + } + + func testPort1OmitsC() { + let args = SpotReadArgs.build(config: SpotReadConfig(selectedPort: 1)) + XCTAssertEqual(args, ["-v", "-e"]) + } + + func testPort2IncludesC() { + let args = SpotReadArgs.build(config: SpotReadConfig(selectedPort: 2)) + XCTAssertEqual(args, ["-v", "-e", "-c", "2"]) + } + + func testLedFlag() { + let args = SpotReadArgs.build(config: SpotReadConfig(enableLEDs: true)) + XCTAssertEqual(args, ["-v", "-e", "-Y", "l"]) + } + + func testPortAndLeds() { + let args = SpotReadArgs.build( + config: SpotReadConfig(selectedPort: 2, enableLEDs: true)) + XCTAssertEqual(args, ["-v", "-e", "-c", "2", "-Y", "l"]) + } + + func testNeverU() { + for config in [ + SpotReadConfig(), + SpotReadConfig(selectedPort: 2), + SpotReadConfig(enableLEDs: true), + SpotReadConfig(selectedPort: 3, enableLEDs: true), + ] { + XCTAssertFalse(SpotReadArgs.build(config: config).contains("-u")) + XCTAssertFalse(SpotReadArgs.build(config: config).contains("-d")) + } + } +} diff --git a/Tests/ICCeryCoreTests/SpotReadClassifierTests.swift b/Tests/ICCeryCoreTests/SpotReadClassifierTests.swift new file mode 100644 index 0000000..04062ac --- /dev/null +++ b/Tests/ICCeryCoreTests/SpotReadClassifierTests.swift @@ -0,0 +1,91 @@ +import XCTest +@testable import ICCeryCore + +/// `SpotReadClassifier` / `SpotReadParser` against real `spotread` +/// phrasing (issue #148). The calibration-tile line classifies through +/// `ChartreadClassifier`; the spot prompt and its continuation lines +/// need the spot-specific matchers. +final class SpotReadClassifierTests: XCTestCase { + + // Real `spotread` stdout (calibration then spot prompt). + private let calibrateLines = [ + "Spot read needs a calibration before continuing", + "Place instrument on spot reading white calibration tile,", + " and then hit any key to continue,", + "or hit Esc or Q to abort:", + ] + private let spotPromptLines = [ + "Place instrument on a spot to be measured,", + " and hit a key to take a reading,", + "or hit Esc or Q to abort:", + ] + + func testCalibrationPrompt() { + var state = ChartreadState.idle + for line in calibrateLines { + state = SpotReadClassifier.classify(line: line, previousState: state).state + } + XCTAssertEqual(state, .calibrating) + } + + func testSpotPromptIsAwaitingTrigger() { + var state = ChartreadState.calibrating + for line in spotPromptLines { + state = SpotReadClassifier.classify(line: line, previousState: state).state + } + XCTAssertEqual(state, .awaitingStrip) + } + + func testAbortLineDoesNotBecomeWarning() { + // "or hit Esc or Q to abort:" contains no '?' but does contain + // "abort" — it must stay on the current prompt, never flip to + // a warning. + let r = SpotReadClassifier.classify( + line: "or hit Esc or Q to abort:", previousState: .awaitingStrip) + XCTAssertEqual(r.state, .awaitingStrip) + } + + func testParseResultLine() throws { + let parsed = SpotReadParser.parse( + line: "Result is XYZ: 18.51 20.05 15.71, D50 Lab: 51.9 -8.3 12.2") + let lab = try XCTUnwrap(parsed?.lab) + XCTAssertEqual(lab.l, 51.9, accuracy: 0.001) + XCTAssertEqual(lab.a, -8.3, accuracy: 0.001) + XCTAssertEqual(lab.b, 12.2, accuracy: 0.001) + let xyz = try XCTUnwrap(parsed?.xyz) + XCTAssertEqual(xyz.x, 18.51, accuracy: 0.001) + XCTAssertEqual(xyz.y, 20.05, accuracy: 0.001) + XCTAssertEqual(xyz.z, 15.71, accuracy: 0.001) + } + + func testParseLabOnlyLine() throws { + let parsed = SpotReadParser.parse(line: "Result is Lab: 40.0 1.2 -3.4") + let lab = try XCTUnwrap(parsed?.lab) + XCTAssertEqual(lab.l, 40.0, accuracy: 0.001) + XCTAssertNil(parsed?.xyz) + } + + func testNonSampleLineParsesNil() { + XCTAssertNil(SpotReadParser.parse(line: "Place instrument on a spot to be measured,")) + XCTAssertNil(SpotReadParser.parse(line: "Calibration successful.")) + XCTAssertNil(SpotReadParser.parse(line: "")) + } + + func testDeltaEBetweenFixtures() { + let a = SpotReadParser.parse( + line: "Result is XYZ: 18.51 20.05 15.71, D50 Lab: 51.9 -8.3 12.2")!.lab + let b = SpotReadParser.parse( + line: "Result is XYZ: 19.00 20.50 16.00, D50 Lab: 52.3 -8.0 12.6")!.lab + XCTAssertEqual(ColorDifference.deltaE00(a, a), 0, accuracy: 0.0001) + XCTAssertGreaterThan(ColorDifference.deltaE00(a, b), 0) + XCTAssertEqual( + ColorDifference.classify(deltaE: 1.0, goodMax: 2.0, warningMax: 5.0), + .good) + XCTAssertEqual( + ColorDifference.classify(deltaE: 3.0, goodMax: 2.0, warningMax: 5.0), + .warning) + XCTAssertEqual( + ColorDifference.classify(deltaE: 6.0, goodMax: 2.0, warningMax: 5.0), + .bad) + } +} diff --git a/Tests/ICCeryCoreTests/SpotReadViewModelTests.swift b/Tests/ICCeryCoreTests/SpotReadViewModelTests.swift new file mode 100644 index 0000000..c6a4ffc --- /dev/null +++ b/Tests/ICCeryCoreTests/SpotReadViewModelTests.swift @@ -0,0 +1,250 @@ +import Foundation +import XCTest +@testable import ICCeryCore +@testable import ICCery + +/// Issue #148 — `SpotReadViewModel` under an isolated +/// `TestAppEnvironment` with per-test mock `spotread`/`instlist` +/// sidecars in a temp bin dir. +@MainActor +final class SpotReadViewModelTests: XCTestCase { + + private var env: TestAppEnvironment! + private var workflow: TargetWorkflowViewModel! + private var spot: SpotReadViewModel! + private var binDir: URL! + + override func setUp() async throws { + binDir = FileManager.default.temporaryDirectory + .appendingPathComponent("spot-bin-\(UUID().uuidString)") + try FileManager.default.createDirectory( + at: binDir, withIntermediateDirectories: true) + // `bundledArgyllRoot` also points at the temp bin dir so the + // real sidecars copied into the host app by the build phase do + // not mask a missing `spotread` in the override dir. + env = try TestAppEnvironment.make( + argyllBinDir: binDir, bundledArgyllRoot: binDir) + workflow = TargetWorkflowViewModel(environment: env.environment) + spot = workflow.spotRead + workflow.wizard.setTarget(basename: "spot", workingDirectory: env.root) + } + + override func tearDown() async throws { + spot?.stopIfNeeded() + try? await Task.sleep(nanoseconds: 700_000_000) + env?.cleanup() + try? FileManager.default.removeItem(at: binDir) + env = nil + workflow = nil + spot = nil + binDir = nil + } + + // MARK: - Helpers + + private func writeMock(_ name: String, _ body: String) throws { + let url = binDir.appendingPathComponent(name) + try body.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], ofItemAtPath: url.path) + } + + private func installInstlist(_ devicesJson: String) throws { + try writeMock("instlist", """ + #!/bin/sh + printf '%s' '\(devicesJson)' + exit 0 + """) + } + + private func installSpotread(lab: String = "51.9 -8.3 12.2") throws { + try writeMock("spotread", """ + #!/bin/sh + echo "Spot read needs a calibration before continuing" + echo "Place instrument on spot reading white calibration tile," + echo " and then hit any key to continue," + echo "or hit Esc or Q to abort:" + IFS= read -r line || exit 0 + echo "Calibration successful." + while true; do + echo "Place instrument on a spot to be measured," + echo " and hit a key to take a reading," + echo "or hit Esc or Q to abort:" + IFS= read -r line || exit 0 + case "$line" in + q*|Q*) exit 0 ;; + esac + echo "Result is XYZ: 18.51 20.05 15.71, D50 Lab: \(lab)" + done + """) + } + + private func waitFor( + _ predicate: @escaping () async -> Bool, + timeout: TimeInterval = 10 + ) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if await predicate() { return true } + try? await Task.sleep(nanoseconds: 50_000_000) + } + return await predicate() + } + + private func waitForSync( + _ predicate: @escaping () -> Bool, + timeout: TimeInterval = 10 + ) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if predicate() { return true } + try? await Task.sleep(nanoseconds: 50_000_000) + } + return predicate() + } + + // MARK: - Missing sidecar + + func testMissingSidecarNoSpawn() async throws { + // bin dir has no spotread → resolver override misses and the + // bundled path does not exist either. + XCTAssertFalse(spot.sidecarAvailable) + spot.sheetOpened() + XCTAssertEqual(workflow.wizard.notice?.kind, .error) + + spot.start() + XCTAssertEqual(spot.lastError, "spotread sidecar missing — run fetch-argyll") + XCTAssertFalse(spot.isRunning) + let running = await env.environment.runner.processManager.isRunning(ProcessID.spotread) + XCTAssertFalse(running) + } + + // MARK: - defaultInstrument seeding + + func testDefaultInstrumentSeedsPicker() async throws { + try installSpotread() + try installInstlist(""" + {"event":"instruments","devices":[ + {"port":1,"name":"X-Rite i1Pro","type":"i1"}, + {"port":2,"name":"ColorMunki Photo","type":"CM"}]} + """) + var settings = env.environment.settingsStore.load() + settings.defaultInstrument = "CM" + try env.environment.settingsStore.save(settings) + + spot.sheetOpened() + let ok1 = await waitFor { !self.spot.isDetecting && !self.spot.instruments.isEmpty } + XCTAssertTrue(ok1) + guard case .device(let device) = spot.selectedInstrument else { + XCTFail("Expected device selection, got .auto") + return + } + XCTAssertEqual(device.port, 2) + XCTAssertFalse(spot.defaultMissing) + } + + func testDefaultInstrumentNotPresent() async throws { + try installSpotread() + try installInstlist(""" + {"event":"instruments","devices":[ + {"port":1,"name":"X-Rite i1Pro","type":"i1"}]} + """) + var settings = env.environment.settingsStore.load() + settings.defaultInstrument = "51" // Spyder X — absent + try env.environment.settingsStore.save(settings) + + spot.sheetOpened() + let ok2 = await waitFor { !self.spot.isDetecting } + XCTAssertTrue(ok2) + XCTAssertEqual(spot.selectedInstrument, .auto) + XCTAssertTrue(spot.defaultMissing) + } + + func testSetDefaultToggleWritesSettingsOnly() async throws { + try installSpotread() + try installInstlist(""" + {"event":"instruments","devices":[ + {"port":2,"name":"ColorMunki Photo","type":"CM"}]} + """) + spot.sheetOpened() + let ok3 = await waitFor { !self.spot.instruments.isEmpty } + XCTAssertTrue(ok3) + spot.selectedInstrument = .device(spot.instruments[0]) + spot.applyDefaultToggle(true) + XCTAssertEqual(env.environment.settingsStore.load().defaultInstrument, "CM") + // printtarg instrument is untouched (R15). + XCTAssertEqual(workflow.instrument, .i1) + spot.applyDefaultToggle(false) + XCTAssertNil(env.environment.settingsStore.load().defaultInstrument) + } + + // MARK: - Exclusive lease + + func testDuplicateSpotreadIdRejected() async throws { + try writeMock("spotread", "#!/bin/sh\nsleep 30\n") + let pm = env.environment.runner.processManager + let bin = env.environment.runner.binaryResolver.resolve("spotread") + try await pm.runStreaming(id: ProcessID.spotread, binary: bin, arguments: []) + let ok4 = await pm.isRunning(ProcessID.spotread) + XCTAssertTrue(ok4) + do { + try await pm.runStreaming(id: ProcessID.spotread, binary: bin, arguments: []) + XCTFail("Expected duplicateID") + } catch let error as ProcessError { + guard case .duplicateID(let id) = error else { + XCTFail("Expected duplicateID, got \(error)") + return + } + XCTAssertEqual(id, "spotread") + } + await pm.kill(id: ProcessID.spotread) + } + + // MARK: - Session + + func testMockSpotreadProducesSample() async throws { + try installSpotread() + spot.sheetOpened() + spot.start() + let ok5 = await waitFor { self.spot.state == .calibrating } + XCTAssertTrue(ok5, "expected calibrating prompt") + + spot.calibrate() + let ok6 = await waitFor { self.spot.state == .awaitingStrip } + XCTAssertTrue(ok6, "expected read prompt") + + spot.trigger() + let ok7 = await waitFor { !self.spot.samples.isEmpty } + XCTAssertTrue(ok7, "expected a sample") + let sample = try XCTUnwrap(spot.samples.first) + XCTAssertEqual(sample.lab.l, 51.9, accuracy: 0.001) + XCTAssertNotNil(sample.xyz) + XCTAssertNil(spot.displayedDeltaE) // first sample hides ΔE + + spot.trigger() + let ok8 = await waitFor { self.spot.samples.count >= 2 } + XCTAssertTrue(ok8, "expected a second sample") + XCTAssertNotNil(spot.displayedDeltaE) + XCTAssertEqual(spot.displayedDeltaE ?? -1, 0, accuracy: 0.0001) // identical Lab + + spot.stopIfNeeded() + XCTAssertFalse(spot.isRunning) + let deadline = Date().addingTimeInterval(5) + var alive = await env.environment.runner.processManager.isRunning(ProcessID.spotread) + while alive && Date() < deadline { + try await Task.sleep(nanoseconds: 100_000_000) + alive = await env.environment.runner.processManager.isRunning(ProcessID.spotread) + } + XCTAssertFalse(alive, "spotread child must not outlive Stop") + } + + func testStartBlockedWhileChartreadRunning() async throws { + try installSpotread() + // Simulate a live Stage 3 chartread child. + workflow.measurement.isChartreadRunning = true + spot.sheetOpened() + spot.start() + XCTAssertEqual(spot.lastError, "Stop the Stage 3 chart read first.") + XCTAssertFalse(spot.isRunning) + } +} diff --git a/Tests/ICCeryCoreTests/TestAppEnvironment.swift b/Tests/ICCeryCoreTests/TestAppEnvironment.swift index 1a7f9d0..eb15c85 100644 --- a/Tests/ICCeryCoreTests/TestAppEnvironment.swift +++ b/Tests/ICCeryCoreTests/TestAppEnvironment.swift @@ -19,10 +19,24 @@ struct TestAppEnvironment { var historyURL: URL { root.appendingPathComponent("verification_history.json") } + 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. - static func make() throws -> TestAppEnvironment { + /// `argyllBinDir` overrides the `BinaryResolver` tool directory so + /// tests can point at mock sidecar scripts (#148). + /// `bundledArgyllRoot` replaces the real app-bundle sidecar root so + /// tests can simulate a missing sidecar even when the build phase + /// copied real binaries into the host app. + static func make( + argyllBinDir: URL? = nil, + bundledArgyllRoot: URL? = nil + ) throws -> TestAppEnvironment { let root = FileManager.default.temporaryDirectory .appendingPathComponent("iccery-test-env-\(UUID().uuidString)") try FileManager.default.createDirectory( @@ -41,7 +55,9 @@ struct TestAppEnvironment { presetStore: PresetStore(settingsStore: settingsStore), runner: ArgyllRunner( processManager: processManager, - binaryResolver: BinaryResolver(overrideDir: nil) + binaryResolver: BinaryResolver( + bundledRoot: bundledArgyllRoot ?? AppPaths.bundledArgyllDir, + overrideDir: argyllBinDir) ), cupsService: CupsService( processManager: processManager, @@ -49,6 +65,12 @@ struct TestAppEnvironment { ), historyStore: VerificationHistoryStore( url: root.appendingPathComponent("verification_history.json") + ), + 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/Fixtures/bin/spotread b/Tests/ICCeryUITests/Fixtures/bin/spotread new file mode 100755 index 0000000..fb88e77 --- /dev/null +++ b/Tests/ICCeryUITests/Fixtures/bin/spotread @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Mock spotread for Milestone10SpotReadUITests. + +Emits the real spotread prompt phrasing; each trigger line produces one +"Result is XYZ: …, D50 Lab: …" sample. Override the emitted colour with +MOCK_SPOTREAD_LAB / MOCK_SPOTREAD_XYZ. 'q' quits with exit 0. +Usage: spotread -v -e [-c port] [-Y l] +""" +import os +import sys + +LAB = os.environ.get("MOCK_SPOTREAD_LAB", "51.9 -8.3 12.2") +XYZ = os.environ.get("MOCK_SPOTREAD_XYZ", "18.51 20.05 15.71") + + +def read_line(): + try: + return sys.stdin.readline() + except Exception: + return "" + + +def main(): + print("Spot read needs a calibration before continuing") + print("Place instrument on spot reading white calibration tile,") + print(" and then hit any key to continue,") + print("or hit Esc or Q to abort:") + sys.stdout.flush() + if not read_line(): + return 0 + print("Calibration successful.") + + while True: + print("Place instrument on a spot to be measured,") + print(" and hit a key to take a reading,") + print("or hit Esc or Q to abort:") + sys.stdout.flush() + line = read_line() + if not line: + return 0 + if line.strip().lower().startswith("q"): + return 0 + print("Result is XYZ: %s, D50 Lab: %s" % (XYZ, LAB)) + sys.stdout.flush() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Tests/ICCeryUITests/Milestone10GamutCompareUITests.swift b/Tests/ICCeryUITests/Milestone10GamutCompareUITests.swift new file mode 100644 index 0000000..5ec28d4 --- /dev/null +++ b/Tests/ICCeryUITests/Milestone10GamutCompareUITests.swift @@ -0,0 +1,291 @@ +import Foundation +import Metal +import XCTest + +/// Milestone 10 — Issue #147 gamut compare chrome tests. +/// +/// Sheet-opened only: no GPU hit-test assertions (no reliable SceneKit +/// click on the runner). Containment itself is covered by +/// `GamutContainmentTests`. +@MainActor +final class Milestone10GamutCompareUITests: XCTestCase { + + /// Metal on the test host — the app under test runs on the same + /// machine, so this predicts whether the sheet mounts SceneKit. + /// GPU-less runners still get the banner/Close assertions (#147). + private var hasGPU: Bool { MTLCreateSystemDefaultDevice() != nil } + + private var app: XCUIApplication! + private var testRoot: URL! + private var binDir: URL! + private var workDir: URL! + private var appDataDir: URL! + private var referenceGamutURL: URL! + + override func setUp() async throws { + continueAfterFailure = false + testRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-ui-m10-gamut-\(UUID().uuidString)") + binDir = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .appendingPathComponent("Fixtures/bin") + workDir = testRoot.appendingPathComponent("work") + appDataDir = testRoot.appendingPathComponent("AppData") + referenceGamutURL = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Resources/Argyll/reference_gamuts/sRGB.gam") + + try FileManager.default.createDirectory( + at: workDir, withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: appDataDir, withIntermediateDirectories: true) + + app = XCUIApplication() + app.launchEnvironment = [ + "ICCERY_UI_TESTING": "1", + "ICCERY_TEST_ROOT": testRoot.path, + "ICCERY_ARGYLL_BINARY_DIR": binDir.path, + "ICCERY_TEST_WORKDIR": workDir.path, + ] + } + + override func tearDown() async throws { + // Never leave the gamut sheet up for `terminate()` (#147). + if app != nil, element("btnCloseGamut").exists { + element("btnCloseGamut").click() + } + app?.terminate() + app = nil + if let testRoot { + try? FileManager.default.removeItem(at: testRoot) + } + testRoot = nil + } + + private func element(_ id: String) -> XCUIElement { + let inApp = app.descendants(matching: .any)[id].firstMatch + if inApp.exists { return inApp } + let inSheet = app.sheets.firstMatch.descendants(matching: .any)[id].firstMatch + if inSheet.exists { return inSheet } + // Menu popup items live outside the window hierarchy. + return app.menuItems[id].firstMatch + } + + private func waitFor(_ id: String, timeout: TimeInterval = 15) -> 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 + } + + /// Exists **and** `isEnabled`. Layer toggles render as disabled + /// placeholders until the async layer load lands — on the macOS 12 + /// runner `waitFor` alone wins the race against `parse`. + private func waitUntilEnabled(_ id: String, timeout: TimeInterval = 15) -> XCUIElement { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + let el = element(id) + if el.exists && el.isEnabled { return el } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + let el = element(id) + XCTAssertTrue( + el.exists && el.isEnabled, "Expected enabled element \(id)") + return el + } + + private func launchApp() { + app.launch() + if !app.wait(for: .runningForeground, timeout: 10) { + app.activate() + } + } + + private func openGamutSheet() { + launchApp() + waitFor("btnViewGamut").click() + _ = waitFor("gamutView") + } + + /// Inverse of `waitFor` — polls until the element leaves the tree. + private func waitForGone(_ id: String, timeout: TimeInterval = 10) { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if !element(id).exists { return } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + XCTAssertFalse(element(id).exists, "Expected element \(id) to disappear") + } + + /// `btnCloseGamut` dismisses the sheet so `tearDown`'s `terminate()` + /// is not stuck behind a key sheet (#147). No-op when already closed. + private func closeGamutSheet() { + let close = element("btnCloseGamut") + guard close.waitForExistence(timeout: 5) else { return } + close.click() + waitForGone("gamutView") + } + + func testLayerTogglesExistWithSRGB() throws { + openGamutSheet() + + let srgb = waitUntilEnabled("gamutLayer-sRGB") + XCTAssertTrue(srgb.exists) + // NSButton checkbox value is 1 when checked. + XCTAssertEqual(srgb.value as? Int, 1, "sRGB layer should be on") + + let compare = waitFor("gamutLayer-compare") + XCTAssertTrue(compare.exists) + XCTAssertFalse(compare.isEnabled, "Compare toggle must be disabled before a load") + + let status = waitFor("gamutStatusText") + let statusValue = status.value as? String ?? "" + XCTAssertTrue(statusValue.contains("sRGB"), "Status should list the sRGB layer, got: \(statusValue)") + closeGamutSheet() + } + + func testAddCompareButtonExists() throws { + openGamutSheet() + + waitFor("btnGamutAddCompare").click() + // No ICCERY_TEST_GAMUT_FILE set → the stubbed picker cancels; + // the sRGB status must stay non-empty. + waitFor("btnGamutOpenGam").click() + + let status = waitFor("gamutStatusText") + let value = status.value as? String ?? "" + XCTAssertTrue(value.contains("sRGB"), "Status should keep the sRGB clause, got: \(value)") + closeGamutSheet() + } + + func testCompareGamLoadEnablesToggle() throws { + let compareURL = workDir.appendingPathComponent("compare.gam") + try FileManager.default.copyItem(at: referenceGamutURL, to: compareURL) + app.launchEnvironment["ICCERY_TEST_GAMUT_FILE"] = compareURL.path + + openGamutSheet() + waitFor("btnGamutAddCompare").click() + waitFor("btnGamutOpenGam").click() + + // The pre-load placeholder also exists — wait for enabled. + let compare = waitUntilEnabled("gamutLayer-compare") + XCTAssertTrue(compare.isEnabled, "Compare toggle should enable after load") + XCTAssertEqual(compare.value as? Int, 1, "Compare layer should be on after load") + + let status = waitFor("gamutStatusText") + let value = status.value as? String ?? "" + XCTAssertTrue(value.contains("compare"), "Status should list the compare layer, got: \(value)") + + let remove = waitFor("btnGamutRemoveCompare") + XCTAssertTrue(remove.isEnabled) + closeGamutSheet() + } + + func testOpenProfileRunsIccgamutForCompare() throws { + // A profile with no sibling .gam → the mock iccgamut writes one. + let profileURL = workDir.appendingPathComponent("myprinter.icc") + try Data("MOCK_ICC".utf8).write(to: profileURL) + app.launchEnvironment["ICCERY_TEST_GAMUT_PROFILE"] = profileURL.path + app.launchEnvironment["ICCERY_MOCK_GAMUT_SOURCE"] = referenceGamutURL.path + + openGamutSheet() + waitFor("btnGamutAddCompare").click() + waitFor("btnGamutOpenProfile").click() + + let compare = waitUntilEnabled("gamutLayer-compare") + XCTAssertTrue(compare.isEnabled, "Compare toggle should enable after iccgamut") + XCTAssertEqual(compare.value as? Int, 1, "Compare layer should be on after iccgamut") + + // The compare slot's display name is the .gam stem ("myprinter"). + let status = waitFor("gamutStatusText") + let statusValue = status.value as? String ?? "" + XCTAssertTrue(statusValue.contains("myprinter"), "Status should list the compare layer, got: \(statusValue)") + closeGamutSheet() + } + + func testInspectPanelIdleStableHeight() throws { + openGamutSheet() + + let panel = waitFor("gamutInspectPanel") + XCTAssertTrue(panel.exists) + XCTAssertTrue(element("gamutInspectIdle").exists) + XCTAssertTrue(element("gamutStatusText").exists) + closeGamutSheet() + } + + func testManualLabInspectShowsContainment() throws { + openGamutSheet() + + waitFor("gamutLabEntryL").click() + element("gamutLabEntryL").typeText("50") + element("gamutLabEntryA").click() + element("gamutLabEntryA").typeText("0") + element("gamutLabEntryB").click() + element("gamutLabEntryB").typeText("0") + + waitFor("btnGamutInspectLab").click() + + let inside = waitFor("gamutInspect-sRGB") + let value = inside.value as? String ?? inside.label + XCTAssertTrue(value.contains("in"), "Lab(50,0,0) should be inside sRGB, got: \(value)") + XCTAssertTrue(element("gamutInspectL").exists) + XCTAssertTrue(element("gamutInspectSwatch").exists) + closeGamutSheet() + } + + func testResetIdentifierUnchanged() throws { + openGamutSheet() + let reset = waitFor("btnResetGamutCamera") + XCTAssertTrue(reset.isEnabled) + closeGamutSheet() + } + + /// `btnCloseGamut` is always enabled — including on the fallback + /// banner — and dismisses the sheet (#147). + func testCloseButtonDismissesSheet() throws { + openGamutSheet() + + let close = waitFor("btnCloseGamut") + XCTAssertTrue(close.isEnabled) + close.click() + waitForGone("gamutView") + } + + /// The fallback banner exists exactly when the host lacks Metal — + /// no `SCNView` is mounted on a GPU-less runner, and none may be + /// reported unavailable on a GPU host. + func testFallbackBannerMatchesGPUAvailability() throws { + openGamutSheet() + + if hasGPU { + XCTAssertFalse( + element("gamutViewerUnavailable").exists, + "GPU host must mount the SceneKit view, not the fallback") + } else { + _ = waitFor("gamutViewerUnavailable") + } + closeGamutSheet() + } + + /// `ICCERY_TEST_SKIP_SCENEKIT=1` forces the fallback even on a GPU + /// host — banner plus a working Close, no `SCNView` mounted (#147). + /// The env is set for this test only; the default launch env must + /// not carry it, or CI's future GPU run would skip SceneKit too. + func testForcedSceneKitSkipShowsBannerAndClose() throws { + app.launchEnvironment["ICCERY_TEST_SKIP_SCENEKIT"] = "1" + openGamutSheet() + + _ = waitFor("gamutViewerUnavailable") + let close = waitFor("btnCloseGamut") + XCTAssertTrue(close.isEnabled) + close.click() + waitForGone("gamutView") + } +} diff --git a/Tests/ICCeryUITests/Milestone10MediaLibraryUITests.swift b/Tests/ICCeryUITests/Milestone10MediaLibraryUITests.swift new file mode 100644 index 0000000..bb4b518 --- /dev/null +++ b/Tests/ICCeryUITests/Milestone10MediaLibraryUITests.swift @@ -0,0 +1,162 @@ +import XCTest + +/// Milestone 10 UI tests — issue #146 media recipe library. Mock CUPS +/// binaries (`ICCERY_CUPS_BIN_DIR` → `Fixtures/bin`) emit +/// `Mock_Epson_7450` / `Mock_Canon_Pro`; recipes are seeded by writing +/// `/AppData/media_library.json` before launch — +/// `AppPaths` redirects app data under `ICCERY_TEST_ROOT`. All queries +/// are by identifier only ("Media" also appears in help overlays). +@MainActor +final class Milestone10MediaLibraryUITests: XCTestCase { + + private var app: XCUIApplication! + private var testRoot: URL! + private var binDir: URL! + + override func setUp() async throws { + continueAfterFailure = false + testRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-ui10-\(UUID().uuidString)") + binDir = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .appendingPathComponent("Fixtures/bin") + + let appData = testRoot.appendingPathComponent("AppData", isDirectory: true) + try FileManager.default.createDirectory( + at: appData, withIntermediateDirectories: true) + // A recipe bound to a queue that is never enumerated. + let fixture = """ + [ + { + "id": "fixture-missing-queue", + "name": "Missing Queue Recipe", + "notes": "", + "printer_id": "No_Such_Queue", + "printer_display_name": "Missing Queue", + "paper_name": "Rag", + "ink_set": "PK", + "colour_space": "rgb", + "preset_id": "preset-std-rgb", + "calibration_url": null, + "apply_calibration": false, + "created": "2026-09-12T00:00:00Z", + "updated": "2026-09-12T00:00:00Z" + } + ] + """ + try fixture.write( + to: appData.appendingPathComponent("media_library.json"), + atomically: true, encoding: .utf8) + + 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 + } + + 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 waitForEnabled(_ id: String, timeout: TimeInterval = 15) -> XCUIElement { + let el = waitFor(id, timeout: timeout) + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if el.isEnabled { return el } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + return el + } + + func testMediaPickerDoesNotReusePresetSelect() throws { + launchApp() + + let preset = app.popUpButtons["presetSelect"] + XCTAssertTrue(preset.waitForExistence(timeout: 10)) + let media = app.popUpButtons["mediaSelect"] + XCTAssertTrue(media.waitForExistence(timeout: 10)) + } + + func testCaptureRequiresNamePaperInk() throws { + launchApp() + + // Capture enables once the mock CUPS enumeration selects a queue. + let capture = waitForEnabled("btnMediaLibraryCapture") + XCTAssertTrue(capture.isEnabled) + capture.click() + + _ = waitFor("saveMediaRecipeDialog") + let save = element("btnConfirmSaveMedia") + XCTAssertTrue(save.exists) + XCTAssertFalse(save.isEnabled) + + for (id, text) in [ + ("saveMediaName", "UI Recipe"), + ("saveMediaPaper", "Rag"), + ("saveMediaInk", "PK"), + ] { + let field = element(id) + field.click() + field.typeText(text) + } + + XCTAssertTrue(save.isEnabled) + } + + func testManageApplyMissingPrinterShowsBanner() throws { + launchApp() + + let manage = app.buttons["btnMediaLibraryManage"] + XCTAssertTrue(manage.waitForExistence(timeout: 10)) + manage.click() + _ = waitFor("manageMediaDialog") + + let apply = element("btnMediaLibraryApply-fixture-missing-queue") + XCTAssertTrue(apply.waitForExistence(timeout: 10)) + apply.click() + + let notice = waitFor("noticeText") + let text = (notice.value as? String) ?? notice.label + XCTAssertTrue( + text.contains("is not installed"), + "expected not-installed notice, got: \(text)") + + // A failed apply keeps the manage sheet open and the sidebar + // picker reverts. + XCTAssertTrue(element("manageMediaDialog").exists) + XCTAssertTrue(element("mediaSelect").exists) + } +} diff --git a/Tests/ICCeryUITests/Milestone10ProjectUITests.swift b/Tests/ICCeryUITests/Milestone10ProjectUITests.swift new file mode 100644 index 0000000..25db5f9 --- /dev/null +++ b/Tests/ICCeryUITests/Milestone10ProjectUITests.swift @@ -0,0 +1,263 @@ +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 +/// through the File menu when it is in the AX tree, else by their +/// keyboard shortcuts (⌘N) — 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 + } + + /// Alert/sheet button by visible title, falling back to the a11y + /// id; nil when neither matches. macOS 12 SwiftUI alerts often + /// drop `accessibilityIdentifier` on their buttons, so the title + /// is the reliable handle there. + private func alertButton(title: String, id: String) -> XCUIElement? { + let inDialog = app.dialogs.firstMatch.buttons[title].firstMatch + if inDialog.exists { return inDialog } + let inSheet = app.sheets.firstMatch.buttons[title].firstMatch + if inSheet.exists { return inSheet } + let byId = element(id) + return byId.exists ? byId : nil + } + + /// Fires File ▸ New Project via the menu when it is in the AX + /// tree, else ⌘N. On macOS 12 `typeKey` may not reach the + /// `CommandGroup`, and menu item ids are unreliable — the menu + /// item is matched by its "New Project" label first. + private func triggerNewProject() { + let fileMenu = app.menuBarItems["File"] + if fileMenu.waitForExistence(timeout: 5) { + fileMenu.click() + let byTitle = app.menuItems["New Project"].firstMatch + let byId = app.menuItems["menuProjectNew"].firstMatch + let item = byTitle.exists ? byTitle : byId + if item.waitForExistence(timeout: 5) { + item.click() + return + } + app.typeKey(XCUIKeyboardKey.escape, modifierFlags: []) + } + app.typeKey("n", modifierFlags: .command) + } + + /// 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") + + // File ▸ New Project when the menu is in the AX tree, else + // ⌘N. 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. macOS 12 alerts often lack + // button identifiers, so confirm by title with id fallback. + triggerNewProject() + 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 let discard = alertButton( + title: "Don't Save", id: "btnProjectDirtyDiscard") { + discard.click() + confirmed = true + break + } + if let start = alertButton( + title: "Start", id: "btnProjectNewConfirm") { + start.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/Tests/ICCeryUITests/Milestone10SpotReadUITests.swift b/Tests/ICCeryUITests/Milestone10SpotReadUITests.swift new file mode 100644 index 0000000..885bbeb --- /dev/null +++ b/Tests/ICCeryUITests/Milestone10SpotReadUITests.swift @@ -0,0 +1,235 @@ +import XCTest + +/// Milestone 10 UI tests — issue #148 spot-read console. Mock Argyll +/// sidecars (`ICCERY_ARGYLL_BINARY_DIR` → `Fixtures/bin`) provide +/// `instlist`, `chartread`, and `spotread`; no real USB Detect is ever +/// clicked. All queries are by identifier only. +@MainActor +final class Milestone10SpotReadUITests: 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-ui10spot-\(UUID().uuidString)") + binDir = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .appendingPathComponent("Fixtures/bin") + workDir = testRoot.appendingPathComponent("work") + 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, + // Redirect the bundled root too so the real sidecars copied + // into the product by the build phase cannot mask a missing + // override binary (`testMissingSidecarShowsMessage`). + "ICCERY_ARGYLL_BUNDLED_ROOT": binDir.path, + "ICCERY_CUPS_BIN_DIR": binDir.path, + "ICCERY_TEST_SAVE_TARGET": + workDir.appendingPathComponent("mytarget.ti1").path, + "ICCERY_TEST_WORKDIR": workDir.path, + "ICCERY_TEST_CSV_EXPORT": + workDir.appendingPathComponent("spot-history.csv").path, + ] + } + + override func tearDown() async throws { + app?.terminate() + app = nil + if let testRoot { + try? FileManager.default.removeItem(at: testRoot) + } + testRoot = nil + } + + private func launchApp() { + app.launch() + if !app.wait(for: .runningForeground, timeout: 10) { + app.activate() + } + } + + /// Seed `wizard_state.json` with a working directory so `btnSpotRead` + /// is enabled without driving the whole Stage 1/2 flow. + private func seedWorkingDirectory() throws { + let appData = testRoot.appendingPathComponent("AppData", isDirectory: true) + try FileManager.default.createDirectory( + at: appData, withIntermediateDirectories: true) + let state = """ + { + "currentStage": 0, + "basename": "spotui", + "cwd": "\(workDir.path)", + "sessionMode": "profile" + } + """ + try state.write( + to: appData.appendingPathComponent("wizard_state.json"), + atomically: true, encoding: .utf8) + } + + 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 + } + + // MARK: - Sidebar gating + + func testSpotReadButtonDisabledWithoutCwd() throws { + launchApp() + let button = app.buttons["btnSpotRead"] + XCTAssertTrue(button.waitForExistence(timeout: 10)) + XCTAssertFalse(button.isEnabled) + } + + func testSpotReadButtonDisabledDuringChartread() throws { + launchApp() + // Drive to Stage 3 with the mock targen/printtarg fixtures. + app.buttons["btnBrowse"].click() + app.buttons["btnGenerate"].click() + _ = waitFor("btnCreateLayout", timeout: 20) + app.buttons["btnCreateLayout"].click() + _ = waitFor("galleryPage-0", timeout: 20) + _ = waitFor("btnAdvanceToStage3", timeout: 10) + app.buttons["btnAdvanceToStage3"].click() + _ = waitFor("stage3TargetBasename", timeout: 10) + + // Start the mock chartread — it blocks on the calibrate prompt. + app.buttons["btnStartRead"].click() + _ = waitFor("btnCalibrate", timeout: 25) + + let button = app.buttons["btnSpotRead"] + XCTAssertTrue(button.exists) + XCTAssertFalse(button.isEnabled) + + // Clean up the live chartread child before teardown. + if app.buttons["btnCancel"].exists { + app.buttons["btnCancel"].click() + } + } + + // MARK: - Sheet contract + + func testSheetHasOwnInstrumentIds() throws { + try seedWorkingDirectory() + launchApp() + + let button = app.buttons["btnSpotRead"] + XCTAssertTrue(button.waitForExistence(timeout: 10)) + let deadline = Date().addingTimeInterval(10) + while !button.isEnabled, Date() < deadline { + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + XCTAssertTrue(button.isEnabled) + button.click() + + _ = waitFor("spotReadView", timeout: 10) + XCTAssertTrue(element("spotInstrumentSelect").waitForExistence(timeout: 10)) + // Stage 3 ids must not appear inside the sheet. + XCTAssertFalse( + app.sheets.firstMatch.descendants(matching: .any)["chartreadInstrumentSelect"].exists) + XCTAssertFalse( + app.sheets.firstMatch.descendants(matching: .any)["btnDetectInstruments"].exists) + XCTAssertTrue(element("btnCloseSpotRead").exists) + } + + func testMissingSidecarShowsMessage() throws { + // Point the override at an empty dir; the bundled root has no + // real sidecars in this checkout, so resolve() misses both. + let emptyBin = testRoot.appendingPathComponent("empty-bin") + try FileManager.default.createDirectory( + at: emptyBin, withIntermediateDirectories: true) + app.launchEnvironment["ICCERY_ARGYLL_BINARY_DIR"] = emptyBin.path + try seedWorkingDirectory() + launchApp() + + let button = app.buttons["btnSpotRead"] + XCTAssertTrue(button.waitForExistence(timeout: 10)) + let deadline = Date().addingTimeInterval(10) + while !button.isEnabled, Date() < deadline { + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + button.click() + + _ = waitFor("spotReadView", timeout: 10) + XCTAssertTrue(element("spotSidecarMissing").waitForExistence(timeout: 10)) + XCTAssertFalse(element("btnSpotStart").exists) + XCTAssertFalse(element("btnSpotDetectInstruments").exists) + XCTAssertTrue(element("btnCloseSpotRead").exists) + } + + func testHistoryCopyDisabledWhenEmpty() throws { + try seedWorkingDirectory() + launchApp() + + let button = app.buttons["btnSpotRead"] + XCTAssertTrue(button.waitForExistence(timeout: 10)) + let deadline = Date().addingTimeInterval(10) + while !button.isEnabled, Date() < deadline { + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + button.click() + + _ = waitFor("spotReadView", timeout: 10) + XCTAssertTrue(element("spotHistoryEmpty").waitForExistence(timeout: 10)) + XCTAssertTrue(element("spotLastEmpty").exists) + XCTAssertFalse(element("btnSpotCopyLab").isEnabled) + XCTAssertFalse(element("btnSpotExportCsv").isEnabled) + } + + /// Full mock session: Start → Calibrate → Read produces one Lab + /// sample and enables Copy/Export. + func testMockSessionProducesSample() throws { + try seedWorkingDirectory() + launchApp() + + let button = app.buttons["btnSpotRead"] + XCTAssertTrue(button.waitForExistence(timeout: 10)) + let deadline = Date().addingTimeInterval(10) + while !button.isEnabled, Date() < deadline { + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + button.click() + + _ = waitFor("spotReadView", timeout: 10) + let start = element("btnSpotStart") + XCTAssertTrue(start.waitForExistence(timeout: 10)) + start.click() + + XCTAssertTrue(element("btnSpotCalibrate").waitForExistence(timeout: 15)) + element("btnSpotCalibrate").click() + + XCTAssertTrue(element("btnSpotTrigger").waitForExistence(timeout: 15)) + element("btnSpotTrigger").click() + + XCTAssertTrue(element("spotLastSample").waitForExistence(timeout: 15)) + XCTAssertTrue(element("spotLabL").exists) + XCTAssertTrue(element("spotSwatch").exists) + XCTAssertTrue(element("btnSpotCopyLab").isEnabled) + XCTAssertTrue(element("btnSpotExportCsv").isEnabled) + + element("btnSpotStop").click() + element("btnCloseSpotRead").click() + } +} diff --git a/Tests/ICCeryUITests/Milestone2UITests.swift b/Tests/ICCeryUITests/Milestone2UITests.swift index 4455f0a..0e45344 100644 --- a/Tests/ICCeryUITests/Milestone2UITests.swift +++ b/Tests/ICCeryUITests/Milestone2UITests.swift @@ -85,6 +85,31 @@ final class Milestone2UITests: XCTestCase { return el } + /// Exists **and** `isEnabled` — guards clicks against buttons that + /// appear a beat before their `.disabled` condition clears. + private func waitUntilEnabled(_ id: String, timeout: TimeInterval = 10) -> XCUIElement { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + let el = element(id) + if el.exists && el.isEnabled { return el } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + let el = element(id) + XCTAssertTrue( + el.exists && el.isEnabled, "Expected enabled element \(id)") + return el + } + + /// Non-asserting existence poll for the retry-or-fail pattern. + private func existsAfter(_ id: String, timeout: TimeInterval) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if element(id).exists { return true } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + return element(id).exists + } + /// Assert an element stays absent after a short dwell — unlike /// `waitForExistence`, which always burns its full timeout on the /// negative path. @@ -181,8 +206,15 @@ final class Milestone2UITests: XCTestCase { XCTAssertTrue(element("tiffDpi").exists) XCTAssertTrue(element("targetLabelPreview").exists) - app.buttons["btnCreateLayout"].click() - XCTAssertTrue(waitFor("galleryPage-0", timeout: 20).exists) + // On the slow macOS 12 runner a synthesized click can land + // while the button is still rebuilding — retry once if the + // gallery never materialises, then allow a generous window + // for the fixture printtarg + PNG render. + waitUntilEnabled("btnCreateLayout").click() + if !existsAfter("galleryPage-0", timeout: 15) { + waitUntilEnabled("btnCreateLayout").click() + } + XCTAssertTrue(waitFor("galleryPage-0", timeout: 30).exists) XCTAssertTrue(FileManager.default.fileExists( atPath: workDir.appendingPathComponent("mytarget.ti2").path)) diff --git a/Tests/ICCeryUITests/Milestone3UITests.swift b/Tests/ICCeryUITests/Milestone3UITests.swift index 8ea1f86..bb20e1d 100644 --- a/Tests/ICCeryUITests/Milestone3UITests.swift +++ b/Tests/ICCeryUITests/Milestone3UITests.swift @@ -113,6 +113,59 @@ final class Milestone3UITests: XCTestCase { return recordedLpArgv() } + /// Drags `#galleryPage-0`'s TIFF upward so `identifier`'s button + /// moves up, clear of the Dock collision zone at the window's + /// bottom edge (#132). + /// + /// macOS overlay scrollbars are not in the AX tree — never use + /// `app.scrollBars` — and a synthesized scroll wheel is inert on + /// this LazyVGrid, so the scroll is a real drag on the gallery + /// cell's content. A stale/off-screen AX frame resolves to a screen + /// point that can be a Dock icon — a coordinate click there once + /// opened Calendar instead of Print. Callers must click only when + /// the returned element `isHittable`; never coordinate-click a + /// stale frame. + @discardableResult + private func scrollStage2UntilHittable( + _ identifier: String, + timeout: TimeInterval = 20 + ) -> XCUIElement { + var button = app.buttons[identifier] + let cell = app.descendants(matching: .any)["galleryPage-0"].firstMatch + XCTAssertTrue(cell.waitForExistence(timeout: 10), "galleryPage-0") + + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + let windowBottom = app.windows.firstMatch.frame.maxY + if button.exists, button.isHittable, + button.frame.maxY < windowBottom - 80 { + return button + } + // Grab the upper half of the cell (the TIFF, not the Print + // button / Dock) and drag toward the top of the window. + // Mouse moves UP ⇒ gallery content moves UP ⇒ Print leaves + // the Dock zone. + if cell.isHittable { + let start = cell.coordinate(withNormalizedOffset: + CGVector(dx: 0.5, dy: 0.25)) + let end = start.withOffset(CGVector(dx: 0, dy: -280)) + start.press(forDuration: 0.15, thenDragTo: end) + } else { + // Cell not hit-testable: drag the stage-2 content + // directly — still content, still never scrollBars. + let scrollView = app.scrollViews["stage-2"] + scrollView.coordinate(withNormalizedOffset: + CGVector(dx: 0.5, dy: 0.55)) + .press(forDuration: 0.15, thenDragTo: + scrollView.coordinate(withNormalizedOffset: + CGVector(dx: 0.5, dy: 0.15))) + } + RunLoop.current.run(until: Date().addingTimeInterval(0.4)) + button = app.buttons[identifier] + } + return button + } + // MARK: - Tests /// Panel appears after the manifest; refresh populates the printer @@ -199,36 +252,16 @@ final class Milestone3UITests: XCTestCase { XCTAssertTrue(app.buttons["btnPrintAll"].isEnabled) // The gallery cell's Print button sits at the window's bottom - // edge where synthesized scroll-wheel events are inert on the - // LazyVGrid (#132). Drag the NSScrollView's vertical AXScrollBar - // thumb instead — a real scroll that re-renders the cell onscreen. - var printPage = app.buttons["btnPrintPage-0"] - let scrollDeadline = Date().addingTimeInterval(15) - while !printPage.isHittable, Date() < scrollDeadline { - let scroller = app.scrollBars.allElementsBoundByIndex - .first { $0.frame.height > $0.frame.width } - if let scroller { - scroller.coordinate(withNormalizedOffset: - CGVector(dx: 0.5, dy: 0.1)) - .press(forDuration: 0.1, thenDragTo: - scroller.coordinate(withNormalizedOffset: - CGVector(dx: 0.5, dy: 0.6))) - } else { - app.scrollViews["stage-2"].scroll(byDeltaX: 0, deltaY: -1) - } - RunLoop.current.run(until: Date().addingTimeInterval(0.5)) - printPage = app.buttons["btnPrintPage-0"] - } - if printPage.isHittable { - printPage.click() - } else { - // LazyVGrid cells can report a stale a11y frame — click the - // point directly; the lp argv assert below still verifies. + // edge; scroll until it is genuinely hittable (#132). Never + // coordinate-click a stale frame — that point can be the Dock. + let printPage = scrollStage2UntilHittable("btnPrintPage-0") + guard printPage.isHittable else { print("AXTREE-BEGIN frame=\(printPage.frame)\n" + "\(app.debugDescription)\nAXTREE-END") - printPage.coordinate(withNormalizedOffset: - CGVector(dx: 0.5, dy: 0.5)).click() + XCTFail("btnPrintPage-0 never became hittable; frame=\(printPage.frame)") + return } + printPage.click() let argv = waitForLpLine() XCTAssertTrue(argv.contains("AP_ColorMatchingMode"), argv) XCTAssertTrue(argv.contains("page1.tif"), argv) diff --git a/Tests/ICCeryUITests/Milestone6GamutUITests.swift b/Tests/ICCeryUITests/Milestone6GamutUITests.swift index df67c68..b404bb9 100644 --- a/Tests/ICCeryUITests/Milestone6GamutUITests.swift +++ b/Tests/ICCeryUITests/Milestone6GamutUITests.swift @@ -1,10 +1,15 @@ import Foundation +import Metal import XCTest /// Milestone 6 — Issue #28 native SceneKit gamut viewer acceptance tests. @MainActor final class Milestone6GamutUITests: XCTestCase { + /// Metal on the test host — the app under test runs on the same + /// machine, so this predicts whether the sheet mounts SceneKit. + private var hasGPU: Bool { MTLCreateSystemDefaultDevice() != nil } + private var app: XCUIApplication! private var testRoot: URL! private var binDir: URL! @@ -64,6 +69,10 @@ final class Milestone6GamutUITests: XCTestCase { } override func tearDown() async throws { + // Never leave the gamut sheet up for `terminate()` (#147). + if app != nil, element("btnCloseGamut").exists { + element("btnCloseGamut").click() + } app?.terminate() app = nil if let testRoot { @@ -90,10 +99,29 @@ final class Milestone6GamutUITests: XCTestCase { return el } + /// Inverse of `waitFor` — polls until the element leaves the tree. + private func waitForGone(_ id: String, timeout: TimeInterval = 10) { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if !element(id).exists { return } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + XCTAssertFalse(element(id).exists, "Expected element \(id) to disappear") + } + + /// `btnCloseGamut` dismisses the sheet so `tearDown`'s `terminate()` + /// is not stuck behind a key sheet (#147). No-op when already closed. + private func closeGamutSheet() { + let close = element("btnCloseGamut") + guard close.waitForExistence(timeout: 5) else { return } + close.click() + waitForGone("gamutView") + } + /// Build and verify the mock profile, then open the native gamut viewer. /// The viewer should load both the reference sRGB mesh and the profile /// gamut copied from that reference. - func testViewGamutOpensSceneKitSheet() throws { + private func openGamutSheet() { app.launch() if !app.wait(for: .runningForeground, timeout: 10) { app.activate() @@ -105,6 +133,12 @@ final class Milestone6GamutUITests: XCTestCase { waitFor("btnViewGamut").click() + _ = waitFor("gamutView") + } + + func testViewGamutOpensSceneKitSheet() throws { + openGamutSheet() + let gamutView = waitFor("gamutView") XCTAssertTrue(gamutView.exists) @@ -112,9 +146,33 @@ final class Milestone6GamutUITests: XCTestCase { let value = status.value as? String ?? "" XCTAssertTrue(value.contains("faces"), "Gamut status should report mesh faces, got: \(value)") + // The fallback banner appears exactly when the host lacks Metal + // — no SCNView is constructed without a GPU (#147). + if hasGPU { + XCTAssertFalse( + element("gamutViewerUnavailable").exists, + "GPU host must mount the SceneKit view, not the fallback") + } else { + _ = waitFor("gamutViewerUnavailable") + } + // The reset button demonstrates that the viewer is interactive. let reset = waitFor("btnResetGamutCamera") XCTAssertTrue(reset.isEnabled) + + closeGamutSheet() + } + + /// Clicking Reset drives the live `SCNView` — runs only on Metal + /// hosts, skipped on GPU-less runners so the same suite exercises + /// 3D once CI has a GPU (#147). + func testResetCameraInteractsWithScene() throws { + guard hasGPU else { throw XCTSkip("No Metal") } + openGamutSheet() + + let reset = waitFor("btnResetGamutCamera") reset.click() + + closeGamutSheet() } } diff --git a/docs/04-argyll-binaries.md b/docs/04-argyll-binaries.md index 9c06ff4..f94dedc 100644 --- a/docs/04-argyll-binaries.md +++ b/docs/04-argyll-binaries.md @@ -695,7 +695,7 @@ Consumes ICC/ICM. Produces `{stem}.gam` next to the profile (Argyll default). Bu |---|---| | `dispwin` | Never spawned. ICCery#90 “Emissive display calibration (dispwin & dispread)” = Won't Fix | | `dispread` | Same | -| `spotread`, `dispcal`, `collink`, `cctiff`, `spec2cie`, `illumread`, `synthacc` | Not referenced | +| `dispcal`, `collink`, `cctiff`, `spec2cie`, `illumread`, `synthacc` | Not referenced | | Generic `spawn_process` | **Registered** (`lib.rs:55`, `commands.rs:6–14`) but **no JS caller**. Always `cwd=None`. Exists as an escape hatch | --- @@ -725,6 +725,7 @@ The `Child` itself lives only in the wait task (not in a map) so `wait()` cannot | `profcheck_{ti3_path}` | profcheck (full path) | | `iccgamut_{stem}` | iccgamut | | `instlist` | instlist (literal) | +| `spotread` | spotread (literal, issue #148) | | caller-supplied | unused `spawn_process` | ### 12.3 Duplicate rejection (ICCery#116, `07d28eb`) @@ -830,6 +831,7 @@ From `lib.rs:54–119` plus the command bodies: | `run_profcheck` | profcheck | `profcheck_{ti3_path}` | | `extract_gamut` | iccgamut | `iccgamut_{stem}` | | `detect_instruments` | instlist | `instlist` | +| `run_spotread` | spotread (`-v -e [-c port] [-Y l]`, no `-u`) | `spotread` | | `generate_calibration_target` | targen | `targen_{CAL_basename}` | ### Argyll runners (captured, no events) 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/15-stage3-chartread.md b/docs/15-stage3-chartread.md index 15a18d3..c033932 100644 --- a/docs/15-stage3-chartread.md +++ b/docs/15-stage3-chartread.md @@ -80,3 +80,14 @@ If an unpatched binary rejects `-Y l`, capture last stderr line and expand Proce ## Interactive buttons vs real keys See [05](05-argyll-fork.md) §12. Real strip-mode keys are `f/b/n/d/q`, Space, Return, `y/n`. UI labels "Skip" / "Undo" send `s\n` / `u\n` which the **mock** understands; upstream strip mode treats unknown letters as trigger. Preserve current UI behaviour or document a protocol change — do not silently change what bytes are sent without updating tests. + +## Spot Read (issue #148) + +The Spot Read sheet (`btnSpotRead` in the sidebar) runs the bundled +`spotread` sidecar under the single-lease process id `spotread` — it is +**not** Stage 3 and shares no identifiers or process ids with +`chartread`. Stage 3 is unchanged: `chartread_{basename}` remains the +only chart path. `spotread` argv is `-v -e [-c port] [-Y l]` — never +`-u` (the v2.0 `-u` policy covers printtarg + chartread + profcheck +only). Stdin reuses the `chartread` byte table (`" \n"` trigger, +`"q\n"` quit + ~500 ms + kill). diff --git a/docs/18-gamut-viewer.md b/docs/18-gamut-viewer.md index ee2a15c..131546b 100644 --- a/docs/18-gamut-viewer.md +++ b/docs/18-gamut-viewer.md @@ -827,3 +827,35 @@ HTML ids that `_wireToggles` hard-codes: `chkProfileGamut`, `chkSrgbReference`, | #185 axes / EdgesGeometry / vertex colour / legend | All present (GridHelper kept; CSS2D parent-visibility bug). | | #212 Node test crash | Polyfill + dynamic import + `typeof window` guard. | | #225 Monterey WebGL | Lazy ensure, feature-detect, pause rAF, context-lost, low-power flags. | +| #147 compare + inspect | macOS native: layer toggles (`gamutLayer-*`), compare slot (one extra profile or `.gam`), click/typed-Lab containment, TIFF pixel sample. | + +--- + +## 13. macOS rewrite notes (#28, #147) + +The native viewer (`Sources/ICCery/GamutView.swift` + `GamutViewModel`) keeps +the v1 contract points that matter — `(a*, L*, b*)` axes, camera home +(180,120,180) lookAt (0,50,0), R resets via a local `NSEvent` monitor, native +faces only — and adds the compare/inspect layer model: + +- **Layers.** `NamedGamut` (reference / profileA / profileB). sRGB cannot be + removed, only hidden. The compare slot holds exactly one profile; picking a + third replaces it and posts `gamutNoticeText`. +- **Toggles hide, not unload.** `visibleIDs` maps to `SCNNode.isHidden`; the + scene is built once and a checkbox never resets the camera. +- **Containment.** `GamutGeometry.containment` ray-casts the face table + (`GamutVertex.position` space) with off-axis retries on edge hits; no faces + → `.unknown`. `GamutGeometry.volume` sums signed tetrahedra from the vertex + centroid (Lab-cubic); the `vol % of sRGB` clause only prints when both + volumes are finite and > 0. +- **Inspect.** Click a mesh (hit-test in the `SCNView` coordinator on + mouse-up, never a ZStack tap gesture), type Lab, or sample a TIFF pixel. + Per-layer in/out/? + swatch; `gamutInspectApprox` marks samples that went + through `ApproximateLab` (fixed-matrix sRGB→Lab D50, **not** ColorSync, no + CMM). +- **Fallback.** No GPU → `gamutViewerUnavailable` text replaces the scene; + layer toggles stay visible but disabled; the representable is never + respawned in a loop. +- **iccgamut** still runs only as the bundled sidecar, `-v -d {density}` + (density 10 = surface density, not a directory). Failure is an in-sheet + info notice — sRGB + profile A stay loaded (#24). diff --git a/docs/21-ui-reference.md b/docs/21-ui-reference.md index 648cd33..e49abb0 100644 --- a/docs/21-ui-reference.md +++ b/docs/21-ui-reference.md @@ -1,14 +1,16 @@ # 21 — UI reference -Vanilla HTML + CSS. Rewrite may use any toolkit; **ids and behaviours** below are the functional contract. **242 element ids** in `src/index.html` — complete roster at the end. +Vanilla HTML + CSS. Rewrite may use any toolkit; **ids and behaviours** below are the functional contract. **298 element ids** in `src/index.html` — complete roster at the end. ## Shell -- Sidebar 270 px: logo `./assets/ICCery-logo.svg`, settings/about icon buttons, preset select, Calibrate Printer, cal status chip, stepper 1–5. +- Sidebar 270 px: logo `./assets/ICCery-logo.svg`, settings/about icon buttons, preset select, media recipe select + Capture/Manage, Calibrate Printer, View Gamut, Spot Read, cal status chip, stepper 1–5. - Main: notification banner, one visible `.stage`. - Window 1280×800, min 1100×700, hidden until paint, dark `#1A1A22`. -Sidebar chrome ids: `openSettingsBtn`, `openAboutBtn`, `btnSavePresetModal`, `btnOpenPresetsDialog`, `presetSelect`, `btnCalibratePrinter`, `calStatusChip`. +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`. @@ -70,13 +72,18 @@ Keyboard: **R** resets gamut camera when Stage 5 is visible. Bind to a focusable | About | `aboutDialog` | `aboutVersion`, `aboutBuildDate` from `get_app_info`, `closeAboutBtn` | | Save preset | `savePresetDialog` | `savePresetName`, `savePresetDesc`, `btnConfirmSavePreset`, `btnCloseSavePresetDialog` | | Manage presets | `managePresetsDialog` | `managePresetsList`, `btnExportActivePreset`, `btnImportPreset`, `btnCloseManagePresetsDialog` | +| Save media recipe | `saveMediaRecipeDialog` | `saveMediaName`, `saveMediaNotes`, `saveMediaPaper`, `saveMediaInk`, `saveMediaPrinter`, `saveMediaPreset`, `saveMediaColourSpace`, `saveMediaCal`, `saveMediaApplyCal`, `btnConfirmSaveMedia`, `btnCloseSaveMediaDialog` | +| Manage media recipes | `manageMediaDialog` | `mediaLibraryList`, `mediaLibraryEmpty`, `mediaRow-{id}`, `btnMediaLibraryApply-{id}`, `btnMediaLibraryDelete-{id}`, `btnMediaLibraryApply`, `btnMediaLibraryCaptureFromManage`, `btnCloseManageMediaDialog` | | 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`, `btnCloseGamut` | ## 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 (242) +## 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`. +`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`. diff --git a/docs/22-settings-presets.md b/docs/22-settings-presets.md index a908bb4..5d9ef98 100644 --- a/docs/22-settings-presets.md +++ b/docs/22-settings-presets.md @@ -7,7 +7,7 @@ Persisted at `{app_data}/settings.json` via `load_settings` / `save_settings`. I | Field | Default | Notes | |-------|---------|-------| | `argyll_binary_dir` | `null` | Overrides bundled sidecars. `resolve_binary` checks this first. | -| `default_instrument` | `null` | **Stored but not applied to argv.** Stage 2 `#instrumentSelect` is the source of truth. Do not start honouring this without an explicit product decision. | +| `default_instrument` | `null` | Stored; seeds the Spot Read instrument picker when that instrument is present (#148). **Never** written into `printtarg -i` or `targen` argv. Stage 2 `#instrumentSelect` remains the printtarg instrument. | | `log_level` | `null` | `error` / `warn` / `info` / `debug` / `trace`. `null` → Debug in debug builds, Info in release. Applied at startup **and** on save (#158). | | `delta_e_good_max` | `2.0` | Stage 3 swatch traffic-light "Good". Must be ≥ 0. | | `delta_e_warning_max` | `5.0` | Stage 3 "Warning" band. Must be **strictly greater** than good. | @@ -67,3 +67,25 @@ Built-ins cannot be deleted. Custom presets overlay by `id`. Import/export is JS All four: `instrument: "i1"`, `colprof_algorithm: "l"`, `random_seed: 1`, `no_randomize: false`, `colprof_fwa: "D50"`. UI: `#presetSelect`, `#btnSavePresetModal` → `#savePresetDialog` (`savePresetName`, `savePresetDesc`, `btnConfirmSavePreset`), `#btnOpenPresetsDialog` → `#managePresetsDialog` (`managePresetsList`, `btnExportActivePreset`, `btnImportPreset`). + +## Media library (`media_library.json`) + +Persisted at `{app_data}/media_library.json` — a sibling of `settings.json`, never a field inside it (issue #146). A `MediaRecipe` binds a CUPS queue + paper + ink set + optional `.cal` to a `ProfilingPreset`. Cap: 200 entries; the 201st is refused with an error, never silently evicted. Corrupt JSON → keep the file, load `[]`, persistent warning banner. + +| Field | Type | Notes | +|-------|------|-------| +| `id` | string | `recipe-`, never user-typed | +| `name`, `notes` | string | Rendered through `Text` only (#114) | +| `printer_id` | string | CUPS queue id (`lpstat -e` name) | +| `printer_display_name` | string | Human label; applied to `wizard.printerName` | +| `paper_name`, `ink_set` | string | Library metadata only — never written to targen flags | +| `driver_media_type` | string? | Last captured CUPS `media_type` (read-only) | +| `colour_space` | `"rgb"` \| `"cmyk"` | Must match the bound preset | +| `preset_id` | string | `ProfilingPreset.id` (built-in or custom) | +| `calibration_url` | string? | Absolute `.cal` path, stored verbatim | +| `apply_calibration` | bool | Forced off for `CAL_` stems or missing files | +| `created`, `updated` | iso8601 | | + +Apply path: recipe → `applyPreset` (#82 mapping, no second Stage 1 form) → queue re-enumerated (`lpstat -e`) → `printer_id` absent from a non-empty list warns "not installed" and leaves the queue untouched; an empty list is indeterminate and never flags. `CAL_` bound cal **or** a live `CAL_` wizard basename forces `applyCalibration` off — `printtarg -K` can never see a `CAL_` file (literal refusal; escape hatch is rename + re-capture). Staleness: `.printer` = absent from enumerated queues; `.calibration` = bound cal `CREATED + calibration_stale_days < now`. + +Capture with no preset selected auto-snapshots the live form as a `custom-` preset and binds to it. UI: `#mediaSelect` (immediate apply, `#presetSelect`-style), `#btnMediaLibraryCapture` → `#saveMediaRecipeDialog`, `#btnMediaLibraryManage` → `#manageMediaDialog` (`mediaLibraryList`, `mediaRow-{id}`, `btnMediaLibraryApply-{id}`, `btnMediaLibraryDelete-{id}`), stale badge `#mediaRecipeStale`.