Compare commits

..
Author SHA1 Message Date
gronod e967502ea7 Merge pull request 'test(m10): wait for gamut layer enable; drive New via File menu on macOS 12' (#158) from fix/m10-ci-ui-macos12 into milestone/m10-studio
macOS CI / package (pull_request) Blocked by required conditions
macOS CI / build-and-test (pull_request) In progress
2026-09-13 14:59:45 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> eb3ae08cf9 test(m10): wait for gamut layer enable; drive New via File menu on macOS 12
Actions run 33718 (Xcode 14.2, macOS 12) failed four UI tests that
pass locally. Gamut layer toggles render as disabled placeholders
until the async layer load lands, so waitFor(id) won the race on the
placeholder — added waitUntilEnabled and gate on isEnabled. The
project New test's ⌘N may not reach the CommandGroup and alert
buttons often lack identifiers on macOS 12 — drive New through the
File menu "New Project" item and confirm the alert by button title
with id fallback.

Refs #147 #149 #146

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-13 14:59:11 +01:00
gronod 3d8e70be62 Merge pull request 'fix(ui): split ViewBuilders for Swift 5.7 ten-child limit (#146)' (#157) from fix/146-viewbuilder-ten-child into milestone/m10-studio
macOS CI / build-and-test (pull_request) Failing after 29m46s
macOS CI / package (pull_request) Skipped
2026-09-13 13:35:36 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> f43c275152 fix(ui): split ViewBuilders for Swift 5.7 ten-child limit (#146)
Actions run 33666 (Xcode 14.2, macOS 12) failed to compile: the
sidebar VStack had 14 children and SaveMediaRecipeDialog had 13,
over the Swift 5.7 ViewBuilder 10-child cap. Extracted
layout-transparent Group subviews (header/presetBlock/mediaBlock/
studioButtons/stepperAndProject; fields/readOnlyRows) — no id,
order, or layout change.

Refs #146

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-13 13:33:42 +01:00
gronod 48d2816c73 Merge pull request 'docs(m10): defaultInstrument seeds Spot Read; README floor is macOS 12' (#155) from chore/m10-docs-hygiene into milestone/m10-studio
macOS CI / build-and-test (pull_request) Failing after 1m23s
macOS CI / package (pull_request) Skipped
2026-09-13 12:24:23 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> b494b4fffd docs(m10): defaultInstrument seeds Spot Read; README floor is macOS 12
Refs #148

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-13 12:23:43 +01:00
gronod c39afe0dfd Merge pull request 'feat(project): .icceryproj index over basename and cwd + recipe + last ΔE (#149)' (#154) from feat/149-project-file into milestone/m10-studio 2026-09-13 11:29:58 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 1d78957d7a feat(project): .icceryproj index over basename and cwd + recipe + last ΔE (#149)
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-13 11:28:40 +01:00
gronod 99c0d7e83d Merge pull request 'feat(gamut): second-profile compare and click-inspect (#147)' (#153) from feat/147-gamut-compare into milestone/m10-studio 2026-09-13 10:24:24 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 398d9b4756 feat(gamut): second-profile compare and click-inspect (#147)
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-13 10:23:25 +01:00
gronod 538ef1e05f Merge pull request 'feat(spotread): spot-read console for live Lab (#148)' (#152) from feat/148-spot-read into milestone/m10-studio 2026-09-13 01:04:45 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> d40932f4cf feat(spotread): spot-read console for live Lab (#148)
Refs #148

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-13 01:03:57 +01:00
gronod 1931da8448 Merge pull request 'feat(media): media recipe library bound to presets (#146)' (#151) from feat/146-media-library into milestone/m10-studio 2026-09-12 23:00:21 +01:00
47 changed files with 5833 additions and 119 deletions
@@ -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<SpotReadEvent> {
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 // MARK: - Stage 0 calibration
/// Generates a calibration wedge `.ti1`. /// Generates a calibration wedge `.ti1`.
@@ -828,6 +970,20 @@ public enum ChartreadEvent: Sendable {
case failed(ArgyllRunnerError) 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. /// Exact bytes sent to `chartread` stdin.
public enum ChartreadInput: Sendable { public enum ChartreadInput: Sendable {
case trigger // " \n" case trigger // " \n"
@@ -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
}
}
@@ -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
}
}
@@ -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))
}
/// 01 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)]
// 01 XYZ D65 the 0100 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 } }
}
}
@@ -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<Double>(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<Double>.zero
for vertex in mesh.vertices {
centroid += SIMD3<Double>(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<Double>(mesh.vertices[Int(face.a)].position) - centroid
let b = SIMD3<Double>(mesh.vertices[Int(face.b)].position) - centroid
let c = SIMD3<Double>(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<Double>] = [
SIMD3(1, 0, 0),
simd_normalize(SIMD3<Double>(0.71, 1.0, 0.53)),
simd_normalize(SIMD3<Double>(0.53, 0.71, 1.0)),
]
/// MöllerTrumbore 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<Double>,
direction dir: SIMD3<Double>,
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<Double>(mesh.vertices[Int(face.a)].position)
let vb = SIMD3<Double>(mesh.vertices[Int(face.b)].position)
let vc = SIMD3<Double>(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
}
}
@@ -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
}
}
@@ -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"
}
}
@@ -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)
}
}
@@ -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
}
}
@@ -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)
}
}
@@ -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 0100 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: <x> <y> <z>, D50 Lab: <L> <a> <b>`; 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)
}
}
@@ -4,6 +4,8 @@ import Foundation
/// filter events on `id` historical bug #56 was an id mismatch. /// filter events on `id` historical bug #56 was an id mismatch.
public enum ProcessID { public enum ProcessID {
public static let instlist = "instlist" 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 targen(_ basename: String) -> String { "targen_\(basename)" }
public static func printtarg(_ basename: String) -> String { "printtarg_\(basename)" } public static func printtarg(_ basename: String) -> String { "printtarg_\(basename)" }
+1 -1
View File
@@ -8,7 +8,7 @@ All measurement, chart generation, and profile mathematics live in the [Gronod A
|---|---| |---|---|
| Product | ICCery v2 for macOS | | Product | ICCery v2 for macOS |
| Bundle | `com.gronod.iccery2` | | Bundle | `com.gronod.iccery2` |
| Floor | macOS 14 Sonoma, universal `arm64` + `x86_64` | | Floor | macOS 12.0 Monterey, universal `arm64` + `x86_64` |
| Default branch | `develop` | | Default branch | `develop` |
| M6 | Stage 0 calibration, CGATS import, SceneKit gamut viewer, packaging — shipped on `develop` | | M6 | Stage 0 calibration, CGATS import, SceneKit gamut viewer, packaging — shipped on `develop` |
| M7 | Pre-UAT hardening & baseline consolidation — shipped on `develop` | | M7 | Pre-UAT hardening & baseline consolidation — shipped on `develop` |
+23 -2
View File
@@ -14,6 +14,7 @@ struct AppEnvironment: Sendable {
let cupsService: CupsService let cupsService: CupsService
let historyStore: VerificationHistoryStore let historyStore: VerificationHistoryStore
let mediaStore: MediaLibraryStore let mediaStore: MediaLibraryStore
let recentProjectsStore: RecentProjectsStore
static func live( static func live(
environment: [String: String] = ProcessInfo.processInfo.environment environment: [String: String] = ProcessInfo.processInfo.environment
@@ -21,11 +22,15 @@ struct AppEnvironment: Sendable {
let settingsStore = SettingsStore() let settingsStore = SettingsStore()
var overrideDir = settingsStore.load().argyllBinaryDir var overrideDir = settingsStore.load().argyllBinaryDir
.map { URL(fileURLWithPath: $0) } .map { URL(fileURLWithPath: $0) }
var bundledRoot = AppPaths.bundledArgyllDir
var cupsDir = URL(fileURLWithPath: "/usr/bin") var cupsDir = URL(fileURLWithPath: "/usr/bin")
#if DEBUG #if DEBUG
if let dir = environment["ICCERY_ARGYLL_BINARY_DIR"], !dir.isEmpty { if let dir = environment["ICCERY_ARGYLL_BINARY_DIR"], !dir.isEmpty {
overrideDir = URL(fileURLWithPath: dir) 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 { if let dir = environment["ICCERY_CUPS_BIN_DIR"], !dir.isEmpty {
cupsDir = URL(fileURLWithPath: dir) cupsDir = URL(fileURLWithPath: dir)
} }
@@ -36,13 +41,15 @@ struct AppEnvironment: Sendable {
presetStore: PresetStore(settingsStore: settingsStore), presetStore: PresetStore(settingsStore: settingsStore),
runner: ArgyllRunner( runner: ArgyllRunner(
processManager: .shared, processManager: .shared,
binaryResolver: BinaryResolver(overrideDir: overrideDir) binaryResolver: BinaryResolver(
bundledRoot: bundledRoot, overrideDir: overrideDir)
), ),
cupsService: CupsService( cupsService: CupsService(
processManager: .shared, processManager: .shared,
binaryDir: cupsDir), binaryDir: cupsDir),
historyStore: VerificationHistoryStore(), historyStore: VerificationHistoryStore(),
mediaStore: MediaLibraryStore() mediaStore: MediaLibraryStore(),
recentProjectsStore: RecentProjectsStore()
) )
} }
} }
@@ -76,6 +83,20 @@ enum UITestHooks {
static var presetImportURL: URL? { url("ICCERY_TEST_PRESET_IMPORT") } static var presetImportURL: URL? { url("ICCERY_TEST_PRESET_IMPORT") }
/// Preset export destination. /// Preset export destination.
static var presetExportURL: URL? { url("ICCERY_TEST_PRESET_EXPORT") } 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") }
// MARK: - Print panel / CUPS stubs (issue 13/17) // MARK: - Print panel / CUPS stubs (issue 13/17)
+35 -2
View File
@@ -70,6 +70,19 @@ final class FileDialogService {
message: "Choose a calibration file (.cal)") 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. /// `btnImportPreset` open a `.json` preset file.
func selectPresetFile(startingAt start: URL? = nil) -> URL? { func selectPresetFile(startingAt start: URL? = nil) -> URL? {
open(extensions: ["json"], startingAt: start, open(extensions: ["json"], startingAt: start,
@@ -82,6 +95,23 @@ final class FileDialogService {
message: "Export this preset as JSON") 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) // MARK: - Internals (private not a shared public picker API)
private func save( private func save(
@@ -102,16 +132,19 @@ final class FileDialogService {
private func open( private func open(
extensions: [String], extensions: [String],
startingAt start: URL?, startingAt start: URL?,
message: String? message: String?,
title: String? = nil,
allowsOtherFileTypes: Bool = true
) -> URL? { ) -> URL? {
let panel = NSOpenPanel() let panel = NSOpenPanel()
panel.canChooseDirectories = false panel.canChooseDirectories = false
panel.canChooseFiles = true panel.canChooseFiles = true
panel.allowsMultipleSelection = false panel.allowsMultipleSelection = false
panel.allowedContentTypes = utTypes(extensions) panel.allowedContentTypes = utTypes(extensions)
panel.allowsOtherFileTypes = true panel.allowsOtherFileTypes = allowsOtherFileTypes
panel.directoryURL = start panel.directoryURL = start
if let message { panel.message = message } if let message { panel.message = message }
if let title { panel.title = title }
return run(panel) return run(panel)
} }
+474 -72
View File
@@ -1,5 +1,6 @@
import SwiftUI import SwiftUI
import SceneKit import SceneKit
import Metal
import ICCeryCore import ICCeryCore
import simd import simd
@@ -62,72 +63,300 @@ 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 /// Displays the bundled `sRGB.gam` reference plus up to two profile
/// the CIELAB coordinate convention `x = a*`, `y = L*`, `z = b*` so that the /// meshes with independent visibility toggles, a status line, and an
/// a* (green-red) axis is horizontal, L* (lightness) is vertical, and b* /// inspect panel (click a mesh, type a Lab value, or sample a TIFF
/// (blue-yellow) is depth. /// pixel). Uses the CIELAB coordinate convention `x = a*`, `y = L*`,
/// `z = b*`.
struct GamutView: View { struct GamutView: View {
@StateObject private var viewModel: GamutViewModel @StateObject private var viewModel: GamutViewModel
@State private var pause: () -> Void = {} @State private var pause: () -> Void = {}
@FocusState private var isFocused: Bool @FocusState private var isFocused: Bool
@Binding var showingAllHelp: Bool
init(profileGamURL: URL? = nil) { init(
_viewModel = StateObject(wrappedValue: GamutViewModel(profileGamURL: profileGamURL)) environment: AppEnvironment,
profileGamURL: URL? = nil,
showingAllHelp: Binding<Bool>
) {
_viewModel = StateObject(wrappedValue: GamutViewModel(
environment: environment, profileGamURL: profileGamURL))
_showingAllHelp = showingAllHelp
} }
var body: some View { var body: some View {
ZStack { VStack(spacing: 0) {
GamutSceneView( toolbar
profileMesh: viewModel.profileMesh, Divider().overlay(Theme.border)
referenceMesh: viewModel.sRGBMesh, sceneArea
onReset: $viewModel.resetCamera, Divider().overlay(Theme.border)
onPause: $pause statusLine
) inspectPanel
.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)
}
} }
.frame(minWidth: 500, minHeight: 400) .frame(minWidth: 720, minHeight: 520)
.background(Theme.background)
.onDisappear { pause() } .onDisappear { pause() }
.accessibilityElement(children: .contain) .accessibilityElement(children: .contain)
.accessibilityIdentifier("gamutView") .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 || viewModel.viewerUnavailable)
.help(layer.map { $0.sourceURL.lastPathComponent } ?? "No profile .gam loaded")
.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 0100 · ±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: - 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 /// `NSViewRepresentable` wrapper around an `SCNView` rendering one node
/// one or two ``GamutMesh`` values. /// per ``NamedGamut`` layer.
/// ///
/// Scene construction and camera reset are coordinated through a typed callback /// Layer toggles hide/show `SCNNode`s the scene is built once and the
/// binding owned by the view model. /// camera is only reset through the explicit reset path, never on a
/// mesh or visibility update.
private struct GamutSceneView: NSViewRepresentable { private struct GamutSceneView: NSViewRepresentable {
var profileMesh: GamutMesh? var layers: [NamedGamut]
var referenceMesh: GamutMesh? var visibleIDs: Set<String>
var onReset: Binding<() -> Void> var onReset: Binding<() -> Void>
var onPause: Binding<() -> Void> var onPause: Binding<() -> Void>
var onUnavailable: () -> Void
var onInspect: (SIMD3<Float>, String?) -> Void
func makeNSView(context: Context) -> SCNView { func makeNSView(context: Context) -> SCNView {
let scnView = SCNView() let scnView = SCNView()
@@ -142,14 +371,23 @@ private struct GamutSceneView: NSViewRepresentable {
context.coordinator.scnView = scnView context.coordinator.scnView = scnView
context.coordinator.scene = scene 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.installKeyMonitor()
context.coordinator.installClickGesture()
// No GPU the docs/18 fallback; never respawn the view in a loop.
if MTLCreateSystemDefaultDevice() == nil {
DispatchQueue.main.async { onUnavailable() }
}
return scnView return scnView
} }
func updateNSView(_ nsView: SCNView, context: Context) { 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 { func makeCoordinator() -> Coordinator {
@@ -165,6 +403,9 @@ private struct GamutSceneView: NSViewRepresentable {
static func dismantleNSView(_ nsView: SCNView, coordinator: Coordinator) { static func dismantleNSView(_ nsView: SCNView, coordinator: Coordinator) {
coordinator.removeKeyMonitor() coordinator.removeKeyMonitor()
if let click = coordinator.clickGesture {
nsView.removeGestureRecognizer(click)
}
nsView.isPlaying = false nsView.isPlaying = false
} }
@@ -172,11 +413,14 @@ private struct GamutSceneView: NSViewRepresentable {
final class Coordinator: NSObject { final class Coordinator: NSObject {
weak var scnView: SCNView? weak var scnView: SCNView?
weak var scene: SCNScene? weak var scene: SCNScene?
var onInspect: (SIMD3<Float>, String?) -> Void = { _, _ in }
private(set) var clickGesture: NSClickGestureRecognizer?
private var keyMonitor: Any? private var keyMonitor: Any?
private let profileNode = SCNNode() /// One `SCNNode` per loaded layer, keyed by `NamedGamut.id`.
private let referenceGroup = SCNNode() private var layerNodes: [String: SCNNode] = [:]
private let axisNode = SCNNode() private let axisNode = SCNNode()
private let layerGroup = SCNNode()
private let cameraNode: SCNNode = { private let cameraNode: SCNNode = {
let node = SCNNode() let node = SCNNode()
node.camera = SCNCamera() node.camera = SCNCamera()
@@ -184,33 +428,97 @@ private struct GamutSceneView: NSViewRepresentable {
return node return node
}() }()
func buildScene(profile: GamutMesh?, reference: GamutMesh?) { /// Builds the static scene furniture exactly once axis
guard let scene else { return } /// 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(axisNode)
scene.rootNode.addChildNode(profileNode) scene.rootNode.addChildNode(layerGroup)
scene.rootNode.addChildNode(referenceGroup)
scene.rootNode.addChildNode(cameraNode) scene.rootNode.addChildNode(cameraNode)
buildAxisScaffold() 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) addLights(to: scene)
resetCamera() 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<String>) {
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>(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) { private func addLights(to scene: SCNScene) {
let ambient = SCNNode() let ambient = SCNNode()
ambient.light = SCNLight() ambient.light = SCNLight()
@@ -351,7 +659,8 @@ private struct GamutSceneView: NSViewRepresentable {
return SCNNode(geometry: geometry) 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 (geometry, _) = scnGeometry(for: mesh)
let material = SCNMaterial() let material = SCNMaterial()
@@ -361,11 +670,26 @@ private struct GamutSceneView: NSViewRepresentable {
material.isDoubleSided = true material.isDoubleSided = true
geometry.materials = [material] geometry.materials = [material]
let node = SCNNode(geometry: geometry) return SCNNode(geometry: geometry)
node.name = name
return node
} }
/// 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 { private func referenceMeshNode(_ mesh: GamutMesh) -> SCNNode {
let (geometry, _) = scnGeometry(for: mesh) let (geometry, _) = scnGeometry(for: mesh)
@@ -488,3 +812,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)
}
}
+273 -23
View File
@@ -1,53 +1,303 @@
import Combine import Combine
import Foundation import Foundation
import ICCeryCore import ICCeryCore
import simd
/// View model for the native SceneKit gamut viewer. /// View model for the native SceneKit gamut viewer (issues #28, #147).
/// ///
/// Loads the bundled `sRGB.gam` reference immediately and, optionally, a /// Loads the bundled `sRGB.gam` reference immediately, the workflow's own
/// printer/profile `.gam` from the current working directory. /// 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 @MainActor
final class GamutViewModel: ObservableObject { final class GamutViewModel: ObservableObject {
/// Parsed reference sRGB gamut mesh. /// Stable layer ids also the `gamutLayer-<id>` a11y suffixes.
@Published var sRGBMesh: GamutMesh? static let srgbLayerID = "sRGB"
static let profileLayerID = "profile"
static let compareLayerID = "compare"
/// Parsed printer/profile gamut mesh. /// Loaded meshes: bundled sRGB plus up to two profiles.
@Published var profileMesh: GamutMesh? @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<String> = [srgbLayerID]
/// User-facing status line (`gamutStatusText`). Always non-empty
/// once set `Milestone6GamutUITests` asserts it.
@Published var status = "Loading gamut…" @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. /// Closure injected into the SceneKit view to request a camera reset.
@Published var resetCamera: () -> Void = {} @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 self.profileGamURL = profileGamURL
Task { await load() } loadTask = Task { await load() }
} }
private var loadTask: Task<Void, Never>?
/// 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 { private func load() async {
do { do {
let referenceURL = BinaryResolver().referenceGamut("sRGB") let referenceURL = environment.runner.binaryResolver.referenceGamut("sRGB")
let reference = try await parse(url: referenceURL) let reference = try await parse(url: referenceURL)
sRGBMesh = reference layers.append(NamedGamut(
id: Self.srgbLayerID,
if let profileGamURL { displayName: "sRGB",
let profile = try await parse(url: profileGamURL) role: .reference,
profileMesh = profile mesh: reference,
status = "Profile gamut (\(profile.faces.count) faces) vs sRGB reference" sourceURL: referenceURL))
} else { visibleIDs.insert(Self.srgbLayerID)
status = "sRGB reference gamut (\(reference.faces.count) faces)"
}
} catch { } catch {
status = "Could not load gamut: \(error.localizedDescription)" 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 /// `.icc/.icm` sibling `.gam` or `iccgamut` compare slot.
/// the UI. /// 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 `(xa*, yL*, zb*)`.
/// The swatch is the nearest vertex colour of the hit layer's mesh.
func inspectSceneHit(world: SIMD3<Float>, 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 { private func parse(url: URL) async throws -> GamutMesh {
try await Task.detached { try await Task.detached {
try GamutMeshParser.parse(url: url) try GamutMeshParser.parse(url: url)
+7 -2
View File
@@ -25,8 +25,13 @@ struct ICCeryApp: App {
.preferredColorScheme(.dark) .preferredColorScheme(.dark)
} }
.commands { .commands {
// Single-window app: no File > New window. // Single-window app: the File menu carries the project
CommandGroup(replacing: .newItem) {} // 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)
}
} }
} }
} }
+17 -3
View File
@@ -46,9 +46,11 @@ struct SaveMediaRecipeDialog: View {
|| media.captureColourSpaceMismatch || media.captureColourSpaceMismatch
} }
var body: some View { // Swift 5.7 (Xcode 14.2 CI runner) caps a ViewBuilder body at 10
VStack(alignment: .leading, spacing: 14) { // children (#146); Group blocks are layout-transparent, so field
Text("Save Media Recipe").font(.title3).foregroundStyle(Theme.text) // order and every docs/21 id are unchanged.
private var fields: some View {
Group {
TextField("Name", text: $media.saveMediaName) TextField("Name", text: $media.saveMediaName)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
.accessibilityIdentifier("saveMediaName") .accessibilityIdentifier("saveMediaName")
@@ -61,7 +63,11 @@ struct SaveMediaRecipeDialog: View {
TextField("Ink set", text: $media.saveMediaInk) TextField("Ink set", text: $media.saveMediaInk)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
.accessibilityIdentifier("saveMediaInk") .accessibilityIdentifier("saveMediaInk")
}
}
private var readOnlyRows: some View {
Group {
captureRow("Printer", value: printerCaption, captureRow("Printer", value: printerCaption,
identifier: "saveMediaPrinter") identifier: "saveMediaPrinter")
captureRow("Preset", captureRow("Preset",
@@ -74,6 +80,14 @@ struct SaveMediaRecipeDialog: View {
value: workflow.profile.calibrationFile.isEmpty value: workflow.profile.calibrationFile.isEmpty
? "None" : workflow.profile.calibrationFile, ? "None" : workflow.profile.calibrationFile,
identifier: "saveMediaCal") 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", Toggle("Apply calibration to profile",
isOn: $media.saveMediaApplyCal) isOn: $media.saveMediaApplyCal)
.disabled(!media.calApplyable) .disabled(!media.calApplyable)
+689
View File
@@ -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<AnyCancellable>()
/// 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 25.
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])
}
}
+151
View File
@@ -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")
}
}
+45 -1
View File
@@ -9,6 +9,7 @@ struct RootView: View {
/// Observed directly: nested ObservableObjects are not tracked /// Observed directly: nested ObservableObjects are not tracked
/// through the parent's `objectWillChange`. /// through the parent's `objectWillChange`.
@ObservedObject private var model: WizardViewModel @ObservedObject private var model: WizardViewModel
@ObservedObject private var project: ProjectSession
@State private var showingSettings = false @State private var showingSettings = false
@State private var showingAbout = false @State private var showingAbout = false
@State private var showingAllHelp = false @State private var showingAllHelp = false
@@ -16,6 +17,7 @@ struct RootView: View {
init(workflow: TargetWorkflowViewModel) { init(workflow: TargetWorkflowViewModel) {
self.workflow = workflow self.workflow = workflow
self._model = ObservedObject(wrappedValue: workflow.wizard) self._model = ObservedObject(wrappedValue: workflow.wizard)
self._project = ObservedObject(wrappedValue: workflow.project)
} }
var body: some View { var body: some View {
@@ -67,6 +69,14 @@ struct RootView: View {
) { ) {
ManageMediaDialog(workflow: workflow) 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) { .sheet(isPresented: $showingAbout) {
AboutView { showingAbout = false } AboutView { showingAbout = false }
} }
@@ -74,7 +84,41 @@ struct RootView: View {
get: { workflow.wizard.showingGamutViewer }, get: { workflow.wizard.showingGamutViewer },
set: { workflow.wizard.showingGamutViewer = $0 } 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)
} }
} }
+1 -1
View File
@@ -58,7 +58,7 @@ struct SettingsView: View {
Text($0.label).tag($0.code) 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) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
+64 -2
View File
@@ -11,6 +11,8 @@ struct SidebarView: View {
@ObservedObject private var profile: ProfileWorkflowViewModel @ObservedObject private var profile: ProfileWorkflowViewModel
@ObservedObject private var media: MediaLibraryViewModel @ObservedObject private var media: MediaLibraryViewModel
@ObservedObject private var printSession: PrintSessionViewModel @ObservedObject private var printSession: PrintSessionViewModel
@ObservedObject private var measurement: MeasurementWorkflowViewModel
@ObservedObject private var project: ProjectSession
var onOpenSettings: () -> Void var onOpenSettings: () -> Void
var onOpenAbout: () -> Void var onOpenAbout: () -> Void
@Binding var showingAllHelp: Bool @Binding var showingAllHelp: Bool
@@ -26,6 +28,8 @@ struct SidebarView: View {
self._profile = ObservedObject(wrappedValue: workflow.profile) self._profile = ObservedObject(wrappedValue: workflow.profile)
self._media = ObservedObject(wrappedValue: workflow.media) self._media = ObservedObject(wrappedValue: workflow.media)
self._printSession = ObservedObject(wrappedValue: workflow.print) self._printSession = ObservedObject(wrappedValue: workflow.print)
self._measurement = ObservedObject(wrappedValue: workflow.measurement)
self._project = ObservedObject(wrappedValue: workflow.project)
self.onOpenSettings = onOpenSettings self.onOpenSettings = onOpenSettings
self.onOpenAbout = onOpenAbout self.onOpenAbout = onOpenAbout
self._showingAllHelp = showingAllHelp self._showingAllHelp = showingAllHelp
@@ -33,6 +37,21 @@ struct SidebarView: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 0) { 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 { HStack {
Image("ICCery-logo") Image("ICCery-logo")
.resizable() .resizable()
@@ -61,7 +80,11 @@ struct SidebarView: View {
.padding(12) .padding(12)
Divider().overlay(Theme.border) Divider().overlay(Theme.border)
}
}
private var presetBlock: some View {
Group {
// Preset select (`#presetSelect`) issue #11. Selection // Preset select (`#presetSelect`) issue #11. Selection
// applies the preset immediately; names render via Text only. // applies the preset immediately; names render via Text only.
Picker("Preset", selection: Binding( Picker("Preset", selection: Binding(
@@ -93,7 +116,11 @@ struct SidebarView: View {
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.bottom, 8) .padding(.bottom, 8)
}
}
private var mediaBlock: some View {
Group {
// Media library (`#mediaSelect`) issue #146. Selection // Media library (`#mediaSelect`) issue #146. Selection
// applies the recipe immediately, like presets; names render // applies the recipe immediately, like presets; names render
// via Text only (#114). Never reuses `presetSelect` (#137). // via Text only (#114). Never reuses `presetSelect` (#137).
@@ -143,7 +170,11 @@ struct SidebarView: View {
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.bottom, 8) .padding(.bottom, 8)
}
}
private var studioButtons: some View {
Group {
// Calibrate Printer (`#btnCalibratePrinter`). // Calibrate Printer (`#btnCalibratePrinter`).
Button(action: { model.enterCalibration() }) { Button(action: { model.enterCalibration() }) {
Label("Calibrate Printer", systemImage: "slider.horizontal.3") Label("Calibrate Printer", systemImage: "slider.horizontal.3")
@@ -158,9 +189,36 @@ struct SidebarView: View {
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
.controlSize(.large) .controlSize(.large)
.helpOverlay(
"View the profile gamut in 3D against sRGB.",
showing: $showingAllHelp)
.accessibilityIdentifier("btnViewGamut") .accessibilityIdentifier("btnViewGamut")
.padding(.horizontal, 12) .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) Divider().overlay(Theme.border)
.padding(.vertical, 8) .padding(.vertical, 8)
@@ -180,9 +238,13 @@ struct SidebarView: View {
.padding(.horizontal, 6) .padding(.horizontal, 6)
Spacer() 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)
} }
} }
+360
View File
@@ -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)
}
}
+420
View File
@@ -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<Void, Never>?
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 0100 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: "\"\""))\""
}
}
+19 -1
View File
@@ -102,6 +102,11 @@ final class TargetWorkflowViewModel: ObservableObject {
@Published var showingSaveMedia = false @Published var showingSaveMedia = false
@Published var showingManageMedia = 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 /// Stage 3 measurement workflow, owned at the app level so it persists
/// across stage switches and can observe settings changes. /// across stage switches and can observe settings changes.
@Published var measurement: MeasurementWorkflowViewModel @Published var measurement: MeasurementWorkflowViewModel
@@ -112,8 +117,13 @@ final class TargetWorkflowViewModel: ObservableObject {
@Published var calibration: CalibrationViewModel! @Published var calibration: CalibrationViewModel!
/// Stage 2 unmanaged print session. /// Stage 2 unmanaged print session.
@Published var print: PrintSessionViewModel! @Published var print: PrintSessionViewModel!
/// Media recipe library, created last it needs a complete `self`. /// Media recipe library needs a complete `self`.
@Published var media: MediaLibraryViewModel! @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()) { init(environment: AppEnvironment = .live()) {
self.environment = environment self.environment = environment
@@ -137,6 +147,14 @@ final class TargetWorkflowViewModel: ObservableObject {
workflow: self, workflow: self,
environment: environment environment: environment
) )
self.spotRead = SpotReadViewModel(
workflow: self,
environment: environment
)
self.project = ProjectSession(
workflow: self,
environment: environment
)
reloadPresets() reloadPresets()
} }
@@ -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)
}
}
@@ -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)
}
}
@@ -0,0 +1,127 @@
import Foundation
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)")
}
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)
}
}
@@ -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))
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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"))
}
}
}
@@ -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)
}
}
@@ -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)
}
}
+18 -2
View File
@@ -22,10 +22,21 @@ struct TestAppEnvironment {
var mediaLibraryURL: URL { var mediaLibraryURL: URL {
root.appendingPathComponent("media_library.json") root.appendingPathComponent("media_library.json")
} }
var recentProjectsURL: URL {
root.appendingPathComponent("recent_projects.json")
}
/// Creates an isolated environment under `NSTemporaryDirectory()`. /// Creates an isolated environment under `NSTemporaryDirectory()`.
/// Call `cleanup()` when finished. /// 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 let root = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-test-env-\(UUID().uuidString)") .appendingPathComponent("iccery-test-env-\(UUID().uuidString)")
try FileManager.default.createDirectory( try FileManager.default.createDirectory(
@@ -44,7 +55,9 @@ struct TestAppEnvironment {
presetStore: PresetStore(settingsStore: settingsStore), presetStore: PresetStore(settingsStore: settingsStore),
runner: ArgyllRunner( runner: ArgyllRunner(
processManager: processManager, processManager: processManager,
binaryResolver: BinaryResolver(overrideDir: nil) binaryResolver: BinaryResolver(
bundledRoot: bundledArgyllRoot ?? AppPaths.bundledArgyllDir,
overrideDir: argyllBinDir)
), ),
cupsService: CupsService( cupsService: CupsService(
processManager: processManager, processManager: processManager,
@@ -55,6 +68,9 @@ struct TestAppEnvironment {
), ),
mediaStore: MediaLibraryStore( mediaStore: MediaLibraryStore(
url: root.appendingPathComponent("media_library.json") url: root.appendingPathComponent("media_library.json")
),
recentProjectsStore: RecentProjectsStore(
url: root.appendingPathComponent("recent_projects.json")
) )
) )
return TestAppEnvironment(root: root, environment: environment) return TestAppEnvironment(root: root, environment: environment)
+48
View File
@@ -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())
@@ -0,0 +1,202 @@
import Foundation
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 {
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 {
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")
}
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")
}
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)")
}
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")
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)
}
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")
}
func testInspectPanelIdleStableHeight() throws {
openGamutSheet()
let panel = waitFor("gamutInspectPanel")
XCTAssertTrue(panel.exists)
XCTAssertTrue(element("gamutInspectIdle").exists)
XCTAssertTrue(element("gamutStatusText").exists)
}
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)
}
func testResetIdentifierUnchanged() throws {
openGamutSheet()
let reset = waitFor("btnResetGamutCamera")
XCTAssertTrue(reset.isEnabled)
}
}
@@ -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)
}
}
@@ -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()
}
}
+3 -1
View File
@@ -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 | | `dispwin` | Never spawned. ICCery#90 “Emissive display calibration (dispwin & dispread)” = Won't Fix |
| `dispread` | Same | | `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:614`) but **no JS caller**. Always `cwd=None`. Exists as an escape hatch | | Generic `spawn_process` | **Registered** (`lib.rs:55`, `commands.rs:614`) 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) | | `profcheck_{ti3_path}` | profcheck (full path) |
| `iccgamut_{stem}` | iccgamut | | `iccgamut_{stem}` | iccgamut |
| `instlist` | instlist (literal) | | `instlist` | instlist (literal) |
| `spotread` | spotread (literal, issue #148) |
| caller-supplied | unused `spawn_process` | | caller-supplied | unused `spawn_process` |
### 12.3 Duplicate rejection (ICCery#116, `07d28eb`) ### 12.3 Duplicate rejection (ICCery#116, `07d28eb`)
@@ -830,6 +831,7 @@ From `lib.rs:54119` plus the command bodies:
| `run_profcheck` | profcheck | `profcheck_{ti3_path}` | | `run_profcheck` | profcheck | `profcheck_{ti3_path}` |
| `extract_gamut` | iccgamut | `iccgamut_{stem}` | | `extract_gamut` | iccgamut | `iccgamut_{stem}` |
| `detect_instruments` | instlist | `instlist` | | `detect_instruments` | instlist | `instlist` |
| `run_spotread` | spotread (`-v -e [-c port] [-Y l]`, no `-u`) | `spotread` |
| `generate_calibration_target` | targen | `targen_{CAL_basename}` | | `generate_calibration_target` | targen | `targen_{CAL_basename}` |
### Argyll runners (captured, no events) ### Argyll runners (captured, no events)
+6
View File
@@ -46,3 +46,9 @@ Never spawn with `cwd: ""`. Initialize from `get_default_working_dir` (Documents
## No `"test_target"` fallbacks (#60) ## 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. 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.
+11
View File
@@ -80,3 +80,14 @@ If an unpatched binary rejects `-Y l`, capture last stderr line and expand Proce
## Interactive buttons vs real keys ## 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. 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).
+32
View File
@@ -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). | | #185 axes / EdgesGeometry / vertex colour / legend | All present (GridHelper kept; CSS2D parent-visibility bug). |
| #212 Node test crash | Polyfill + dynamic import + `typeof window` guard. | | #212 Node test crash | Polyfill + dynamic import + `typeof window` guard. |
| #225 Monterey WebGL | Lazy ensure, feature-detect, pause rAF, context-lost, low-power flags. | | #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).
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -7,7 +7,7 @@ Persisted at `{app_data}/settings.json` via `load_settings` / `save_settings`. I
| Field | Default | Notes | | Field | Default | Notes |
|-------|---------|-------| |-------|---------|-------|
| `argyll_binary_dir` | `null` | Overrides bundled sidecars. `resolve_binary` checks this first. | | `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). | | `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_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. | | `delta_e_warning_max` | `5.0` | Stage 3 "Warning" band. Must be **strictly greater** than good. |