Compare commits

...
Author SHA1 Message Date
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
28 changed files with 3849 additions and 104 deletions
@@ -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
}
}
+15 -1
View File
@@ -14,6 +14,7 @@ struct AppEnvironment: Sendable {
let cupsService: CupsService
let historyStore: VerificationHistoryStore
let mediaStore: MediaLibraryStore
let recentProjectsStore: RecentProjectsStore
static func live(
environment: [String: String] = ProcessInfo.processInfo.environment
@@ -47,7 +48,8 @@ struct AppEnvironment: Sendable {
processManager: .shared,
binaryDir: cupsDir),
historyStore: VerificationHistoryStore(),
mediaStore: MediaLibraryStore()
mediaStore: MediaLibraryStore(),
recentProjectsStore: RecentProjectsStore()
)
}
}
@@ -83,6 +85,18 @@ enum UITestHooks {
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)
+35 -2
View File
@@ -70,6 +70,19 @@ final class FileDialogService {
message: "Choose a calibration file (.cal)")
}
/// `selectGamutFile` `.gam` surface mesh for the compare slot (#147).
func selectGamutFile(startingAt start: URL? = nil) -> URL? {
open(extensions: ["gam"], startingAt: start,
message: "Choose a .gam surface mesh",
allowsOtherFileTypes: false)
}
/// `selectTiffFile` `.tif`/`.tiff` target page for gamut sampling (#147).
func selectTiffFile(startingAt start: URL? = nil) -> URL? {
open(extensions: ["tif", "tiff"], startingAt: start,
message: "Choose a target TIFF page")
}
/// `btnImportPreset` open a `.json` preset file.
func selectPresetFile(startingAt start: URL? = nil) -> URL? {
open(extensions: ["json"], startingAt: start,
@@ -82,6 +95,23 @@ final class FileDialogService {
message: "Export this preset as JSON")
}
/// `selectProjectFile` open `.icceryproj` only (issue #149).
func selectProjectFile(startingAt start: URL? = nil) -> URL? {
open(extensions: ["icceryproj"], startingAt: start,
message: "Open ICCery Project",
title: "Open ICCery Project",
allowsOtherFileTypes: false)
}
/// `selectProjectSavePath` save `.icceryproj`, suggested name
/// `{basename}.icceryproj` in the working folder (issue #149).
func selectProjectSavePath(
basename: String, startingAt start: URL? = nil
) -> URL? {
save(named: "\(basename).icceryproj", extensions: ["icceryproj"],
startingAt: start, message: "Save ICCery Project")
}
// MARK: - Internals (private not a shared public picker API)
private func save(
@@ -102,16 +132,19 @@ final class FileDialogService {
private func open(
extensions: [String],
startingAt start: URL?,
message: String?
message: String?,
title: String? = nil,
allowsOtherFileTypes: Bool = true
) -> URL? {
let panel = NSOpenPanel()
panel.canChooseDirectories = false
panel.canChooseFiles = true
panel.allowsMultipleSelection = false
panel.allowedContentTypes = utTypes(extensions)
panel.allowsOtherFileTypes = true
panel.allowsOtherFileTypes = allowsOtherFileTypes
panel.directoryURL = start
if let message { panel.message = message }
if let title { panel.title = title }
return run(panel)
}
+474 -72
View File
@@ -1,5 +1,6 @@
import SwiftUI
import SceneKit
import Metal
import ICCeryCore
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
/// the CIELAB coordinate convention `x = a*`, `y = L*`, `z = b*` so that the
/// a* (green-red) axis is horizontal, L* (lightness) is vertical, and b*
/// (blue-yellow) is depth.
/// Displays the bundled `sRGB.gam` reference plus up to two profile
/// meshes with independent visibility toggles, a status line, and an
/// inspect panel (click a mesh, type a Lab value, or sample a TIFF
/// pixel). Uses the CIELAB coordinate convention `x = a*`, `y = L*`,
/// `z = b*`.
struct GamutView: View {
@StateObject private var viewModel: GamutViewModel
@State private var pause: () -> Void = {}
@FocusState private var isFocused: Bool
@Binding var showingAllHelp: Bool
init(profileGamURL: URL? = nil) {
_viewModel = StateObject(wrappedValue: GamutViewModel(profileGamURL: profileGamURL))
init(
environment: AppEnvironment,
profileGamURL: URL? = nil,
showingAllHelp: Binding<Bool>
) {
_viewModel = StateObject(wrappedValue: GamutViewModel(
environment: environment, profileGamURL: profileGamURL))
_showingAllHelp = showingAllHelp
}
var body: some View {
ZStack {
GamutSceneView(
profileMesh: viewModel.profileMesh,
referenceMesh: viewModel.sRGBMesh,
onReset: $viewModel.resetCamera,
onPause: $pause
)
.focusable()
.focused($isFocused)
.onAppear { isFocused = true }
VStack {
HStack {
Spacer()
Button(action: { viewModel.resetCamera() }) {
Text("Reset view")
}
.accessibilityIdentifier("btnResetGamutCamera")
.padding(8)
}
Spacer()
HStack {
Text(viewModel.status)
.font(.caption)
.padding(8)
.background(.thinMaterial)
.cornerRadius(6)
.accessibilityIdentifier("gamutStatusText")
Spacer()
}
.padding(8)
}
VStack(spacing: 0) {
toolbar
Divider().overlay(Theme.border)
sceneArea
Divider().overlay(Theme.border)
statusLine
inspectPanel
}
.frame(minWidth: 500, minHeight: 400)
.frame(minWidth: 720, minHeight: 520)
.background(Theme.background)
.onDisappear { pause() }
.accessibilityElement(children: .contain)
.accessibilityIdentifier("gamutView")
.sheet(isPresented: $viewModel.showingTiffPreview) {
tiffPreviewSheet
}
}
// MARK: - Toolbar
private var toolbar: some View {
HStack(spacing: 12) {
layerToggle(id: GamutViewModel.srgbLayerID, fallback: "sRGB")
layerToggle(id: GamutViewModel.profileLayerID, fallback: "Profile")
layerToggle(id: GamutViewModel.compareLayerID, fallback: "Compare")
Spacer()
addCompareMenu
Button("Remove compare") { viewModel.removeCompare() }
.disabled(viewModel.layer(id: GamutViewModel.compareLayerID) == nil)
.accessibilityIdentifier("btnGamutRemoveCompare")
Button("Sample TIFF…") { viewModel.openTiffSample() }
.accessibilityIdentifier("btnGamutSampleTiff")
.helpOverlay(
"Sample a colour from a target TIFF page.",
showing: $showingAllHelp)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
private func layerToggle(id: String, fallback: String) -> some View {
let layer = viewModel.layer(id: id)
return Toggle(isOn: Binding(
get: { layer != nil && viewModel.visibleIDs.contains(id) },
set: { on in
if on {
viewModel.visibleIDs.insert(id)
} else {
viewModel.visibleIDs.remove(id)
}
}
)) {
Text(layer?.displayName ?? fallback)
}
.toggleStyle(.checkbox)
.disabled(layer == nil || 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
/// one or two ``GamutMesh`` values.
/// `NSViewRepresentable` wrapper around an `SCNView` rendering one node
/// per ``NamedGamut`` layer.
///
/// Scene construction and camera reset are coordinated through a typed callback
/// binding owned by the view model.
/// Layer toggles hide/show `SCNNode`s the scene is built once and the
/// camera is only reset through the explicit reset path, never on a
/// mesh or visibility update.
private struct GamutSceneView: NSViewRepresentable {
var profileMesh: GamutMesh?
var referenceMesh: GamutMesh?
var layers: [NamedGamut]
var visibleIDs: Set<String>
var onReset: Binding<() -> Void>
var onPause: Binding<() -> Void>
var onUnavailable: () -> Void
var onInspect: (SIMD3<Float>, String?) -> Void
func makeNSView(context: Context) -> SCNView {
let scnView = SCNView()
@@ -142,14 +371,23 @@ private struct GamutSceneView: NSViewRepresentable {
context.coordinator.scnView = scnView
context.coordinator.scene = scene
context.coordinator.buildScene(profile: profileMesh, reference: referenceMesh)
context.coordinator.onInspect = onInspect
context.coordinator.buildSceneOnce()
context.coordinator.syncLayers(layers, visibleIDs: visibleIDs)
context.coordinator.installKeyMonitor()
context.coordinator.installClickGesture()
// No GPU the docs/18 fallback; never respawn the view in a loop.
if MTLCreateSystemDefaultDevice() == nil {
DispatchQueue.main.async { onUnavailable() }
}
return scnView
}
func updateNSView(_ nsView: SCNView, context: Context) {
context.coordinator.buildScene(profile: profileMesh, reference: referenceMesh)
context.coordinator.onInspect = onInspect
context.coordinator.syncLayers(layers, visibleIDs: visibleIDs)
}
func makeCoordinator() -> Coordinator {
@@ -165,6 +403,9 @@ private struct GamutSceneView: NSViewRepresentable {
static func dismantleNSView(_ nsView: SCNView, coordinator: Coordinator) {
coordinator.removeKeyMonitor()
if let click = coordinator.clickGesture {
nsView.removeGestureRecognizer(click)
}
nsView.isPlaying = false
}
@@ -172,11 +413,14 @@ private struct GamutSceneView: NSViewRepresentable {
final class Coordinator: NSObject {
weak var scnView: SCNView?
weak var scene: SCNScene?
var onInspect: (SIMD3<Float>, String?) -> Void = { _, _ in }
private(set) var clickGesture: NSClickGestureRecognizer?
private var keyMonitor: Any?
private let profileNode = SCNNode()
private let referenceGroup = SCNNode()
/// One `SCNNode` per loaded layer, keyed by `NamedGamut.id`.
private var layerNodes: [String: SCNNode] = [:]
private let axisNode = SCNNode()
private let layerGroup = SCNNode()
private let cameraNode: SCNNode = {
let node = SCNNode()
node.camera = SCNCamera()
@@ -184,33 +428,97 @@ private struct GamutSceneView: NSViewRepresentable {
return node
}()
func buildScene(profile: GamutMesh?, reference: GamutMesh?) {
guard let scene else { return }
/// Builds the static scene furniture exactly once axis
/// scaffold, lights, camera home. Layer content lives under
/// `layerGroup` and is managed by `syncLayers`.
func buildSceneOnce() {
guard let scene, scene.rootNode.childNodes.isEmpty else { return }
// Rebuild from scratch on every mesh change to avoid stale geometry.
scene.rootNode.childNodes.forEach { $0.removeFromParentNode() }
scene.rootNode.addChildNode(axisNode)
scene.rootNode.addChildNode(profileNode)
scene.rootNode.addChildNode(referenceGroup)
scene.rootNode.addChildNode(layerGroup)
scene.rootNode.addChildNode(cameraNode)
buildAxisScaffold()
if let profile {
profileNode.addChildNode(profileMeshNode(profile, name: "profile"))
} else {
profileNode.childNodes.forEach { $0.removeFromParentNode() }
}
if let reference {
referenceGroup.childNodes.forEach { $0.removeFromParentNode() }
referenceGroup.addChildNode(referenceMeshNode(reference))
}
addLights(to: scene)
resetCamera()
}
/// Reconciles the node set with `layers` and `visibleIDs`.
///
/// New layers get a node; removed layers lose theirs; hidden
/// layers keep their mesh (`isHidden` only). Never rebuilds the
/// scene, so the camera is untouched by a checkbox toggle.
func syncLayers(_ layers: [NamedGamut], visibleIDs: Set<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) {
let ambient = SCNNode()
ambient.light = SCNLight()
@@ -351,7 +659,8 @@ private struct GamutSceneView: NSViewRepresentable {
return SCNNode(geometry: geometry)
}
private func profileMeshNode(_ mesh: GamutMesh, name: String) -> SCNNode {
/// Profile A: solid vertex-coloured surface.
private func profileMeshNode(_ mesh: GamutMesh) -> SCNNode {
let (geometry, _) = scnGeometry(for: mesh)
let material = SCNMaterial()
@@ -361,11 +670,26 @@ private struct GamutSceneView: NSViewRepresentable {
material.isDoubleSided = true
geometry.materials = [material]
let node = SCNNode(geometry: geometry)
node.name = name
return node
return SCNNode(geometry: geometry)
}
/// Compare profile B: same vertex colours at ~30 % opacity so
/// overlaps with A and the sRGB reference stay readable.
private func compareMeshNode(_ mesh: GamutMesh) -> SCNNode {
let (geometry, _) = scnGeometry(for: mesh)
let material = SCNMaterial()
material.lightingModel = .lambert
material.diffuse.contents = NSColor.white
material.transparency = 0.30
material.isDoubleSided = true
material.writesToDepthBuffer = false
geometry.materials = [material]
return SCNNode(geometry: geometry)
}
/// Bundled sRGB reference: faint fill + structural edge lines.
private func referenceMeshNode(_ mesh: GamutMesh) -> SCNNode {
let (geometry, _) = scnGeometry(for: mesh)
@@ -488,3 +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 Foundation
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
/// printer/profile `.gam` from the current working directory.
/// Loads the bundled `sRGB.gam` reference immediately, the workflow's own
/// profile `.gam` when one exists, and an optional compare mesh the user
/// adds from the sheet toolbar. `iccgamut` failure is an in-sheet info
/// notice, never fatal (#24).
@MainActor
final class GamutViewModel: ObservableObject {
/// Parsed reference sRGB gamut mesh.
@Published var sRGBMesh: GamutMesh?
/// Stable layer ids also the `gamutLayer-<id>` a11y suffixes.
static let srgbLayerID = "sRGB"
static let profileLayerID = "profile"
static let compareLayerID = "compare"
/// Parsed printer/profile gamut mesh.
@Published var profileMesh: GamutMesh?
/// Loaded meshes: bundled sRGB plus up to two profiles.
@Published var layers: [NamedGamut] = []
/// User-facing status line.
/// Layer ids currently shown in the scene. Toggling never unloads
/// the mesh the `SCNNode` is hidden only.
@Published var visibleIDs: Set<String> = [srgbLayerID]
/// User-facing status line (`gamutStatusText`). Always non-empty
/// once set `Milestone6GamutUITests` asserts it.
@Published var status = "Loading gamut…"
/// In-sheet info line (`gamutNoticeText`). The main `NoticeBanner`
/// sits behind the sheet, so notices surface here instead.
@Published var noticeText: String?
/// Set when `SCNView` cannot create a render context; the scene is
/// replaced by the docs/18 fallback text (`gamutViewerUnavailable`).
@Published var viewerUnavailable = false
/// Closure injected into the SceneKit view to request a camera reset.
@Published var resetCamera: () -> Void = {}
private let profileGamURL: URL?
// MARK: - Inspect panel
init(profileGamURL: URL? = nil) {
/// Lab point currently inspected, or `nil` for the idle state.
@Published var inspectLab: LabColor?
/// Swatch colour: the hit vertex's `rgb`, or the approximate sRGB of
/// the inspected Lab.
@Published var inspectSwatch: DisplayRGB?
/// `true` when the swatch/Lab came from the approximate helper or a
/// typed value drives the "approx. Lab, not ColorSync" caption.
@Published var inspectIsApproximate = false
/// Per-layer containment for `inspectLab`, in layer order.
@Published var inspectResults: [(id: String, name: String, containment: GamutContainment)] = []
/// Manual Lab entry fields (`gamutLabEntry*`).
@Published var labEntryL = ""
@Published var labEntryA = ""
@Published var labEntryB = ""
// MARK: - TIFF sampling
/// PNG bytes for the preview sheet (`gamutTiffPreview`).
@Published var tiffPreviewPNG: Data?
@Published var showingTiffPreview = false
private let environment: AppEnvironment
private let profileGamURL: URL?
private let fileDialogs = FileDialogService.shared
init(environment: AppEnvironment, profileGamURL: URL? = nil) {
self.environment = environment
self.profileGamURL = profileGamURL
Task { await load() }
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 {
do {
let referenceURL = BinaryResolver().referenceGamut("sRGB")
let referenceURL = environment.runner.binaryResolver.referenceGamut("sRGB")
let reference = try await parse(url: referenceURL)
sRGBMesh = reference
if let profileGamURL {
let profile = try await parse(url: profileGamURL)
profileMesh = profile
status = "Profile gamut (\(profile.faces.count) faces) vs sRGB reference"
} else {
status = "sRGB reference gamut (\(reference.faces.count) faces)"
}
layers.append(NamedGamut(
id: Self.srgbLayerID,
displayName: "sRGB",
role: .reference,
mesh: reference,
sourceURL: referenceURL))
visibleIDs.insert(Self.srgbLayerID)
} catch {
status = "Could not load gamut: \(error.localizedDescription)"
return
}
if let profileGamURL {
do {
let profile = try await parse(url: profileGamURL)
layers.append(NamedGamut(
id: Self.profileLayerID,
displayName: profileGamURL.deletingPathExtension().lastPathComponent,
role: .profileA,
mesh: profile,
sourceURL: profileGamURL))
visibleIDs.insert(Self.profileLayerID)
} catch {
// #24 a missing/unparseable profile mesh is info, not fatal.
noticeText = "Profile gamut could not be loaded: \(error.localizedDescription)"
}
}
refreshStatus()
}
// MARK: - Compare slot (profile B)
/// `btnGamutOpenGam` pick an existing `.gam` for the compare slot.
func openCompareGam() {
let url = UITestHooks.isEnabled
? UITestHooks.gamutFileURL
: fileDialogs.selectGamutFile()
guard let url else { return }
Task { await loadCompareGam(url: url) }
}
/// `btnGamutOpenProfile` pick `.icc/.icm`; uses a sibling `.gam`
/// when present, otherwise runs bundled `iccgamut -v -d 10` (#24).
func openCompareProfile() {
let url = UITestHooks.isEnabled
? UITestHooks.gamutProfileURL
: fileDialogs.selectProfileFile()
guard let url else { return }
Task { await loadCompareProfile(url: url) }
}
/// `btnGamutRemoveCompare` drops layer B, leaves sRGB + A.
func removeCompare() {
layers.removeAll { $0.id == Self.compareLayerID }
visibleIDs.remove(Self.compareLayerID)
refreshStatus()
}
/// Parses `url` into the compare slot. Internal for tests the UI
/// reaches it through `openCompareGam` / `openCompareProfile`.
func loadCompareGam(url: URL) async {
do {
let mesh = try await parse(url: url)
installCompare(NamedGamut(
id: Self.compareLayerID,
displayName: url.deletingPathExtension().lastPathComponent,
role: .profileB,
mesh: mesh,
sourceURL: url))
} catch {
noticeText = "Could not load compare gamut: \(error.localizedDescription)"
}
}
/// Parses a `.gam` file off the main actor so large meshes do not stall
/// the UI.
/// `.icc/.icm` sibling `.gam` or `iccgamut` compare slot.
/// Internal for tests.
func loadCompareProfile(url: URL) async {
let gamURL = url.deletingPathExtension().appendingPathExtension("gam")
do {
if !FileManager.default.fileExists(atPath: gamURL.path) {
_ = try await environment.runner.runIccgamut(
config: IccgamutConfig(profileURL: url))
}
await loadCompareGam(url: gamURL)
} catch {
noticeText = "Gamut extraction failed: \(error.localizedDescription)"
}
}
/// A third profile replaces B the compare slot never stacks and
/// sRGB is never touched.
private func installCompare(_ gamut: NamedGamut) {
if layers.contains(where: { $0.id == gamut.id }) {
noticeText = "Compare slot holds one profile. The previous compare mesh was replaced."
}
layers.removeAll { $0.id == gamut.id }
layers.append(gamut)
visibleIDs.insert(gamut.id)
refreshStatus()
}
// MARK: - Status line
/// `sRGB 448v / 892 faces · Profile 1024v / 2048 faces · vol 62% of sRGB`.
/// Unloaded layers are omitted; the volume clause appears only when
/// both volumes are finite and positive.
private func refreshStatus() {
var clauses = layers.map {
"\($0.displayName) \($0.mesh.vertices.count)v / \($0.mesh.faces.count) faces"
}
if let srgb = layer(id: Self.srgbLayerID),
let profile = layer(id: Self.profileLayerID) {
let srgbVolume = GamutGeometry.volume(of: srgb.mesh)
let profileVolume = GamutGeometry.volume(of: profile.mesh)
if srgbVolume.isFinite, srgbVolume > 0,
profileVolume.isFinite, profileVolume > 0 {
clauses.append("vol \(Int((profileVolume / srgbVolume * 100).rounded()))% of sRGB")
}
}
status = clauses.isEmpty ? "No gamut loaded" : clauses.joined(separator: " · ")
}
// MARK: - Inspect
/// Whether every manual Lab field parses as a number; the Inspect
/// button is disabled while this is false.
var canInspectLab: Bool {
[labEntryL, labEntryA, labEntryB].allSatisfy { Double($0) != nil }
}
/// Runs containment for a Lab point and publishes the inspect row.
func inspect(lab: LabColor, swatch: DisplayRGB?, isApproximate: Bool) {
inspectLab = lab
inspectSwatch = swatch ?? LabColorMath.labToSRGB(lab)
inspectIsApproximate = isApproximate
inspectResults = layers.map {
($0.id, $0.displayName, GamutGeometry.containment(of: lab, in: $0.mesh))
}
}
/// Click on the axis scaffold or empty background returns the panel
/// to idle.
func clearInspect() {
inspectLab = nil
inspectSwatch = nil
inspectIsApproximate = false
inspectResults = []
}
/// SceneKit hit callback: world `(x, y, z)` Lab `(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 {
try await Task.detached {
try GamutMeshParser.parse(url: url)
+7 -2
View File
@@ -25,8 +25,13 @@ struct ICCeryApp: App {
.preferredColorScheme(.dark)
}
.commands {
// Single-window app: no File > New window.
CommandGroup(replacing: .newItem) {}
// Single-window app: the File menu carries the project
// commands (issue #149). Replacing `.newItem` keeps
// SwiftUI's empty default New from stacking (R19 the
// group is filled, so no second New appears).
CommandGroup(replacing: .newItem) {
ProjectCommands(workflow: workflow)
}
}
}
}
+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")
}
}
+37 -1
View File
@@ -9,6 +9,7 @@ struct RootView: View {
/// Observed directly: nested ObservableObjects are not tracked
/// through the parent's `objectWillChange`.
@ObservedObject private var model: WizardViewModel
@ObservedObject private var project: ProjectSession
@State private var showingSettings = false
@State private var showingAbout = false
@State private var showingAllHelp = false
@@ -16,6 +17,7 @@ struct RootView: View {
init(workflow: TargetWorkflowViewModel) {
self.workflow = workflow
self._model = ObservedObject(wrappedValue: workflow.wizard)
self._project = ObservedObject(wrappedValue: workflow.project)
}
var body: some View {
@@ -82,7 +84,41 @@ struct RootView: View {
get: { workflow.wizard.showingGamutViewer },
set: { workflow.wizard.showingGamutViewer = $0 }
)) {
GamutView(profileGamURL: workflow.wizard.gamutProfileURL)
GamutView(
environment: workflow.environment,
profileGamURL: workflow.wizard.gamutProfileURL,
showingAllHelp: $showingAllHelp)
}
// Project file chrome (issue #149): window title, New confirm
// alert, dirty alert, relocate sheet. Panels never appear from
// a View UITestHooks inject fixture paths instead.
.onReceive(project.$windowTitle) { title in
for window in NSApp.windows where !(window is NSPanel) {
window.title = title
}
}
.alert("Start a new project?", isPresented: $project.showingNewAlert) {
Button("Cancel", role: .cancel) {}
.accessibilityIdentifier("btnProjectNewCancel")
Button("Start") { project.confirmNew() }
.accessibilityIdentifier("btnProjectNewConfirm")
} message: {
Text("The working folder and targets on disk are not deleted.")
.accessibilityIdentifier("projectNewAlert")
}
.alert(
"Save the current project first?",
isPresented: $project.showingDirtyAlert
) {
Button("Save") { project.resolveDirty(save: true) }
.accessibilityIdentifier("btnProjectDirtySave")
Button("Don't Save") { project.resolveDirty(save: false) }
.accessibilityIdentifier("btnProjectDirtyDiscard")
Button("Cancel", role: .cancel) { project.cancelDirty() }
.accessibilityIdentifier("btnProjectDirtyCancel")
}
.sheet(isPresented: $project.showingRelocateSheet) {
ProjectRelocateSheet(project: project)
}
}
+11
View File
@@ -12,6 +12,7 @@ struct SidebarView: View {
@ObservedObject private var media: MediaLibraryViewModel
@ObservedObject private var printSession: PrintSessionViewModel
@ObservedObject private var measurement: MeasurementWorkflowViewModel
@ObservedObject private var project: ProjectSession
var onOpenSettings: () -> Void
var onOpenAbout: () -> Void
@Binding var showingAllHelp: Bool
@@ -28,6 +29,7 @@ struct SidebarView: View {
self._media = ObservedObject(wrappedValue: workflow.media)
self._printSession = ObservedObject(wrappedValue: workflow.print)
self._measurement = ObservedObject(wrappedValue: workflow.measurement)
self._project = ObservedObject(wrappedValue: workflow.project)
self.onOpenSettings = onOpenSettings
self.onOpenAbout = onOpenAbout
self._showingAllHelp = showingAllHelp
@@ -160,6 +162,9 @@ struct SidebarView: View {
.frame(maxWidth: .infinity)
}
.controlSize(.large)
.helpOverlay(
"View the profile gamut in 3D against sRGB.",
showing: $showingAllHelp)
.accessibilityIdentifier("btnViewGamut")
.padding(.horizontal, 12)
@@ -202,6 +207,12 @@ struct SidebarView: View {
.padding(.horizontal, 6)
Spacer()
// Project chip (issue #149) a compact footer in the
// spacer's bottom, under the stepper. The 270 pt column
// cannot take four more large buttons (R10).
ProjectChip(project: project, showingAllHelp: $showingAllHelp)
.padding(8)
}
.frame(width: Theme.Metrics.sidebarWidth)
.background(Theme.panel)
+8 -1
View File
@@ -119,8 +119,11 @@ final class TargetWorkflowViewModel: ObservableObject {
@Published var print: PrintSessionViewModel!
/// Media recipe library needs a complete `self`.
@Published var media: MediaLibraryViewModel!
/// Spot-read console, created last needs `wizard` / `measurement`.
/// Spot-read console needs `wizard` / `measurement`.
@Published var spotRead: SpotReadViewModel!
/// Project file session (issue #149), created last needs `media`
/// for recipe apply and `spotRead` for the live-child check.
@Published var project: ProjectSession!
init(environment: AppEnvironment = .live()) {
self.environment = environment
@@ -148,6 +151,10 @@ final class TargetWorkflowViewModel: ObservableObject {
workflow: self,
environment: environment
)
self.project = ProjectSession(
workflow: self,
environment: environment
)
reloadPresets()
}
@@ -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)
}
}
@@ -22,6 +22,9 @@ struct TestAppEnvironment {
var mediaLibraryURL: URL {
root.appendingPathComponent("media_library.json")
}
var recentProjectsURL: URL {
root.appendingPathComponent("recent_projects.json")
}
/// Creates an isolated environment under `NSTemporaryDirectory()`.
/// Call `cleanup()` when finished.
@@ -65,6 +68,9 @@ struct TestAppEnvironment {
),
mediaStore: MediaLibraryStore(
url: root.appendingPathComponent("media_library.json")
),
recentProjectsStore: RecentProjectsStore(
url: root.appendingPathComponent("recent_projects.json")
)
)
return TestAppEnvironment(root: root, environment: environment)
@@ -0,0 +1,186 @@
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
}
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 = waitFor("gamutLayer-sRGB")
XCTAssertTrue(srgb.exists)
XCTAssertTrue(srgb.isEnabled)
// 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()
let compare = waitFor("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 = waitFor("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,226 @@
import XCTest
/// Milestone 10 UI tests issue #149 project file. Panels are never
/// real: `ICCERY_TEST_PROJECT_OPEN` / `ICCERY_TEST_PROJECT_SAVE`
/// inject fixture paths through `UITestHooks`. Menu commands are driven
/// by their keyboard shortcuts (N) or the sidebar chip so the tests do
/// not depend on menu AX exposure (R19). All queries by identifier.
@MainActor
final class Milestone10ProjectUITests: XCTestCase {
private var app: XCUIApplication!
private var testRoot: URL!
private var binDir: URL!
private var workDir: URL!
override func setUp() async throws {
continueAfterFailure = false
testRoot = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-ui10p-\(UUID().uuidString)")
workDir = testRoot.appendingPathComponent("WorkDir")
binDir = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.appendingPathComponent("Fixtures/bin")
try FileManager.default.createDirectory(
at: testRoot.appendingPathComponent("AppData"),
withIntermediateDirectories: true)
try FileManager.default.createDirectory(
at: workDir, withIntermediateDirectories: true)
app = XCUIApplication()
app.launchEnvironment = [
"ICCERY_UI_TESTING": "1",
"ICCERY_TEST_ROOT": testRoot.path,
"ICCERY_ARGYLL_BINARY_DIR": binDir.path,
"ICCERY_CUPS_BIN_DIR": binDir.path,
]
}
override func tearDown() async throws {
app?.terminate()
app = nil
if let testRoot {
try? FileManager.default.removeItem(at: testRoot)
}
testRoot = nil
workDir = nil
}
private func launchApp() {
app.launch()
if !app.wait(for: .runningForeground, timeout: 10) {
app.activate()
}
}
private func element(_ id: String) -> XCUIElement {
let inApp = app.descendants(matching: .any)[id].firstMatch
if inApp.exists { return inApp }
return app.sheets.firstMatch.descendants(matching: .any)[id].firstMatch
}
private func waitFor(_ id: String, timeout: TimeInterval = 10) -> XCUIElement {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
let el = element(id)
if el.exists { return el }
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
let el = element(id)
XCTAssertTrue(el.exists, "Expected element \(id)")
return el
}
private func noticeText(timeout: TimeInterval = 10) -> String {
let el = waitFor("noticeText", timeout: timeout)
return (el.value as? String) ?? el.label
}
/// Writes a `.icceryproj` fixture under `testRoot` and points the
/// open-picker hook at it.
private func stageProjectFixture(
basename: String = "ui149job",
lastVerification: Bool = false
) throws -> URL {
let verification = lastVerification ? """
"last_verification": {
"date": "2026-09-12T00:00:00Z",
"avg_de00": 0.7,
"max_de00": 1.9,
"status": "excellent",
"profile_filename": "\(basename).icc"
},
""" : ""
let json = """
{
"schema_version": 1,
"name": "UI Fixture Project",
"notes": "",
"basename": "\(basename)",
"cwd": "\(workDir.path)",
"printer_id": null,
"media_recipe_id": null,
"preset_id": null,
"calibration_url": null,
\(verification)
"updated": "2026-09-13T00:00:00Z"
}
"""
let url = testRoot.appendingPathComponent("fixture.icceryproj")
try json.write(to: url, atomically: true, encoding: .utf8)
app.launchEnvironment["ICCERY_TEST_PROJECT_OPEN"] = url.path
return url
}
private func artefact(_ ext: String, stem: String = "ui149job") throws {
try "x".write(
to: workDir.appendingPathComponent("\(stem).\(ext)"),
atomically: true, encoding: .utf8)
}
// MARK: - Tests
func testNewProjectClearsBasenameDoesNotDeleteFixtureTi3() throws {
try artefact("ti3")
_ = try stageProjectFixture()
launchApp()
// Open the fixture via the chip never a real panel.
waitFor("btnProjectOpen").click()
_ = waitFor("projectChipPath")
let basenameField = app.textFields["targetBasename"]
XCTAssertTrue(basenameField.waitForExistence(timeout: 10))
XCTAssertEqual(basenameField.value as? String, "ui149job")
// N fires the File-menu New command even when the menu is not
// in the AX tree. Mock CUPS may have enumerated a queue that the
// fixture does not record, making the session dirty in that
// case the dirty alert gates New first.
app.typeKey("n", modifierFlags: .command)
let deadline = Date().addingTimeInterval(10)
var confirmed = false
while Date() < deadline {
// Dirty sessions show the dirty alert first; discarding it
// runs the New reset directly (no second confirm).
if element("btnProjectDirtyDiscard").exists {
element("btnProjectDirtyDiscard").click()
confirmed = true
break
}
if element("btnProjectNewConfirm").exists {
element("btnProjectNewConfirm").click()
confirmed = true
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
XCTAssertTrue(confirmed, "expected the New or dirty alert")
// Basename cleared (legal "no target" state not a
// placeholder), fixture `.ti3` untouched on disk.
let cleared = basenameField.value as? String ?? ""
XCTAssertEqual(cleared, "")
XCTAssertTrue(FileManager.default.fileExists(
atPath: workDir.appendingPathComponent("ui149job.ti3").path))
XCTAssertTrue(element("projectChip").exists)
XCTAssertEqual(
element("projectChipName").value as? String, "No project")
}
func testOpenProjectDiskWinsOverJsonStage() throws {
// JSON claims a finished profile; disk stops at .ti2 (R18).
try artefact("ti2")
_ = try stageProjectFixture(lastVerification: true)
launchApp()
waitFor("btnProjectOpen").click()
let notice = noticeText()
XCTAssertTrue(
notice.contains("artefacts on disk stop at .ti2"),
"got: \(notice)")
XCTAssertTrue(element("projectChipStale").exists)
XCTAssertEqual(
element("projectChipPath").value as? String, workDir.lastPathComponent)
}
func testSaveDisabledWithoutBasename() throws {
launchApp()
_ = waitFor("projectChip")
XCTAssertEqual(element("projectChipName").value as? String, "No project")
// Chip Save is hidden while unbound; Save As lives in the menu.
XCTAssertFalse(element("btnProjectSave").exists)
// When the File menu is in the AX tree, Save must be disabled.
let fileMenu = app.menuBarItems["File"]
if fileMenu.waitForExistence(timeout: 3) {
fileMenu.click()
let save = app.menuItems["menuProjectSave"].firstMatch
if save.waitForExistence(timeout: 3) {
XCTAssertFalse(save.isEnabled)
}
app.typeKey(XCUIKeyboardKey.escape, modifierFlags: [])
}
}
func testCalBasenameRefused() throws {
// A fixture whose stem is CAL_-prefixed binds a live CAL_
// basename; the persisted original is empty, so Save must
// refuse and never write CAL_ back (R11).
let url = try stageProjectFixture(basename: "CAL_ui149")
let before = try Data(contentsOf: url)
launchApp()
waitFor("btnProjectOpen").click()
let save = waitFor("btnProjectSave")
XCTAssertTrue(save.isEnabled)
save.click()
XCTAssertTrue(
noticeText().contains("Finish or exit calibration"),
"got: \(noticeText())")
XCTAssertEqual(try Data(contentsOf: url), before)
}
}
+6
View File
@@ -46,3 +46,9 @@ Never spawn with `cwd: ""`. Initialize from `get_default_working_dir` (Documents
## No `"test_target"` fallbacks (#60)
A rewrite must not invent default basenames. Later stages stay inert until `wizardState.basename` is set from a real artefact.
## Project file (#149)
A `.icceryproj` is a JSON **index** (`schema_version` 1) over `basename` + `cwd` + optional `media_recipe_id`/`preset_id`/`calibration_url`/`printer_*` + a `last_verification` ΔE₀₀ snapshot. It is never a second source of truth: opening one writes `WizardState` and re-runs the same artefact probe window focus uses — if JSON claims Stage 5 but disk stops at `.ti2`, the stepper follows the disk and the sidebar chip shows `projectChipStale` with an info banner.
`wizard_state.json` remains the crash-resume file; there is no auto-save. Recents live in `appDataDir/recent_projects.json` (cap 20, bookmark + path, corrupt → `[]` + keep bytes). `Save Report…` writes `{basename}-report.md` in cwd atomically. A live `CAL_` basename is never persisted — Save resolves the persisted `calibrationOriginalBasename` or refuses with a banner.
+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). |
| #212 Node test crash | Polyfill + dynamic import + `typeof window` guard. |
| #225 Monterey WebGL | Lazy ensure, feature-detect, pause rAF, context-lost, low-power flags. |
| #147 compare + inspect | macOS native: layer toggles (`gamutLayer-*`), compare slot (one extra profile or `.gam`), click/typed-Lab containment, TIFF pixel sample. |
---
## 13. macOS rewrite notes (#28, #147)
The native viewer (`Sources/ICCery/GamutView.swift` + `GamutViewModel`) keeps
the v1 contract points that matter — `(a*, L*, b*)` axes, camera home
(180,120,180) lookAt (0,50,0), R resets via a local `NSEvent` monitor, native
faces only — and adds the compare/inspect layer model:
- **Layers.** `NamedGamut` (reference / profileA / profileB). sRGB cannot be
removed, only hidden. The compare slot holds exactly one profile; picking a
third replaces it and posts `gamutNoticeText`.
- **Toggles hide, not unload.** `visibleIDs` maps to `SCNNode.isHidden`; the
scene is built once and a checkbox never resets the camera.
- **Containment.** `GamutGeometry.containment` ray-casts the face table
(`GamutVertex.position` space) with off-axis retries on edge hits; no faces
→ `.unknown`. `GamutGeometry.volume` sums signed tetrahedra from the vertex
centroid (Lab-cubic); the `vol % of sRGB` clause only prints when both
volumes are finite and > 0.
- **Inspect.** Click a mesh (hit-test in the `SCNView` coordinator on
mouse-up, never a ZStack tap gesture), type Lab, or sample a TIFF pixel.
Per-layer in/out/? + swatch; `gamutInspectApprox` marks samples that went
through `ApproximateLab` (fixed-matrix sRGB→Lab D50, **not** ColorSync, no
CMM).
- **Fallback.** No GPU → `gamutViewerUnavailable` text replaces the scene;
layer toggles stay visible but disabled; the representable is never
respawned in a loop.
- **iccgamut** still runs only as the bundled sidecar, `-v -d {density}`
(density 10 = surface density, not a directory). Failure is an in-sheet
info notice — sRGB + profile A stay loaded (#24).
File diff suppressed because one or more lines are too long