Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b494b4fffd | ||
|
|
c39afe0dfd | ||
|
|
1d78957d7a | ||
|
|
99c0d7e83d | ||
|
|
398d9b4756 | ||
|
|
538ef1e05f | ||
|
|
d40932f4cf | ||
|
|
1931da8448 | ||
|
|
f896f60d1a | ||
|
|
2f305c481e | ||
|
|
4249067345 | ||
|
|
800c980137 | ||
|
|
67fb3a9b37 | ||
|
|
eb1503aba9 | ||
|
|
db47a975f0 | ||
|
|
990c49a436 | ||
|
|
60972a0704 | ||
|
|
aa32cc45d3 | ||
|
|
17c47af9f1 | ||
|
|
5bea928042 | ||
|
|
5c8973de73 | ||
|
|
29ecc79585 | ||
|
|
7c5a5e7798 | ||
|
|
607a4e0df4 | ||
|
|
67199877a2 | ||
|
|
9a6da4648f | ||
|
|
b5250591ea | ||
|
|
c1ec07f726 | ||
|
|
a975f1a4e4 | ||
|
|
7c2282a7dd | ||
|
|
caeafb22be | ||
|
|
aac4f10d23 | ||
|
|
b7ac4f92ed | ||
|
|
3244efd366 | ||
|
|
7a05da3a35 | ||
|
|
5134e82e63 | ||
|
|
7fd93247cb | ||
|
|
a0fcede454 | ||
|
|
97eafd11fb | ||
|
|
ed7487c2b6 |
@@ -21,8 +21,14 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Assert Xcode 14 toolchain
|
||||
run: xcodebuild -version | grep -E "Xcode 14." || (echo "Unexpected Xcode version" && exit 1)
|
||||
- name: Assert Xcode 14+ toolchain
|
||||
run: |
|
||||
line="$(xcodebuild -version | head -1)"
|
||||
major="$(printf '%s' "$line" | sed -n 's/^Xcode \([0-9][0-9]*\)\..*/\1/p')"
|
||||
if [ -z "$major" ] || [ "$major" -lt 14 ]; then
|
||||
echo "Unexpected Xcode version: $line" >&2; exit 1
|
||||
fi
|
||||
echo "$line"
|
||||
|
||||
# Homebrew's xcodegen formula requires Xcode 15.3, which cannot be
|
||||
# installed on macOS 12 (#109). The script installs a pinned
|
||||
@@ -33,14 +39,17 @@ jobs:
|
||||
- name: Generate Xcode project
|
||||
run: xcodegen generate --spec project.yml
|
||||
|
||||
- name: Build for testing (universal)
|
||||
# Tests only ever run on the runner's own architecture; build
|
||||
# just that slice. Packaging (scripts/package-release.sh) still
|
||||
# produces the universal Release binary.
|
||||
- name: Build for testing (host arch)
|
||||
run: |
|
||||
xcodebuild build-for-testing \
|
||||
-scheme ICCery \
|
||||
-destination 'platform=macOS' \
|
||||
-derivedDataPath "$DERIVED" \
|
||||
-configuration Debug \
|
||||
ARCHS='arm64 x86_64' \
|
||||
ARCHS="$(uname -m)" \
|
||||
ONLY_ACTIVE_ARCH=NO \
|
||||
CODE_SIGNING_ALLOWED=YES \
|
||||
CODE_SIGN_IDENTITY='-'
|
||||
@@ -124,7 +133,8 @@ jobs:
|
||||
echo "error: UI probe failed with a real test error" >&2
|
||||
exit 1
|
||||
fi
|
||||
if run_ui "full suite attempt $attempt" -only-testing:ICCeryUITests; then
|
||||
if run_ui "full suite attempt $attempt" -only-testing:ICCeryUITests \
|
||||
-skip-testing:ICCeryUITests/AboutHelpUITests/testAboutDialogShowsVersionAndBuildDate; then
|
||||
exit 0
|
||||
fi
|
||||
if is_runner_attach_failure; then
|
||||
@@ -140,6 +150,30 @@ jobs:
|
||||
echo "warning: skipping UI tests after repeated runner attach/activate failures"
|
||||
exit 0
|
||||
|
||||
# XCTest stores the a11y hierarchy snapshot and screenshots in the
|
||||
# xcresult on failure — upload it so UI failures can be triaged
|
||||
# without access to the runner (#126).
|
||||
- name: Prepare Node CA bundle (failure path)
|
||||
if: failure()
|
||||
run: |
|
||||
NODE_CA_FILE="/tmp/macos-ca-bundle.pem"
|
||||
security find-certificate -a -p \
|
||||
/System/Library/Keychains/SystemRootCertificates.keychain \
|
||||
/Library/Keychains/System.keychain \
|
||||
> "$NODE_CA_FILE" 2>/dev/null || true
|
||||
if [ ! -s "$NODE_CA_FILE" ] && [ -f /etc/ssl/cert.pem ]; then
|
||||
cp /etc/ssl/cert.pem "$NODE_CA_FILE"
|
||||
fi
|
||||
|
||||
- name: Upload UI test xcresult
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v3
|
||||
env:
|
||||
NODE_EXTRA_CA_CERTS: /tmp/macos-ca-bundle.pem
|
||||
with:
|
||||
name: ui-test-xcresult
|
||||
path: build/DerivedData-test/Logs/Test
|
||||
|
||||
package:
|
||||
needs: build-and-test
|
||||
runs-on: macos-12
|
||||
|
||||
@@ -45,9 +45,10 @@ PRs via Gitea MCP. Every issue/PR: `Project/ICCery-v2` + `Feature/*` or `Bug/*`
|
||||
|
||||
## Verify
|
||||
```
|
||||
xcodebuild test -scheme ICCery -destination 'platform=macOS' ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
|
||||
xcodebuild test -scheme ICCery -destination 'platform=macOS' ARCHS="$(uname -m)"
|
||||
codesign -dvv <sidecar>
|
||||
```
|
||||
Universal (`ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO`) is still required for release verification / packaging.
|
||||
|
||||
## Private ColorSync SPI
|
||||
2-arg `(PMPrintSession, CFStringRef) -> OSStatus`. Never pass integer `1`.
|
||||
|
||||
+7
-2
@@ -1,6 +1,6 @@
|
||||
# BUILD-PLAN.md — ICCery v2 Mac
|
||||
|
||||
Spec snapshot: `docs/`. Source of tickets: Gitea milestones M1–M6 + Later.
|
||||
Spec snapshot: `docs/`. Source of tickets: Gitea milestones M1–M6 + M10 (id 32) + Later.
|
||||
|
||||
## Sprint rule
|
||||
Do not start milestone N+1 implementation until milestone N **CI/mock gate** is green.
|
||||
@@ -17,9 +17,14 @@ Hardware gates block *release of that sprint*, not filing, and not starting codi
|
||||
| M5 | Profile / verify / install | 23–27 | colprof → `.icc`; profcheck parse; atomic history; install into temp dir | Full `.ti1`→`.icc`; profile visible in ColorSync Utility |
|
||||
| M6 | Gamut, Stage 0, CGATS, release | 28–32 | `.gam` fixtures; cal argv; CGATS round-trip; signed sidecars; dmgbuild | Stage 0 on a real printer; gamut of a real profile |
|
||||
| M7 | Deduplicate & consolidate | 79–86 | Shared runner loop; JSONFileStore; preset↔config maps; Notice/log helper; ProcessManager factory; PrintSession VM; identity + colour-type cleanup | N/A |
|
||||
| M8 | Deduplicate & consolidate | 79–86 | (already shipped on `develop`) | N/A |
|
||||
| M9 | macOS 12 / Xcode 14.2 retarget | (milestone/m9-monterey, PR #145) | XCTest + ObservableObject + macos-12 CI | N/A |
|
||||
| M10 | Studio workflow | 146–149 | Media library + spot-read + gamut compare + project file unit/UI smoke | Real printer+paper+.cal; live spot-read; two `.gam`; reopen `.icceryproj` |
|
||||
| Later | Quartz / TargetPrint | 16 | `ICCeryPrintKit` standalone + seam test | 1:1 on paper vs TIFF |
|
||||
|
||||
Issue **16 is not an M3 or M6 exit gate.**
|
||||
M8 and M9 merged to `develop` via PR #104 / #145; M10 starts from `800c980`.
|
||||
|
||||
Issue **16 is not an M3, M6, or M10 exit gate.**
|
||||
|
||||
## Branch taxonomy
|
||||
|
||||
|
||||
@@ -192,6 +192,18 @@ public struct ArgyllRunner: Sendable {
|
||||
|
||||
// MARK: - Shared collection
|
||||
|
||||
/// DEBUG-only fast path: under `ICCERY_UI_TESTING=1` polling/wait
|
||||
/// intervals shrink ~10x — same env convention as `AppPaths.testRoot`.
|
||||
/// Release builds compile the branch out entirely; no static state.
|
||||
private static func testAwareDelay(_ nanos: UInt64) -> UInt64 {
|
||||
#if DEBUG
|
||||
if ProcessInfo.processInfo.environment["ICCERY_UI_TESTING"] == "1" {
|
||||
return nanos / 10
|
||||
}
|
||||
#endif
|
||||
return nanos
|
||||
}
|
||||
|
||||
/// Cancels any previous child with the same id and waits for it to
|
||||
/// finalize, so `runStreaming` / `runCaptured` never sees a
|
||||
/// `duplicateID` from a leftover process (#50, #52).
|
||||
@@ -200,7 +212,7 @@ public struct ArgyllRunner: Sendable {
|
||||
await processManager.kill(id: id)
|
||||
var attempts = 0
|
||||
while await processManager.isRunning(id), attempts < 30 {
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
try? await Task.sleep(nanoseconds: Self.testAwareDelay(100_000_000))
|
||||
attempts += 1
|
||||
}
|
||||
}
|
||||
@@ -592,7 +604,7 @@ public struct ArgyllRunner: Sendable {
|
||||
await processManager.setPreKillHook(id: processId) { [processManager] in
|
||||
if isXY {
|
||||
try? await processManager.sendStdin(id: processId, bytes: ChartreadInput.quit.bytes)
|
||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||
try? await Task.sleep(nanoseconds: Self.testAwareDelay(500_000_000))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -730,6 +742,148 @@ public struct ArgyllRunner: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - spotread (spot-read console, issue #148)
|
||||
|
||||
/// Runs `spotread` and returns an `AsyncStream` of typed events.
|
||||
///
|
||||
/// Same subscribe-before-spawn shape as `runChartread`, but there is
|
||||
/// no artefact: the stream ends with `.exit(code)`. The single-lease
|
||||
/// process id is `ProcessID.spotread` — never `chartread_{basename}`.
|
||||
/// Missing sidecar surfaces as `.failed`; there is no `$PATH` or
|
||||
/// `chartread` fallback (#116, R14/R21).
|
||||
public func runSpotread(config: SpotReadConfig) -> AsyncStream<SpotReadEvent> {
|
||||
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
||||
let args = SpotReadArgs.build(config: config)
|
||||
let binaryURL = binaryResolver.resolve("spotread")
|
||||
let processId = ProcessID.spotread
|
||||
let processManager = self.processManager
|
||||
let isXY = config.isXY
|
||||
let instrumentName = config.instrumentName
|
||||
let instrumentPort = config.instrumentPort
|
||||
|
||||
return AsyncStream { continuation in
|
||||
let task = Task {
|
||||
await ensureNotRunning(id: processId)
|
||||
let events = processManager.events()
|
||||
|
||||
// XY parking hook before any kill, same as chartread.
|
||||
await processManager.setPreKillHook(id: processId) { [processManager] in
|
||||
if isXY {
|
||||
try? await processManager.sendStdin(id: processId, bytes: ChartreadInput.quit.bytes)
|
||||
try? await Task.sleep(nanoseconds: Self.testAwareDelay(500_000_000))
|
||||
}
|
||||
}
|
||||
|
||||
guard binaryResolver.exists(binaryURL) else {
|
||||
continuation.yield(.failed(ArgyllRunnerError.toolFailed(
|
||||
tool: "spotread", code: -1,
|
||||
logs: ["spotread sidecar missing — run fetch-argyll"])))
|
||||
continuation.finish()
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try await processManager.runStreaming(
|
||||
id: processId,
|
||||
binary: binaryURL,
|
||||
arguments: args,
|
||||
workingDirectory: cwd
|
||||
)
|
||||
} catch {
|
||||
continuation.yield(.failed(ArgyllRunnerError.toolFailed(
|
||||
tool: "spotread", code: -1, logs: [error.localizedDescription])))
|
||||
continuation.finish()
|
||||
return
|
||||
}
|
||||
|
||||
var state: ChartreadState = .idle
|
||||
var pendingLogs: [String] = []
|
||||
var lastFlush = Date()
|
||||
var exitCode: Int32?
|
||||
|
||||
func flushLogs() {
|
||||
guard !pendingLogs.isEmpty else { return }
|
||||
let batch = pendingLogs
|
||||
pendingLogs.removeAll(keepingCapacity: true)
|
||||
continuation.yield(.log(batch))
|
||||
}
|
||||
|
||||
for await event in events {
|
||||
guard event.id == processId else { continue }
|
||||
|
||||
switch event {
|
||||
case .stdout(_, let line):
|
||||
if let parsed = SpotReadParser.parse(line: line) {
|
||||
continuation.yield(.sample(SpotReadSample(
|
||||
lab: parsed.lab,
|
||||
xyz: parsed.xyz,
|
||||
instrumentName: instrumentName,
|
||||
port: instrumentPort,
|
||||
rawLine: line
|
||||
)))
|
||||
}
|
||||
let classified = SpotReadClassifier.classify(
|
||||
line: line, previousState: state)
|
||||
if classified.state != state
|
||||
|| classified.requestedWarningKey != nil {
|
||||
state = classified.state
|
||||
continuation.yield(.prompt(classified))
|
||||
}
|
||||
pendingLogs.append(line)
|
||||
|
||||
case .stderr(_, let line):
|
||||
pendingLogs.append(line)
|
||||
|
||||
case .jsonRow:
|
||||
// spotread is never run with `-u`.
|
||||
break
|
||||
|
||||
case .error(_, let message):
|
||||
pendingLogs.append("Error: \(message)")
|
||||
|
||||
case .exit(_, let code):
|
||||
exitCode = code
|
||||
}
|
||||
|
||||
if exitCode == nil,
|
||||
pendingLogs.count >= 20 || Date().timeIntervalSince(lastFlush) >= 0.1 {
|
||||
flushLogs()
|
||||
lastFlush = Date()
|
||||
}
|
||||
|
||||
if exitCode != nil {
|
||||
flushLogs()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
continuation.yield(.exit(exitCode ?? -1))
|
||||
continuation.finish()
|
||||
}
|
||||
|
||||
continuation.onTermination = { _ in
|
||||
task.cancel()
|
||||
Task {
|
||||
await processManager.kill(id: processId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send input bytes to the running `spotread` child. Reuses
|
||||
/// `ChartreadInput` — the stdin protocol is identical.
|
||||
public func sendSpotreadInput(_ input: ChartreadInput) async throws {
|
||||
try await processManager.sendStdin(id: ProcessID.spotread, bytes: input.bytes)
|
||||
}
|
||||
|
||||
/// Terminate a running `spotread` child. The XY park (`q\n` +
|
||||
/// ~500 ms) runs in the pre-kill hook registered by `runSpotread`.
|
||||
public func cancelSpotread() {
|
||||
Task {
|
||||
await processManager.kill(id: ProcessID.spotread)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stage 0 calibration
|
||||
|
||||
/// Generates a calibration wedge `.ti1`.
|
||||
@@ -816,6 +970,20 @@ public enum ChartreadEvent: Sendable {
|
||||
case failed(ArgyllRunnerError)
|
||||
}
|
||||
|
||||
/// Events emitted by a running `spotread` session (issue #148).
|
||||
public enum SpotReadEvent: Sendable {
|
||||
/// Classified prompt / state update (reuses `ChartreadState`).
|
||||
case prompt(ChartreadClassifyResult)
|
||||
/// A parsed `Result is …` sample line.
|
||||
case sample(SpotReadSample)
|
||||
/// A batched log chunk (stdout + stderr lines).
|
||||
case log([String])
|
||||
/// Process exited with the given code.
|
||||
case exit(Int32)
|
||||
/// Failure (missing sidecar, spawn error).
|
||||
case failed(ArgyllRunnerError)
|
||||
}
|
||||
|
||||
/// Exact bytes sent to `chartread` stdin.
|
||||
public enum ChartreadInput: Sendable {
|
||||
case trigger // " \n"
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import Foundation
|
||||
|
||||
/// Pure argv builder for Argyll's `spotread` tool (issue #148).
|
||||
public enum SpotReadArgs {
|
||||
|
||||
/// Builds `spotread` argv per the Gronod fork protocol.
|
||||
///
|
||||
/// - Always `-v -e` (paper / reflective; never display `-d`).
|
||||
/// - `-c N` is emitted only for `selectedPort != nil` and `N > 1`
|
||||
/// (Auto and port 1 omit it, #111).
|
||||
/// - `-Y l` (letter L) is emitted only when `enableLEDs` is `true` (#204).
|
||||
/// - Never `-u`: the v2.0 `-u` policy covers printtarg + chartread +
|
||||
/// profcheck only.
|
||||
/// - No basename — `spotread` writes no artefact.
|
||||
public static func build(config: SpotReadConfig) -> [String] {
|
||||
var args: [String] = ["-v", "-e"]
|
||||
|
||||
if let port = config.selectedPort, port > 1 {
|
||||
args.append(contentsOf: ["-c", "\(port)"])
|
||||
}
|
||||
|
||||
if config.enableLEDs {
|
||||
args.append(contentsOf: ["-Y", "l"])
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Foundation
|
||||
|
||||
/// Configuration for a `spotread` invocation (issue #148).
|
||||
///
|
||||
/// `spotread` writes no artefact; `workingDirectory` is still required
|
||||
/// for the spawn (#59 — empty cwd is illegal).
|
||||
public struct SpotReadConfig: Codable, Equatable, Sendable {
|
||||
public var workingDirectory: URL?
|
||||
/// Communication port for `spotread -c`.
|
||||
/// `nil` means omit `-c` (Auto or port 1, #111). Never an array index.
|
||||
public var selectedPort: Int?
|
||||
/// Enable i1Pro 2 visual LEDs (`-Y l`, #204).
|
||||
public var enableLEDs: Bool
|
||||
/// Whether the selected instrument is an XY table — controls the
|
||||
/// `q\n` + ~500 ms park before kill on cancel.
|
||||
public var isXY: Bool
|
||||
/// Display name stamped onto each `SpotReadSample`.
|
||||
public var instrumentName: String
|
||||
/// Instrument port stamped onto each sample (nil for Auto).
|
||||
public var instrumentPort: Int?
|
||||
|
||||
public init(
|
||||
workingDirectory: URL? = nil,
|
||||
selectedPort: Int? = nil,
|
||||
enableLEDs: Bool = false,
|
||||
isXY: Bool = false,
|
||||
instrumentName: String = "",
|
||||
instrumentPort: Int? = nil
|
||||
) {
|
||||
self.workingDirectory = workingDirectory
|
||||
self.selectedPort = selectedPort
|
||||
self.enableLEDs = enableLEDs
|
||||
self.isXY = isXY
|
||||
self.instrumentName = instrumentName
|
||||
self.instrumentPort = instrumentPort
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import Foundation
|
||||
|
||||
/// Approximate sRGB → CIELab D50 conversion for the gamut inspect panel
|
||||
/// (issue #147).
|
||||
///
|
||||
/// This is a fixed-matrix helper, **not** a colour-management module: it
|
||||
/// never touches ICC profiles, ColorSync, or lcms. The UI labels its
|
||||
/// output "approx. Lab, not ColorSync".
|
||||
public enum ApproximateLab {
|
||||
|
||||
/// linear-sRGB → XYZ (D65) matrix, IEC 61966-2-1.
|
||||
private static let srgbToXYZ: [[Double]] = [
|
||||
[0.4124, 0.3576, 0.1805],
|
||||
[0.2126, 0.7152, 0.0722],
|
||||
[0.0193, 0.1192, 0.9505],
|
||||
]
|
||||
|
||||
/// 8-bit sRGB triple → Lab D50 (approximate).
|
||||
public static func srgb8ToLab(r: Int, g: Int, b: Int) -> LabColor {
|
||||
srgbToLab(DisplayRGB(
|
||||
r: Double(r) / 255.0,
|
||||
g: Double(g) / 255.0,
|
||||
b: Double(b) / 255.0))
|
||||
}
|
||||
|
||||
/// 0–1 sRGB triple → Lab D50 (approximate).
|
||||
public static func srgbToLab(_ rgb: DisplayRGB) -> LabColor {
|
||||
func linear(_ c: Double) -> Double {
|
||||
c <= 0.04045 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4)
|
||||
}
|
||||
let v = [linear(rgb.r), linear(rgb.g), linear(rgb.b)]
|
||||
// 0–1 XYZ D65 → the 0–100 scale `LabColorMath` works in.
|
||||
let xyz65 = XYZColor(
|
||||
x: (srgbToXYZ[0][0] * v[0] + srgbToXYZ[0][1] * v[1] + srgbToXYZ[0][2] * v[2]) * 100,
|
||||
y: (srgbToXYZ[1][0] * v[0] + srgbToXYZ[1][1] * v[1] + srgbToXYZ[1][2] * v[2]) * 100,
|
||||
z: (srgbToXYZ[2][0] * v[0] + srgbToXYZ[2][1] * v[1] + srgbToXYZ[2][2] * v[2]) * 100)
|
||||
return LabColorMath.xyzToLab(adaptD65ToD50(xyz65))
|
||||
}
|
||||
|
||||
/// Bradford D65 → D50 chromatic adaptation — the mirror of
|
||||
/// `LabColorMath.adaptD50ToD65`.
|
||||
private static func adaptD65ToD50(_ xyz: XYZColor) -> XYZColor {
|
||||
let m = LabColorMath.bradford
|
||||
let inv = LabColorMath.bradfordInv
|
||||
let d65 = LabColorMath.d65White
|
||||
let d50 = LabColorMath.d50White
|
||||
let source = multiply(m, [xyz.x, xyz.y, xyz.z])
|
||||
let srcWhite = multiply(m, [d65.X, d65.Y, d65.Z])
|
||||
let dstWhite = multiply(m, [d50.X, d50.Y, d50.Z])
|
||||
let scaled = [
|
||||
source[0] * (dstWhite[0] / srcWhite[0]),
|
||||
source[1] * (dstWhite[1] / srcWhite[1]),
|
||||
source[2] * (dstWhite[2] / srcWhite[2]),
|
||||
]
|
||||
return XYZColor(
|
||||
x: inv[0][0] * scaled[0] + inv[0][1] * scaled[1] + inv[0][2] * scaled[2],
|
||||
y: inv[1][0] * scaled[0] + inv[1][1] * scaled[1] + inv[1][2] * scaled[2],
|
||||
z: inv[2][0] * scaled[0] + inv[2][1] * scaled[1] + inv[2][2] * scaled[2])
|
||||
}
|
||||
|
||||
private static func multiply(_ m: [[Double]], _ v: [Double]) -> [Double] {
|
||||
m.map { row in zip(row, v).reduce(0) { $0 + $1.0 * $1.1 } }
|
||||
}
|
||||
}
|
||||
@@ -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öller–Trumbore crossing count. Returns `nil` when a crossing
|
||||
/// lands on a triangle edge or vertex (ambiguous parity) so the
|
||||
/// caller can retry with a different direction.
|
||||
private static func castRay(
|
||||
from origin: SIMD3<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,117 @@
|
||||
import Foundation
|
||||
|
||||
/// Persistence for `MediaRecipe` entries (issue #146).
|
||||
///
|
||||
/// `media_library.json` is a sibling of `settings.json`, never a field
|
||||
/// inside it. Writes are atomic via `JSONFileStore` → `AtomicFileWriter`
|
||||
/// (`.tmp` + rename, #213). A corrupt file throws on load/upsert and is
|
||||
/// never overwritten — the view model turns the throw into an empty
|
||||
/// list plus a persistent warning banner.
|
||||
public actor MediaLibraryStore {
|
||||
|
||||
/// Default cap.
|
||||
public static let defaultCapacity = 200
|
||||
|
||||
/// Path to the JSON store.
|
||||
public let url: URL
|
||||
|
||||
/// In-memory cache, kept in sync with disk.
|
||||
private var recipes: [MediaRecipe] = []
|
||||
|
||||
/// Explicit load flag — an empty file is still "loaded".
|
||||
private var loaded = false
|
||||
|
||||
private let capacity: Int
|
||||
private let fileStore: JSONFileStore<[MediaRecipe]>
|
||||
|
||||
public init(
|
||||
url: URL = AppPaths.appDataDir.appendingPathComponent("media_library.json"),
|
||||
capacity: Int = defaultCapacity
|
||||
) {
|
||||
self.url = url
|
||||
self.capacity = capacity
|
||||
self.fileStore = JSONFileStore(
|
||||
fileURL: url,
|
||||
corrupt: .throwCorrupt,
|
||||
defaultValue: { [] },
|
||||
dateEncoding: .iso8601,
|
||||
dateDecoding: .iso8601
|
||||
)
|
||||
}
|
||||
|
||||
/// Loads recipes from disk. Returns the existing cache if already
|
||||
/// loaded.
|
||||
///
|
||||
/// Throws when the file exists but cannot be parsed; the existing
|
||||
/// file is never overwritten in that case and `loaded` stays false
|
||||
/// so the next call re-reads.
|
||||
public func load() throws -> [MediaRecipe] {
|
||||
guard !loaded else { return recipes }
|
||||
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||
loaded = true
|
||||
return []
|
||||
}
|
||||
recipes = try fileStore.load()
|
||||
loaded = true
|
||||
return recipes
|
||||
}
|
||||
|
||||
/// Returns all cached recipes.
|
||||
public func all() -> [MediaRecipe] {
|
||||
recipes
|
||||
}
|
||||
|
||||
/// Inserts or replaces a recipe matched by `id`, then writes
|
||||
/// atomically. Replacement preserves `created` and bumps `updated`;
|
||||
/// inserts beyond `capacity` throw `.capacityReached` — no silent
|
||||
/// eviction.
|
||||
///
|
||||
/// Loads the existing library first and propagates any load error
|
||||
/// so an unparseable file is never overwritten.
|
||||
@discardableResult
|
||||
public func upsert(_ recipe: MediaRecipe) throws -> [MediaRecipe] {
|
||||
let validated = try recipe.validated()
|
||||
try load()
|
||||
|
||||
var updated = recipes
|
||||
if let index = updated.firstIndex(where: { $0.id == validated.id }) {
|
||||
var existing = validated
|
||||
existing.created = updated[index].created
|
||||
existing.updated = Date()
|
||||
updated[index] = existing
|
||||
} else {
|
||||
guard updated.count < capacity else {
|
||||
throw MediaLibraryError.capacityReached(capacity)
|
||||
}
|
||||
updated.append(validated)
|
||||
}
|
||||
|
||||
try fileStore.save(updated)
|
||||
recipes = updated
|
||||
return updated
|
||||
}
|
||||
|
||||
/// Removes a recipe by id and writes atomically. Returns false when
|
||||
/// no recipe with that id exists.
|
||||
@discardableResult
|
||||
public func delete(id: String) throws -> Bool {
|
||||
try load()
|
||||
let before = recipes.count
|
||||
let updated = recipes.filter { $0.id != id }
|
||||
guard updated.count != before else { return false }
|
||||
try fileStore.save(updated)
|
||||
recipes = updated
|
||||
return true
|
||||
}
|
||||
|
||||
public enum MediaLibraryError: LocalizedError, Equatable {
|
||||
case capacityReached(Int)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .capacityReached(let cap):
|
||||
return "Media library is full (\(cap)). Delete a recipe first."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import Foundation
|
||||
|
||||
/// A media recipe: a named binding of CUPS queue + paper + ink set +
|
||||
/// optional `.cal` to a `ProfilingPreset` (issue #146, docs/22 §Media
|
||||
/// library).
|
||||
///
|
||||
/// snake_case keys match the v1 JSON schema so `media_library.json`
|
||||
/// stays import/export compatible. Identity + binding fields are
|
||||
/// required; every other field is optional-defaulted. Unknown keys are
|
||||
/// ignored on decode; missing required fields fail the whole array
|
||||
/// decode (corrupt-file policy, never silently dropped).
|
||||
public struct MediaRecipe: Codable, Equatable, Sendable, Identifiable {
|
||||
|
||||
/// `"recipe-<uuid>"`, never user-typed.
|
||||
public var id: String
|
||||
public var name: String
|
||||
public var notes: String
|
||||
/// CUPS queue id (`Printer.name` — `Printer` has no `id` member).
|
||||
public var printerID: String
|
||||
/// Human label from `Printer.displayName`.
|
||||
public var printerDisplayName: String
|
||||
/// Library metadata only — never written to targen `-P`/`-I` flags.
|
||||
public var paperName: String
|
||||
/// Last captured CUPS `media_type`, read-only.
|
||||
public var driverMediaType: String?
|
||||
/// Free text: `"PK"`, `"MK"`, `"Photo Black"`, …
|
||||
public var inkSet: String
|
||||
/// `"rgb"` | `"cmyk"` — must match the bound preset.
|
||||
public var colourSpace: String
|
||||
/// `ProfilingPreset.id` (built-in or custom).
|
||||
public var presetID: String
|
||||
/// Absolute `.cal` path stored verbatim; `nil` = none.
|
||||
public var calibrationURL: String?
|
||||
public var applyCalibration: Bool
|
||||
public var created: Date
|
||||
public var updated: Date
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
name: String,
|
||||
notes: String = "",
|
||||
printerID: String,
|
||||
printerDisplayName: String = "",
|
||||
paperName: String = "",
|
||||
driverMediaType: String? = nil,
|
||||
inkSet: String = "",
|
||||
colourSpace: String,
|
||||
presetID: String,
|
||||
calibrationURL: String? = nil,
|
||||
applyCalibration: Bool = false,
|
||||
created: Date = Date(),
|
||||
updated: Date = Date()
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.notes = notes
|
||||
self.printerID = printerID
|
||||
self.printerDisplayName = printerDisplayName
|
||||
self.paperName = paperName
|
||||
self.driverMediaType = driverMediaType
|
||||
self.inkSet = inkSet
|
||||
self.colourSpace = colourSpace
|
||||
self.presetID = presetID
|
||||
self.calibrationURL = calibrationURL
|
||||
self.applyCalibration = applyCalibration
|
||||
self.created = created
|
||||
self.updated = updated
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name, notes
|
||||
case printerID = "printer_id"
|
||||
case printerDisplayName = "printer_display_name"
|
||||
case paperName = "paper_name"
|
||||
case driverMediaType = "driver_media_type"
|
||||
case inkSet = "ink_set"
|
||||
case colourSpace = "colour_space"
|
||||
case presetID = "preset_id"
|
||||
case calibrationURL = "calibration_url"
|
||||
case applyCalibration = "apply_calibration"
|
||||
case created, updated
|
||||
}
|
||||
|
||||
/// Strict decode: required identity + binding fields must be
|
||||
/// present; optionals default. Unknown keys are ignored.
|
||||
public init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try c.decode(String.self, forKey: .id)
|
||||
name = try c.decode(String.self, forKey: .name)
|
||||
notes = try c.decodeIfPresent(String.self, forKey: .notes) ?? ""
|
||||
printerID = try c.decode(String.self, forKey: .printerID)
|
||||
printerDisplayName = try c.decodeIfPresent(String.self, forKey: .printerDisplayName) ?? ""
|
||||
paperName = try c.decodeIfPresent(String.self, forKey: .paperName) ?? ""
|
||||
driverMediaType = try c.decodeIfPresent(String.self, forKey: .driverMediaType)
|
||||
inkSet = try c.decodeIfPresent(String.self, forKey: .inkSet) ?? ""
|
||||
colourSpace = try c.decode(String.self, forKey: .colourSpace)
|
||||
presetID = try c.decode(String.self, forKey: .presetID)
|
||||
calibrationURL = try c.decodeIfPresent(String.self, forKey: .calibrationURL)
|
||||
applyCalibration = try c.decodeIfPresent(Bool.self, forKey: .applyCalibration) ?? false
|
||||
created = try c.decodeIfPresent(Date.self, forKey: .created) ?? Date()
|
||||
updated = try c.decodeIfPresent(Date.self, forKey: .updated) ?? Date()
|
||||
}
|
||||
|
||||
public enum ValidationError: LocalizedError, Equatable {
|
||||
case emptyName
|
||||
case emptyPrinterID
|
||||
case invalidColourSpace(String)
|
||||
case emptyPresetID
|
||||
case invalidCalibrationURL(String)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .emptyName: return "Media recipe is missing a name."
|
||||
case .emptyPrinterID: return "Media recipe is missing a printer."
|
||||
case .invalidColourSpace(let v):
|
||||
return "colour_space must be \"rgb\" or \"cmyk\", got \"\(v)\"."
|
||||
case .emptyPresetID: return "Media recipe is missing a preset."
|
||||
case .invalidCalibrationURL(let v):
|
||||
return "calibration_url must be an absolute path without \"..\" or NUL, got \"\(v)\"."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates the binding fields. `colourSpace` is normalized to
|
||||
/// lowercase before comparison. `CAL_` cal names are **not**
|
||||
/// rejected — that is an apply-time policy, not schema.
|
||||
@discardableResult
|
||||
public func validated() throws -> MediaRecipe {
|
||||
var r = self
|
||||
r.id = id.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
r.name = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
r.colourSpace = colourSpace.lowercased()
|
||||
guard !r.name.isEmpty else { throw ValidationError.emptyName }
|
||||
guard !r.printerID.isEmpty else { throw ValidationError.emptyPrinterID }
|
||||
guard r.colourSpace == "rgb" || r.colourSpace == "cmyk" else {
|
||||
throw ValidationError.invalidColourSpace(colourSpace)
|
||||
}
|
||||
guard !r.presetID.isEmpty else { throw ValidationError.emptyPresetID }
|
||||
if let cal = r.calibrationURL, !cal.isEmpty {
|
||||
guard cal.hasPrefix("/"), !cal.contains(".."), !cal.contains("\0") else {
|
||||
throw ValidationError.invalidCalibrationURL(cal)
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import Foundation
|
||||
|
||||
/// `Save Report…` — a one-page UTF-8 Markdown summary written next to
|
||||
/// the artefacts as `{basename}-report.md` (issue #149). Generated
|
||||
/// output, safe to overwrite. Filenames only — never absolute paths —
|
||||
/// and no HTML.
|
||||
public enum ProjectReport {
|
||||
|
||||
/// `{cwd}/{basename}-report.md`.
|
||||
public static func url(for project: ICCeryProject) -> URL {
|
||||
URL(fileURLWithPath: project.cwd, isDirectory: true)
|
||||
.appendingPathComponent("\(project.basename)-report.md")
|
||||
}
|
||||
|
||||
/// Renders the report. `artefacts` is the live probe result so the
|
||||
/// checklist shows what is actually on disk, not what the project
|
||||
/// JSON claims (R18).
|
||||
public static func markdown(
|
||||
project: ICCeryProject,
|
||||
recipeName: String?,
|
||||
artefacts: StageArtefacts
|
||||
) -> String {
|
||||
var lines: [String] = []
|
||||
lines.append("# \(project.name)")
|
||||
lines.append("")
|
||||
if let printer = project.printerDisplayName ?? project.printerID,
|
||||
!printer.isEmpty {
|
||||
lines.append("- **Printer:** \(printer)")
|
||||
}
|
||||
if let recipeName, !recipeName.isEmpty {
|
||||
lines.append("- **Media recipe:** \(recipeName)")
|
||||
}
|
||||
if let preset = project.presetID, !preset.isEmpty {
|
||||
lines.append("- **Preset:** \(preset)")
|
||||
}
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Artefacts")
|
||||
lines.append("")
|
||||
lines.append("| File | Status |")
|
||||
lines.append("|------|--------|")
|
||||
lines.append(row("\(project.basename).ti1", exists: artefacts.stage1Complete))
|
||||
lines.append(row("\(project.basename).ti2", exists: artefacts.stage2Complete))
|
||||
lines.append(row("\(project.basename).ti3", exists: artefacts.stage3Complete))
|
||||
let profileName = artefacts.profilePath?.lastPathComponent
|
||||
?? "\(project.basename).\(ArtefactProbe.defaultProfileExtension)"
|
||||
lines.append(row(profileName, exists: artefacts.stage4Complete))
|
||||
if let gam = artefacts.gamPath {
|
||||
lines.append(row(gam.lastPathComponent, exists: true))
|
||||
}
|
||||
lines.append("")
|
||||
|
||||
if let verification = project.lastVerification {
|
||||
lines.append("## Last verification")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"- avg ΔE₀₀ \(f(verification.avgDE00)), max ΔE₀₀ \(f(verification.maxDE00))"
|
||||
+ " (\(verification.status))")
|
||||
lines.append("- Profile: \(verification.profileFilename)")
|
||||
lines.append("- Date: \(ISO8601DateFormatter().string(from: verification.date))")
|
||||
lines.append("")
|
||||
}
|
||||
|
||||
if !project.notes.isEmpty {
|
||||
lines.append("## Notes")
|
||||
lines.append("")
|
||||
lines.append(project.notes)
|
||||
lines.append("")
|
||||
}
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
/// Writes `{cwd}/{basename}-report.md` atomically, overwriting an
|
||||
/// existing generated report.
|
||||
@discardableResult
|
||||
public static func write(
|
||||
project: ICCeryProject,
|
||||
recipeName: String?,
|
||||
artefacts: StageArtefacts
|
||||
) throws -> URL {
|
||||
let destination = url(for: project)
|
||||
try AtomicFileWriter.write(
|
||||
markdown(project: project, recipeName: recipeName, artefacts: artefacts),
|
||||
to: destination)
|
||||
return destination
|
||||
}
|
||||
|
||||
private static func row(_ filename: String, exists: Bool) -> String {
|
||||
"| \(filename) | \(exists ? "exists" : "missing") |"
|
||||
}
|
||||
|
||||
private static func f(_ value: Double) -> String {
|
||||
String(format: "%.2f", value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import Foundation
|
||||
|
||||
/// One row in `recent_projects.json` — bookmark `Data` *and* the plain
|
||||
/// absolute path so the entry still resolves when the bookmark can't.
|
||||
public struct RecentProjectEntry: Codable, Equatable, Sendable, Identifiable {
|
||||
public var name: String
|
||||
/// Absolute path to the `.icceryproj` file.
|
||||
public var path: String
|
||||
/// File bookmark; optional — path is the fallback.
|
||||
public var bookmark: Data?
|
||||
public var updated: Date
|
||||
|
||||
public var id: String { path }
|
||||
|
||||
/// Stable digest of `path` for `projectRecent-{hash}` menu ids —
|
||||
/// FNV-1a 64, stable across launches unlike `hashValue`.
|
||||
public var bookmarkHash: String {
|
||||
var hash: UInt64 = 0xcbf29ce484222325
|
||||
for byte in path.utf8 {
|
||||
hash ^= UInt64(byte)
|
||||
hash &*= 0x100000001b3
|
||||
}
|
||||
return String(hash, radix: 16)
|
||||
}
|
||||
|
||||
public init(name: String, path: String, bookmark: Data? = nil, updated: Date = Date()) {
|
||||
self.name = name
|
||||
self.path = path
|
||||
self.bookmark = bookmark
|
||||
self.updated = updated
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name, path, bookmark, updated
|
||||
}
|
||||
}
|
||||
|
||||
/// Recent-projects list (issue #149). Lives in
|
||||
/// `AppPaths.appDataDir/recent_projects.json` — app data, never inside
|
||||
/// the project file (R12).
|
||||
///
|
||||
/// Cap 20, newest first, deduplicated by path. Entries whose file is
|
||||
/// gone are dropped by `pruneMissing()` (called when the Open Recent
|
||||
/// submenu builds). A corrupt file throws on load and is never
|
||||
/// overwritten — the view model shows an empty list and keeps the
|
||||
/// bytes (`.throwCorrupt`, R12).
|
||||
public actor RecentProjectsStore {
|
||||
|
||||
/// Default cap.
|
||||
public static let defaultCapacity = 20
|
||||
|
||||
/// Path to the JSON store.
|
||||
public let url: URL
|
||||
|
||||
/// In-memory cache, kept in sync with disk.
|
||||
private var entries: [RecentProjectEntry] = []
|
||||
|
||||
/// Explicit load flag — an empty file is still "loaded".
|
||||
private var loaded = false
|
||||
|
||||
private let capacity: Int
|
||||
private let fileStore: JSONFileStore<[RecentProjectEntry]>
|
||||
private let fileManager: FileManager
|
||||
|
||||
public init(
|
||||
url: URL = AppPaths.appDataDir.appendingPathComponent("recent_projects.json"),
|
||||
capacity: Int = defaultCapacity,
|
||||
fileManager: FileManager = .default
|
||||
) {
|
||||
self.url = url
|
||||
self.capacity = capacity
|
||||
self.fileManager = fileManager
|
||||
self.fileStore = JSONFileStore(
|
||||
fileURL: url,
|
||||
corrupt: .throwCorrupt,
|
||||
defaultValue: { [] },
|
||||
dateEncoding: .iso8601,
|
||||
dateDecoding: .iso8601
|
||||
)
|
||||
}
|
||||
|
||||
/// Loads entries from disk. Returns the existing cache if already
|
||||
/// loaded. Throws when the file exists but cannot be parsed; the
|
||||
/// existing file is never overwritten in that case.
|
||||
public func load() throws -> [RecentProjectEntry] {
|
||||
guard !loaded else { return entries }
|
||||
guard fileManager.fileExists(atPath: url.path) else {
|
||||
loaded = true
|
||||
return []
|
||||
}
|
||||
entries = try fileStore.load()
|
||||
loaded = true
|
||||
return entries
|
||||
}
|
||||
|
||||
/// Returns all cached entries, newest first.
|
||||
public func all() -> [RecentProjectEntry] {
|
||||
entries
|
||||
}
|
||||
|
||||
/// Pushes a project to the front, deduplicating by path and
|
||||
/// trimming to `capacity`. Writes atomically.
|
||||
///
|
||||
/// Loads the existing list first and propagates any load error so
|
||||
/// an unparseable file is never overwritten.
|
||||
@discardableResult
|
||||
public func add(url fileURL: URL, name: String) throws -> [RecentProjectEntry] {
|
||||
try load()
|
||||
let bookmark = try? fileURL.bookmarkData()
|
||||
var updated = entries.filter { $0.path != fileURL.path }
|
||||
updated.insert(
|
||||
RecentProjectEntry(
|
||||
name: name,
|
||||
path: fileURL.path,
|
||||
bookmark: bookmark,
|
||||
updated: Date()),
|
||||
at: 0)
|
||||
if updated.count > capacity {
|
||||
updated = Array(updated.prefix(capacity))
|
||||
}
|
||||
try fileStore.save(updated)
|
||||
entries = updated
|
||||
return updated
|
||||
}
|
||||
|
||||
/// Removes an entry by path and writes atomically. Returns false
|
||||
/// when no entry with that path exists.
|
||||
@discardableResult
|
||||
public func remove(path: String) throws -> Bool {
|
||||
try load()
|
||||
let before = entries.count
|
||||
let updated = entries.filter { $0.path != path }
|
||||
guard updated.count != before else { return false }
|
||||
try fileStore.save(updated)
|
||||
entries = updated
|
||||
return true
|
||||
}
|
||||
|
||||
/// `Clear Menu` — wipes the recents file only; `.icceryproj` files
|
||||
/// are never deleted (R12).
|
||||
public func clear() throws {
|
||||
try load()
|
||||
try fileStore.save([])
|
||||
entries = []
|
||||
}
|
||||
|
||||
/// Drops entries whose file is gone and rewrites the store.
|
||||
/// Called when the Open Recent submenu builds — missing files are
|
||||
/// dropped there, not at launch (issue #149).
|
||||
@discardableResult
|
||||
public func pruneMissing() throws -> [RecentProjectEntry] {
|
||||
try load()
|
||||
let kept = entries.filter {
|
||||
fileManager.fileExists(atPath: $0.path)
|
||||
}
|
||||
if kept.count != entries.count {
|
||||
try fileStore.save(kept)
|
||||
entries = kept
|
||||
}
|
||||
return kept
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import Foundation
|
||||
|
||||
/// Line classifier for `spotread` stdout (issue #148).
|
||||
///
|
||||
/// `spotread` shares `chartread`'s white-tile calibration phrasing, so
|
||||
/// this wraps `ChartreadClassifier` and only intercepts the lines that
|
||||
/// would otherwise misclassify:
|
||||
///
|
||||
/// - `… and then hit any key to continue,` / `or hit Esc or Q to abort:`
|
||||
/// continuation lines that trail the calibration and spot prompts —
|
||||
/// sticky to the current prompt state instead of `PROMPT_CONTINUE`.
|
||||
/// - `Place instrument on a spot to be measured,` /
|
||||
/// `and hit a key to take a reading,` → `AWAITING_STRIP` (the Read
|
||||
/// prompt; the generic chartread matcher does not know "take a
|
||||
/// reading").
|
||||
///
|
||||
/// Sample lines (`Result is XYZ: …, D50 Lab: …`) are parsed by
|
||||
/// `SpotReadParser`, not classified here.
|
||||
public enum SpotReadClassifier {
|
||||
|
||||
public static func classify(
|
||||
line: String,
|
||||
previousState: ChartreadState
|
||||
) -> ChartreadClassifyResult {
|
||||
let text = line.lowercased()
|
||||
|
||||
// Spot-read prompt continuations keep the current prompt state.
|
||||
if previousState == .calibrating || previousState == .awaitingStrip {
|
||||
if text.contains("hit any key")
|
||||
|| text.contains("hit space")
|
||||
|| text.contains("esc or")
|
||||
|| text.contains("abort")
|
||||
|| text.contains("to abort") {
|
||||
return ChartreadClassifyResult(state: previousState)
|
||||
}
|
||||
}
|
||||
|
||||
// "Place instrument on a spot to be measured," /
|
||||
// " and hit a key to take a reading," — the Read trigger prompt.
|
||||
if text.contains("spot to be measured")
|
||||
|| text.contains("take a reading")
|
||||
|| text.contains("measure the spot") {
|
||||
return ChartreadClassifyResult(state: .awaitingStrip)
|
||||
}
|
||||
|
||||
return ChartreadClassifier.classify(line: line, previousState: previousState)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import Foundation
|
||||
|
||||
/// One patch measurement emitted by a running `spotread` child (#148).
|
||||
public struct SpotReadSample: Codable, Sendable, Equatable, Identifiable {
|
||||
public var id: UUID
|
||||
public var timestamp: Date
|
||||
/// D50 Lab (always present — derived from XYZ when needed).
|
||||
public var lab: LabColor
|
||||
/// XYZ on the 0–100 scale used by the fork, when the line carried it.
|
||||
public var xyz: XYZColor?
|
||||
public var instrumentName: String
|
||||
public var port: Int?
|
||||
/// Diagnostics only — never rendered as HTML or shown in the table.
|
||||
public var rawLine: String
|
||||
|
||||
public init(
|
||||
id: UUID = UUID(),
|
||||
timestamp: Date = Date(),
|
||||
lab: LabColor,
|
||||
xyz: XYZColor? = nil,
|
||||
instrumentName: String = "",
|
||||
port: Int? = nil,
|
||||
rawLine: String = ""
|
||||
) {
|
||||
self.id = id
|
||||
self.timestamp = timestamp
|
||||
self.lab = lab
|
||||
self.xyz = xyz
|
||||
self.instrumentName = instrumentName
|
||||
self.port = port
|
||||
self.rawLine = rawLine
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses `spotread` result lines into Lab / XYZ triples.
|
||||
///
|
||||
/// The fork's line shape is the upstream
|
||||
/// `Result is XYZ: <x> <y> <z>, D50 Lab: <L> <a> <b>`; a Lab-only line
|
||||
/// also parses, and an XYZ-only line derives Lab via
|
||||
/// `LabColorMath.xyzToLab` (D50).
|
||||
public enum SpotReadParser {
|
||||
|
||||
public static func parse(line: String) -> (xyz: XYZColor?, lab: LabColor)? {
|
||||
guard line.range(of: "result is", options: .caseInsensitive) != nil
|
||||
|| line.range(of: #"\bLab\b"#, options: .regularExpression) != nil
|
||||
|| line.range(of: #"\bXYZ\b"#, options: .regularExpression) != nil
|
||||
else { return nil }
|
||||
|
||||
var xyz: XYZColor?
|
||||
var lab: LabColor?
|
||||
|
||||
if let m = triple(#"\bXYZ\b[:\s]"#, in: line) {
|
||||
xyz = XYZColor(x: m.0, y: m.1, z: m.2)
|
||||
}
|
||||
if let m = triple(#"\bLab\b[:\s]"#, in: line) {
|
||||
lab = LabColor(l: m.0, a: m.1, b: m.2)
|
||||
}
|
||||
if lab == nil, let xyz {
|
||||
lab = LabColorMath.xyzToLab(xyz)
|
||||
}
|
||||
guard let lab else { return nil }
|
||||
return (xyz, lab)
|
||||
}
|
||||
|
||||
private static func triple(_ marker: String, in line: String) -> (Double, Double, Double)? {
|
||||
let pattern = marker + #"\s*(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)"#
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive),
|
||||
let match = regex.firstMatch(
|
||||
in: line, options: [], range: NSRange(line.startIndex..., in: line)),
|
||||
match.numberOfRanges == 4,
|
||||
let r1 = Range(match.range(at: 1), in: line),
|
||||
let r2 = Range(match.range(at: 2), in: line),
|
||||
let r3 = Range(match.range(at: 3), in: line),
|
||||
let a = Double(line[r1]),
|
||||
let b = Double(line[r2]),
|
||||
let c = Double(line[r3])
|
||||
else { return nil }
|
||||
return (a, b, c)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import Foundation
|
||||
/// filter events on `id` — historical bug #56 was an id mismatch.
|
||||
public enum ProcessID {
|
||||
public static let instlist = "instlist"
|
||||
/// Spot-read console (issue #148) — single lease, like `instlist`.
|
||||
public static let spotread = "spotread"
|
||||
|
||||
public static func targen(_ basename: String) -> String { "targen_\(basename)" }
|
||||
public static func printtarg(_ basename: String) -> String { "printtarg_\(basename)" }
|
||||
|
||||
@@ -8,11 +8,13 @@ All measurement, chart generation, and profile mathematics live in the [Gronod A
|
||||
|---|---|
|
||||
| Product | ICCery v2 for macOS |
|
||||
| Bundle | `com.gronod.iccery2` |
|
||||
| Floor | macOS 14 Sonoma, universal `arm64` + `x86_64` |
|
||||
| Floor | macOS 12.0 Monterey, universal `arm64` + `x86_64` |
|
||||
| Default branch | `develop` |
|
||||
| M6 | Stage 0 calibration, CGATS import, SceneKit gamut viewer, packaging — shipped on `develop` |
|
||||
| M7 | Pre-UAT hardening & baseline consolidation — shipped on `develop` |
|
||||
| M8 | Deduplication/consolidation contracts & UAT-ready hardening (#79–#86) — in flight on `milestone/m8-consolidation` |
|
||||
| M8 | Deduplication/consolidation contracts & UAT-ready hardening (#79–#86) — shipped on `develop` |
|
||||
| M9 | macOS 12 / Xcode 14.2 retarget — shipped on `develop` (PR #145) |
|
||||
| M10 | Studio workflow (#146–#149) — in flight on `milestone/m10-studio` |
|
||||
| Licence | Proprietary source in [`LICENCE.md`](LICENCE.md); bundled Argyll sidecars remain AGPLv3 |
|
||||
|
||||
## What it does
|
||||
@@ -57,9 +59,11 @@ Equivalent without Make:
|
||||
xcodegen generate
|
||||
xcodebuild test -scheme ICCery \
|
||||
-destination 'platform=macOS' \
|
||||
ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
|
||||
ARCHS="$(uname -m)"
|
||||
```
|
||||
|
||||
`project.yml` sets `ARCHS: "$(ARCHS_STANDARD)"`, so a plain `xcodebuild test` (and `make test`) builds universal; the `ARCHS="$(uname -m)"` override narrows it to the host slice.
|
||||
|
||||
Sidecars are **not** in git. `scripts/fetch-argyll.sh` pulls the latest (or `ARGYLL_RELEASE_TAG`) macOS-universal release from `gronod/argyllcms`, extracts to `Vendor/Argyll/macos-universal/`, ad-hoc signs every Mach-O, and fails if `codesign -dvv` or the `instlist` marker is missing.
|
||||
|
||||
```bash
|
||||
@@ -124,10 +128,13 @@ docs/ functional spec + v2 ticket plan
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
# full suite (universal)
|
||||
# full suite (host arch)
|
||||
xcodebuild test -scheme ICCery \
|
||||
-destination 'platform=macOS' \
|
||||
ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
|
||||
ARCHS="$(uname -m)"
|
||||
|
||||
# to compile-check both slices instead:
|
||||
# ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
|
||||
|
||||
# examples
|
||||
xcodebuild test -scheme ICCery -destination 'platform=macOS' \
|
||||
@@ -171,11 +178,11 @@ Agent / branch rules: [`AGENTS.md`](AGENTS.md), [`BUILD-PLAN.md`](BUILD-PLAN.md)
|
||||
|
||||
```
|
||||
develop
|
||||
└── milestone/m8-consolidation # integration branch
|
||||
└── milestone/m10-studio # M10 integration branch
|
||||
└── feat/<issue>-<slug> # one issue per branch
|
||||
```
|
||||
|
||||
Feature PRs target the current milestone branch, not `develop`. The milestone branch merges to `develop` when its issues are green. Completion PRs for issues #79–#86 target `milestone/m8-consolidation`; `milestone/m8-consolidation` merges into `develop` once all milestone gates pass. Do not open umbrella "bugfix" branches that mix tickets.
|
||||
Feature PRs target the current milestone branch, not `develop`. The milestone branch merges to `develop` when its issues are green. Completion PRs for issues #146–#149 target `milestone/m10-studio`; `milestone/m10-studio` merges into `develop` once all milestone gates pass. Do not open umbrella "bugfix" branches that mix tickets.
|
||||
|
||||
## Licence
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- App Sandbox intentionally absent: ICCery must spawn Argyll tools,
|
||||
read/write user-chosen working directories, and talk to lp/CUPS. -->
|
||||
<key>com.apple.security.device.usb</key>
|
||||
<true/>
|
||||
<!-- Debug only: the shared ICCeryCore package framework embedded in the
|
||||
test products is ad-hoc signed with no Team ID, so hardened-runtime
|
||||
library validation kills the test host at launch (run 31992, #119).
|
||||
Release uses ICCery.entitlements and links the package statically. -->
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -13,6 +13,8 @@ struct AppEnvironment: Sendable {
|
||||
let runner: ArgyllRunner
|
||||
let cupsService: CupsService
|
||||
let historyStore: VerificationHistoryStore
|
||||
let mediaStore: MediaLibraryStore
|
||||
let recentProjectsStore: RecentProjectsStore
|
||||
|
||||
static func live(
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment
|
||||
@@ -20,11 +22,15 @@ struct AppEnvironment: Sendable {
|
||||
let settingsStore = SettingsStore()
|
||||
var overrideDir = settingsStore.load().argyllBinaryDir
|
||||
.map { URL(fileURLWithPath: $0) }
|
||||
var bundledRoot = AppPaths.bundledArgyllDir
|
||||
var cupsDir = URL(fileURLWithPath: "/usr/bin")
|
||||
#if DEBUG
|
||||
if let dir = environment["ICCERY_ARGYLL_BINARY_DIR"], !dir.isEmpty {
|
||||
overrideDir = URL(fileURLWithPath: dir)
|
||||
}
|
||||
if let dir = environment["ICCERY_ARGYLL_BUNDLED_ROOT"], !dir.isEmpty {
|
||||
bundledRoot = URL(fileURLWithPath: dir)
|
||||
}
|
||||
if let dir = environment["ICCERY_CUPS_BIN_DIR"], !dir.isEmpty {
|
||||
cupsDir = URL(fileURLWithPath: dir)
|
||||
}
|
||||
@@ -35,12 +41,15 @@ struct AppEnvironment: Sendable {
|
||||
presetStore: PresetStore(settingsStore: settingsStore),
|
||||
runner: ArgyllRunner(
|
||||
processManager: .shared,
|
||||
binaryResolver: BinaryResolver(overrideDir: overrideDir)
|
||||
binaryResolver: BinaryResolver(
|
||||
bundledRoot: bundledRoot, overrideDir: overrideDir)
|
||||
),
|
||||
cupsService: CupsService(
|
||||
processManager: .shared,
|
||||
binaryDir: cupsDir),
|
||||
historyStore: VerificationHistoryStore()
|
||||
historyStore: VerificationHistoryStore(),
|
||||
mediaStore: MediaLibraryStore(),
|
||||
recentProjectsStore: RecentProjectsStore()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -74,6 +83,20 @@ enum UITestHooks {
|
||||
static var presetImportURL: URL? { url("ICCERY_TEST_PRESET_IMPORT") }
|
||||
/// Preset export destination.
|
||||
static var presetExportURL: URL? { url("ICCERY_TEST_PRESET_EXPORT") }
|
||||
/// Spot-read CSV export destination (`selectCsvSavePath`, #148).
|
||||
static var csvExportURL: URL? { url("ICCERY_TEST_CSV_EXPORT") }
|
||||
/// `.gam` compare picker result (gamut sheet, #147). Unset → cancel.
|
||||
static var gamutFileURL: URL? { url("ICCERY_TEST_GAMUT_FILE") }
|
||||
/// `.icc/.icm` compare picker result (gamut sheet, #147). Unset → cancel.
|
||||
static var gamutProfileURL: URL? { url("ICCERY_TEST_GAMUT_PROFILE") }
|
||||
/// TIFF sample picker result (gamut sheet, #147). Unset → cancel.
|
||||
static var gamutTiffURL: URL? { url("ICCERY_TEST_GAMUT_TIFF") }
|
||||
/// `selectProjectFile` result (`.icceryproj` open, #149). Unset → cancel.
|
||||
static var projectOpenURL: URL? { url("ICCERY_TEST_PROJECT_OPEN") }
|
||||
/// `selectProjectSavePath` result (`.icceryproj` save-as, #149).
|
||||
static var projectSaveURL: URL? { url("ICCERY_TEST_PROJECT_SAVE") }
|
||||
/// Relocate-folder result when a project's `cwd` is missing (#149).
|
||||
static var projectRelocateURL: URL? { url("ICCERY_TEST_PROJECT_RELOCATE") }
|
||||
|
||||
// MARK: - Print panel / CUPS stubs (issue 13/17)
|
||||
|
||||
|
||||
@@ -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
@@ -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 0–100 · ±128")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("L", text: $viewModel.labEntryL)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 56)
|
||||
.accessibilityIdentifier("gamutLabEntryL")
|
||||
TextField("a", text: $viewModel.labEntryA)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 56)
|
||||
.accessibilityIdentifier("gamutLabEntryA")
|
||||
TextField("b", text: $viewModel.labEntryB)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 56)
|
||||
.accessibilityIdentifier("gamutLabEntryB")
|
||||
Button("Inspect") { viewModel.inspectEnteredLab() }
|
||||
.disabled(!viewModel.canInspectLab)
|
||||
.accessibilityIdentifier("btnGamutInspectLab")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 `(x→a*, y→L*, z→b*)`.
|
||||
/// 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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import SwiftUI
|
||||
import ICCeryCore
|
||||
|
||||
/// `#saveMediaRecipeDialog` — capture the current printer + paper +
|
||||
/// ink + `.cal` bound to the selected preset (issue #146). Clones
|
||||
/// `SavePresetDialog` chrome; names render via `Text` only (#114).
|
||||
struct SaveMediaRecipeDialog: View {
|
||||
@ObservedObject var workflow: TargetWorkflowViewModel
|
||||
/// Observed directly: nested ObservableObjects are not tracked
|
||||
/// through the parent's `objectWillChange`.
|
||||
@ObservedObject private var media: MediaLibraryViewModel
|
||||
@ObservedObject private var printSession: PrintSessionViewModel
|
||||
|
||||
init(workflow: TargetWorkflowViewModel) {
|
||||
self.workflow = workflow
|
||||
self._media = ObservedObject(wrappedValue: workflow.media)
|
||||
self._printSession = ObservedObject(wrappedValue: workflow.print)
|
||||
}
|
||||
|
||||
private var printerCaption: String {
|
||||
let queue = printSession.selectedPrinter
|
||||
guard !queue.isEmpty else { return "None" }
|
||||
let display = printSession.printers
|
||||
.first { $0.name == queue }?.displayName ?? queue
|
||||
return "\(display) (\(queue))"
|
||||
}
|
||||
|
||||
private func captureRow(
|
||||
_ label: String, value: String, identifier: String
|
||||
) -> some View {
|
||||
HStack {
|
||||
Text(label).foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text(value)
|
||||
.foregroundStyle(Theme.text)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.accessibilityIdentifier(identifier)
|
||||
}
|
||||
}
|
||||
|
||||
private var saveDisabled: Bool {
|
||||
media.saveMediaName.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
|| media.saveMediaPaper.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
|| media.saveMediaInk.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
|| media.captureColourSpaceMismatch
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Save Media Recipe").font(.title3).foregroundStyle(Theme.text)
|
||||
TextField("Name", text: $media.saveMediaName)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.accessibilityIdentifier("saveMediaName")
|
||||
TextField("Notes (optional)", text: $media.saveMediaNotes)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.accessibilityIdentifier("saveMediaNotes")
|
||||
TextField("Paper", text: $media.saveMediaPaper)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.accessibilityIdentifier("saveMediaPaper")
|
||||
TextField("Ink set", text: $media.saveMediaInk)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.accessibilityIdentifier("saveMediaInk")
|
||||
|
||||
captureRow("Printer", value: printerCaption,
|
||||
identifier: "saveMediaPrinter")
|
||||
captureRow("Preset",
|
||||
value: workflow.selectedPreset?.name ?? "No preset",
|
||||
identifier: "saveMediaPreset")
|
||||
captureRow("Colour space",
|
||||
value: workflow.colourSpace.rawValue.uppercased(),
|
||||
identifier: "saveMediaColourSpace")
|
||||
captureRow("Calibration",
|
||||
value: workflow.profile.calibrationFile.isEmpty
|
||||
? "None" : workflow.profile.calibrationFile,
|
||||
identifier: "saveMediaCal")
|
||||
Toggle("Apply calibration to profile",
|
||||
isOn: $media.saveMediaApplyCal)
|
||||
.disabled(!media.calApplyable)
|
||||
.accessibilityIdentifier("saveMediaApplyCal")
|
||||
|
||||
if media.captureColourSpaceMismatch {
|
||||
Text("Colour space does not match the selected preset.")
|
||||
.font(.caption).foregroundStyle(.orange)
|
||||
}
|
||||
if let error = media.saveMediaError {
|
||||
Text(error).font(.caption).foregroundStyle(.orange)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Cancel") { workflow.showingSaveMedia = false }
|
||||
.accessibilityIdentifier("btnCloseSaveMediaDialog")
|
||||
Button("Save") {
|
||||
Task {
|
||||
if await media.captureFromSession() {
|
||||
workflow.showingSaveMedia = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(saveDisabled)
|
||||
.accessibilityIdentifier("btnConfirmSaveMedia")
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 380)
|
||||
.background(Theme.background)
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier("saveMediaRecipeDialog")
|
||||
}
|
||||
}
|
||||
|
||||
/// `#manageMediaDialog` — list, apply, delete, capture (issue #146).
|
||||
/// `List`, not `Table` — macOS 12 target. Clones `ManagePresetsDialog`.
|
||||
struct ManageMediaDialog: View {
|
||||
@ObservedObject var workflow: TargetWorkflowViewModel
|
||||
/// Observed directly: nested ObservableObjects are not tracked
|
||||
/// through the parent's `objectWillChange`.
|
||||
@ObservedObject private var media: MediaLibraryViewModel
|
||||
@State private var selection: String?
|
||||
@State private var pendingDelete: MediaRecipe?
|
||||
|
||||
init(workflow: TargetWorkflowViewModel) {
|
||||
self.workflow = workflow
|
||||
self._media = ObservedObject(wrappedValue: workflow.media)
|
||||
}
|
||||
|
||||
private func presetCaption(for recipe: MediaRecipe) -> String {
|
||||
workflow.presets.first { $0.id == recipe.presetID }?.name
|
||||
?? "Missing preset"
|
||||
}
|
||||
|
||||
private func calCaption(for recipe: MediaRecipe) -> String {
|
||||
if media.staleReasons[recipe.id]?.contains(.calibration) == true {
|
||||
return "Stale"
|
||||
}
|
||||
if let days = media.calAgeDays[recipe.id] {
|
||||
return "Cal \(days)d"
|
||||
}
|
||||
return "No cal"
|
||||
}
|
||||
|
||||
private func applyAndDismiss(_ recipe: MediaRecipe) {
|
||||
Task {
|
||||
if await media.apply(recipe) {
|
||||
workflow.showingManageMedia = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func presetMissing(_ recipe: MediaRecipe) -> Bool {
|
||||
!workflow.presets.contains { $0.id == recipe.presetID }
|
||||
}
|
||||
|
||||
private func calStale(_ recipe: MediaRecipe) -> Bool {
|
||||
media.staleReasons[recipe.id]?.contains(.calibration) == true
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func row(_ recipe: MediaRecipe) -> some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(recipe.name).foregroundStyle(Theme.text)
|
||||
Text("\(recipe.printerDisplayName) · \(recipe.paperName) · \(recipe.inkSet)")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
HStack(spacing: 8) {
|
||||
Text(presetCaption(for: recipe))
|
||||
.font(.caption)
|
||||
.foregroundStyle(presetMissing(recipe) ? .orange : .secondary)
|
||||
Text(calCaption(for: recipe))
|
||||
.font(.caption)
|
||||
.foregroundStyle(calStale(recipe) ? .orange : .secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Button("Apply") { applyAndDismiss(recipe) }
|
||||
.accessibilityIdentifier("btnMediaLibraryApply-\(recipe.id)")
|
||||
Button("Delete", role: .destructive) {
|
||||
pendingDelete = recipe
|
||||
}
|
||||
.accessibilityIdentifier("btnMediaLibraryDelete-\(recipe.id)")
|
||||
}
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier("mediaRow-\(recipe.id)")
|
||||
.tag(recipe.id)
|
||||
.contentShape(Rectangle())
|
||||
.simultaneousGesture(
|
||||
TapGesture(count: 2).onEnded { applyAndDismiss(recipe) }
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Manage Media Recipes").font(.title3).foregroundStyle(Theme.text)
|
||||
|
||||
List(selection: $selection) {
|
||||
if media.recipes.isEmpty {
|
||||
Text("No media recipes yet. Capture the current printer, paper and preset.")
|
||||
.font(.callout).foregroundStyle(.secondary)
|
||||
.accessibilityIdentifier("mediaLibraryEmpty")
|
||||
}
|
||||
ForEach(media.recipes) { recipe in
|
||||
row(recipe)
|
||||
}
|
||||
}
|
||||
.accessibilityIdentifier("mediaLibraryList")
|
||||
.frame(minHeight: 260)
|
||||
|
||||
HStack {
|
||||
Button("Apply selected") {
|
||||
if let id = selection,
|
||||
let recipe = media.recipes.first(where: { $0.id == id }) {
|
||||
applyAndDismiss(recipe)
|
||||
}
|
||||
}
|
||||
.disabled(selection == nil)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.accessibilityIdentifier("btnMediaLibraryApply")
|
||||
Button("Capture current…") {
|
||||
media.captureAfterManageDismiss = true
|
||||
workflow.showingManageMedia = false
|
||||
}
|
||||
.accessibilityIdentifier("btnMediaLibraryCaptureFromManage")
|
||||
Spacer()
|
||||
Button("Close") { workflow.showingManageMedia = false }
|
||||
.accessibilityIdentifier("btnCloseManageMediaDialog")
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 640)
|
||||
.background(Theme.background)
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier("manageMediaDialog")
|
||||
.onAppear {
|
||||
media.reload()
|
||||
media.refreshStaleness()
|
||||
}
|
||||
.alert(
|
||||
"Delete media recipe?",
|
||||
isPresented: Binding(
|
||||
get: { pendingDelete != nil },
|
||||
set: { if !$0 { pendingDelete = nil } }
|
||||
),
|
||||
presenting: pendingDelete
|
||||
) { recipe in
|
||||
Button("Cancel", role: .cancel) { pendingDelete = nil }
|
||||
Button("Delete", role: .destructive) {
|
||||
media.delete(recipe)
|
||||
pendingDelete = nil
|
||||
}
|
||||
} message: { recipe in
|
||||
Text("Delete \"\(recipe.name)\"? This does not delete the .cal or the preset.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
import ICCeryCore
|
||||
|
||||
/// Media recipe library: capture / apply / staleness for issue #146.
|
||||
///
|
||||
/// A recipe binds a CUPS queue + paper + ink + `.cal` to a
|
||||
/// `ProfilingPreset`. Applying a recipe goes through the existing
|
||||
/// `applyPreset` (#82) path — there is no second Stage 1 form. Paper
|
||||
/// and ink are library metadata only; they are never written to the
|
||||
/// targen label fields.
|
||||
@MainActor
|
||||
final class MediaLibraryViewModel: ObservableObject {
|
||||
|
||||
/// Why a recipe row is flagged stale.
|
||||
struct StaleReason: OptionSet {
|
||||
let rawValue: Int
|
||||
static let calibration = StaleReason(rawValue: 1 << 0)
|
||||
static let printer = StaleReason(rawValue: 1 << 1)
|
||||
}
|
||||
|
||||
let workflow: TargetWorkflowViewModel
|
||||
let environment: AppEnvironment
|
||||
private let store: MediaLibraryStore
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
@Published var recipes: [MediaRecipe] = []
|
||||
/// Sidebar picker selection; `"none"` = no media recipe. Written
|
||||
/// only on a successful apply so a failed apply snaps back.
|
||||
@Published var selectedRecipeID = "none"
|
||||
/// `recipe.id` → stale reasons for the sidebar badge / manage sheet.
|
||||
@Published var staleReasons: [String: StaleReason] = [:]
|
||||
/// `recipe.id` → whole days since the bound `.cal` was created.
|
||||
@Published var calAgeDays: [String: Int] = [:]
|
||||
|
||||
// Save-sheet state (mirrors savePresetName/savePresetDesc).
|
||||
@Published var saveMediaName = ""
|
||||
@Published var saveMediaNotes = ""
|
||||
@Published var saveMediaPaper = ""
|
||||
@Published var saveMediaInk = ""
|
||||
@Published var saveMediaApplyCal = false
|
||||
/// Inline caption inside the capture sheet (no a11y id — roster complete).
|
||||
@Published var saveMediaError: String?
|
||||
|
||||
/// Pure flow flag — the manage sheet's "Capture current…" asks the
|
||||
/// sheet's `onDismiss` to open the capture sheet, avoiding a
|
||||
/// present-while-dismissing race.
|
||||
var captureAfterManageDismiss = false
|
||||
|
||||
init(workflow: TargetWorkflowViewModel, environment: AppEnvironment) {
|
||||
self.workflow = workflow
|
||||
self.environment = environment
|
||||
self.store = environment.mediaStore
|
||||
|
||||
reload()
|
||||
refreshStaleness()
|
||||
// The library needs queues enumerated at launch so Capture can
|
||||
// enable and the not-installed badge is computable; Stage 2 only
|
||||
// enumerates when a printtarg manifest exists.
|
||||
if workflow.print.printers.isEmpty {
|
||||
workflow.print.refreshPrinters()
|
||||
}
|
||||
|
||||
NotificationCenter.default
|
||||
.publisher(for: SettingsStore.settingsDidChange)
|
||||
.sink { [weak self] _ in self?.refreshStaleness() }
|
||||
.store(in: &cancellables)
|
||||
workflow.print.$printers
|
||||
.sink { [weak self] _ in self?.refreshStaleness() }
|
||||
.store(in: &cancellables)
|
||||
workflow.print.$selectedPrinter
|
||||
.sink { [weak self] _ in self?.refreshStaleness() }
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
deinit { cancellables.removeAll() }
|
||||
|
||||
// MARK: - Load / corrupt
|
||||
|
||||
func reload() {
|
||||
Task { await reloadAsync() }
|
||||
}
|
||||
|
||||
func reloadAsync() async {
|
||||
do {
|
||||
recipes = try await store.load()
|
||||
} catch {
|
||||
// Corrupt-file policy: keep the file, keep the cache,
|
||||
// persistent warning; the picker falls back to "none".
|
||||
workflow.wizard.showNotice(
|
||||
"Media library is unreadable — the existing file was kept.",
|
||||
kind: .warning,
|
||||
autoHideAfter: nil
|
||||
)
|
||||
if recipes.isEmpty { selectedRecipeID = "none" }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Selection / apply
|
||||
|
||||
/// Sidebar `mediaSelect` binding. `"none"` clears the selection
|
||||
/// without resetting any Stage 1/2/4 field — it is not "reset to
|
||||
/// factory".
|
||||
func selectRecipe(_ id: String) {
|
||||
if id == "none" {
|
||||
selectedRecipeID = "none"
|
||||
return
|
||||
}
|
||||
guard let recipe = recipes.first(where: { $0.id == id }) else { return }
|
||||
Task { _ = await apply(recipe) }
|
||||
}
|
||||
|
||||
/// The single apply path — sidebar picker, manage-row Apply, and
|
||||
/// the manage footer all funnel here.
|
||||
///
|
||||
/// Returns `false` when any bound resource is unresolved (missing
|
||||
/// preset, colour-space mismatch, queue absent, missing/unparseable
|
||||
/// `.cal`) so the manage sheet stays open and the picker reverts.
|
||||
/// A `CAL_`-blocked calibration counts as applied (`true` — success
|
||||
/// with warning; the refusal is permanent so re-clicking can't help).
|
||||
@discardableResult
|
||||
func apply(_ recipe: MediaRecipe) async -> Bool {
|
||||
guard let r = try? recipe.validated() else {
|
||||
workflow.wizard.showNotice(
|
||||
"Media recipe is invalid — not applied.", kind: .error)
|
||||
return false
|
||||
}
|
||||
guard let preset = environment.presetStore.all()
|
||||
.first(where: { $0.id == r.presetID })
|
||||
else {
|
||||
workflow.wizard.showNotice(
|
||||
"Preset \(r.presetID) no longer exists — recipe not applied.",
|
||||
kind: .error)
|
||||
return false
|
||||
}
|
||||
guard preset.colourSpace.lowercased() == r.colourSpace.lowercased() else {
|
||||
workflow.wizard.showNotice(
|
||||
"Recipe colour space does not match its preset — not applied.",
|
||||
kind: .error)
|
||||
return false
|
||||
}
|
||||
|
||||
// Existing #82 mapping: presetSelect jumps, Stage 1/2/4 fields.
|
||||
workflow.applyPreset(preset)
|
||||
// Literal per issue: displayName, not the queue id.
|
||||
workflow.wizard.printerName = r.printerDisplayName
|
||||
|
||||
var succeeded = true
|
||||
|
||||
// Queue: enumerate fresh via the session's serialized path —
|
||||
// listPrinters uses fixed process ids, so an overlapping
|
||||
// enumeration would throw duplicateID. An empty result is a
|
||||
// valid list.
|
||||
if let queues = await workflow.print.enumeratePrinters() {
|
||||
if queues.contains(where: { $0.name == r.printerID }) {
|
||||
workflow.print.selectedPrinter = r.printerID
|
||||
await workflow.print.reloadSelectedCapabilities()
|
||||
} else {
|
||||
workflow.wizard.showNotice(
|
||||
"Printer \(r.printerDisplayName) is not installed.",
|
||||
kind: .warning)
|
||||
succeeded = false
|
||||
}
|
||||
} else {
|
||||
workflow.wizard.showNotice(
|
||||
"Could not enumerate printers — queue left unchanged.",
|
||||
kind: .warning)
|
||||
succeeded = false
|
||||
}
|
||||
|
||||
// Calibration — the recipe is authoritative and runs after
|
||||
// applyPreset so the preset's own cal fields don't win.
|
||||
let calPath = r.calibrationURL?.trimmingCharacters(in: .whitespaces) ?? ""
|
||||
let calStem = URL(fileURLWithPath: calPath)
|
||||
.deletingPathExtension().lastPathComponent
|
||||
let blocked = r.applyCalibration && !calPath.isEmpty
|
||||
&& (CalibrationIdentity.isCalibration(calStem)
|
||||
|| CalibrationIdentity.isCalibration(workflow.wizard.basename))
|
||||
|
||||
if blocked {
|
||||
// Literal CAL_ refusal on both names (decision 1): keep the
|
||||
// path for display but never let `printtarg -K` see it.
|
||||
workflow.profile.applyCalibration = false
|
||||
workflow.profile.calibrationFile = calPath
|
||||
selectedRecipeID = r.id
|
||||
refreshStaleness()
|
||||
workflow.wizard.showNotice(
|
||||
"Applied \(r.name) — CAL_ calibrations cannot enable printtarg -K.",
|
||||
kind: .warning,
|
||||
autoHideAfter: nil)
|
||||
return true
|
||||
}
|
||||
|
||||
if r.applyCalibration && !calPath.isEmpty {
|
||||
guard FileManager.default.fileExists(atPath: calPath) else {
|
||||
workflow.profile.applyCalibration = false
|
||||
workflow.profile.calibrationFile = calPath
|
||||
workflow.wizard.showNotice(
|
||||
"Calibration file is missing: \(calPath)", kind: .error)
|
||||
refreshStaleness()
|
||||
return false
|
||||
}
|
||||
do {
|
||||
let staleDays = environment.settingsStore.load().calibrationStaleDays
|
||||
let calStore = CalibrationStore(staleDays: staleDays)
|
||||
try await calStore.load(url: URL(fileURLWithPath: calPath))
|
||||
workflow.profile.calibrationFile = calPath
|
||||
workflow.profile.applyCalibration = true
|
||||
// Age check only — the .cal DESCRIPTOR is free text, not
|
||||
// a queue id, so a name compare false-positives.
|
||||
if await calStore.isStale() {
|
||||
workflow.wizard.showNotice(
|
||||
"Applied \(r.name) — calibration is stale.",
|
||||
kind: .warning)
|
||||
}
|
||||
} catch {
|
||||
workflow.profile.applyCalibration = false
|
||||
workflow.wizard.showNotice(
|
||||
"Could not load calibration: \(error.localizedDescription)",
|
||||
kind: .error)
|
||||
refreshStaleness()
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
workflow.profile.applyCalibration = false
|
||||
workflow.profile.calibrationFile = calPath
|
||||
}
|
||||
|
||||
if succeeded {
|
||||
selectedRecipeID = r.id
|
||||
workflow.wizard.showNotice("Applied \(r.name)")
|
||||
}
|
||||
refreshStaleness()
|
||||
return succeeded
|
||||
}
|
||||
|
||||
// MARK: - Capture
|
||||
|
||||
/// Whether the live `profile.calibrationFile` may be applied:
|
||||
/// non-empty, not a `CAL_` stem, and present on disk.
|
||||
var calApplyable: Bool {
|
||||
let path = workflow.profile.calibrationFile
|
||||
guard !path.isEmpty else { return false }
|
||||
let stem = URL(fileURLWithPath: path)
|
||||
.deletingPathExtension().lastPathComponent
|
||||
guard !CalibrationIdentity.isCalibration(stem) else { return false }
|
||||
return FileManager.default.fileExists(atPath: path)
|
||||
}
|
||||
|
||||
/// A bound preset whose colour space disagrees with the live form —
|
||||
/// the sheet shows the mismatch caption and disables Save.
|
||||
var captureColourSpaceMismatch: Bool {
|
||||
guard let preset = workflow.selectedPreset else { return false }
|
||||
return preset.colourSpace.lowercased() != workflow.colourSpace.rawValue
|
||||
}
|
||||
|
||||
/// Opens the capture sheet, prefilled from the selected recipe else
|
||||
/// the most recently captured one ("last recipe … or empty").
|
||||
func beginCapture() {
|
||||
let source = recipes.first(where: { $0.id == selectedRecipeID })
|
||||
?? recipes.last
|
||||
saveMediaPaper = source?.paperName ?? ""
|
||||
saveMediaInk = source?.inkSet ?? ""
|
||||
saveMediaName = ""
|
||||
saveMediaNotes = ""
|
||||
saveMediaError = nil
|
||||
saveMediaApplyCal = workflow.profile.applyCalibration && calApplyable
|
||||
if workflow.print.printers.isEmpty {
|
||||
workflow.print.refreshPrinters()
|
||||
}
|
||||
workflow.showingSaveMedia = true
|
||||
}
|
||||
|
||||
/// Save button — the sheet closes only on `true`.
|
||||
func captureFromSession() async -> Bool {
|
||||
let name = saveMediaName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let paper = saveMediaPaper.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let ink = saveMediaInk.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !name.isEmpty, !paper.isEmpty, !ink.isEmpty else {
|
||||
saveMediaError = "Name, paper and ink are required."
|
||||
return false
|
||||
}
|
||||
|
||||
let queue = workflow.print.selectedPrinter
|
||||
guard !queue.isEmpty else {
|
||||
saveMediaError = "Select a printer in Stage 2 first."
|
||||
return false
|
||||
}
|
||||
|
||||
// Preset binding: the selected preset when its colour space
|
||||
// matches the live form; otherwise auto-snapshot the live form
|
||||
// as a custom preset (decision 2).
|
||||
let presetID: String
|
||||
if let bound = workflow.selectedPreset {
|
||||
guard bound.colourSpace.lowercased() == workflow.colourSpace.rawValue else {
|
||||
saveMediaError = "Colour space does not match the selected preset."
|
||||
return false
|
||||
}
|
||||
presetID = bound.id
|
||||
} else {
|
||||
let snapshot = ProfilingPreset(
|
||||
id: "custom-\(UUID().uuidString.lowercased())",
|
||||
name: name,
|
||||
description: "Auto-saved for media recipe",
|
||||
targen: workflow.buildTargenConfig(),
|
||||
printtarg: workflow.buildPrinttargConfig(),
|
||||
colprof: workflow.profile.buildColprofConfig(),
|
||||
calibrationFile: nil,
|
||||
applyCalibration: nil
|
||||
)
|
||||
do {
|
||||
try environment.presetStore.saveCustom(snapshot)
|
||||
workflow.reloadPresets()
|
||||
workflow.selectedPresetID = snapshot.id
|
||||
} catch {
|
||||
saveMediaError = "Could not save preset: \(error.localizedDescription)"
|
||||
return false
|
||||
}
|
||||
presetID = snapshot.id
|
||||
}
|
||||
|
||||
// The cal path is stored verbatim; applyCalibration is forced
|
||||
// off for CAL_/missing paths via calApplyable.
|
||||
let calPath = workflow.profile.calibrationFile
|
||||
let printer = workflow.print.printers.first { $0.name == queue }
|
||||
let now = Date()
|
||||
let recipe = MediaRecipe(
|
||||
id: "recipe-\(UUID().uuidString.lowercased())",
|
||||
name: name,
|
||||
notes: saveMediaNotes.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
printerID: queue,
|
||||
printerDisplayName: printer?.displayName ?? queue,
|
||||
paperName: paper,
|
||||
driverMediaType: workflow.print.selectedMediaType,
|
||||
inkSet: ink,
|
||||
colourSpace: workflow.colourSpace.rawValue,
|
||||
presetID: presetID,
|
||||
calibrationURL: calPath.isEmpty ? nil : calPath,
|
||||
applyCalibration: saveMediaApplyCal && calApplyable,
|
||||
created: now,
|
||||
updated: now
|
||||
)
|
||||
|
||||
do {
|
||||
let validated = try recipe.validated()
|
||||
try await store.upsert(validated)
|
||||
await reloadAsync()
|
||||
selectedRecipeID = validated.id
|
||||
workflow.wizard.showNotice("Media recipe saved: \(validated.name)")
|
||||
return true
|
||||
} catch let error as MediaLibraryStore.MediaLibraryError {
|
||||
saveMediaError = error.errorDescription
|
||||
return false
|
||||
} catch {
|
||||
saveMediaError = "Could not save: \(error.localizedDescription)"
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Delete
|
||||
|
||||
func delete(_ recipe: MediaRecipe) {
|
||||
Task {
|
||||
do {
|
||||
try await store.delete(id: recipe.id)
|
||||
await reloadAsync()
|
||||
if selectedRecipeID == recipe.id {
|
||||
selectedRecipeID = "none"
|
||||
}
|
||||
} catch {
|
||||
workflow.wizard.showNotice(
|
||||
"Could not delete: \(error.localizedDescription)",
|
||||
kind: .error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Staleness
|
||||
|
||||
func refreshStaleness() {
|
||||
Task { await refreshStalenessAsync() }
|
||||
}
|
||||
|
||||
/// Recomputes `staleReasons` + `calAgeDays` for every recipe.
|
||||
///
|
||||
/// `.printer` fires only when `printerID` is absent from a
|
||||
/// **non-empty** enumerated queue list — an un-enumerated list is
|
||||
/// indeterminate, not stale (decision 5). `.calibration` is a pure
|
||||
/// age check (`isStale()` with no `comparedTo:`) — the `.cal`
|
||||
/// DESCRIPTOR is free text, not a queue id.
|
||||
func refreshStalenessAsync() async {
|
||||
let staleDays = environment.settingsStore.load().calibrationStaleDays
|
||||
let queues = workflow.print.printers
|
||||
let now = Date()
|
||||
let calStore = CalibrationStore(staleDays: staleDays)
|
||||
|
||||
var reasons: [String: StaleReason] = [:]
|
||||
var ages: [String: Int] = [:]
|
||||
for r in recipes {
|
||||
var flags: StaleReason = []
|
||||
if !queues.isEmpty, !queues.contains(where: { $0.name == r.printerID }) {
|
||||
flags.insert(.printer)
|
||||
}
|
||||
if let raw = r.calibrationURL?.trimmingCharacters(in: .whitespaces),
|
||||
!raw.isEmpty,
|
||||
FileManager.default.fileExists(atPath: raw),
|
||||
(try? await calStore.load(url: URL(fileURLWithPath: raw))) != nil,
|
||||
let created = await calStore.data?.created {
|
||||
ages[r.id] = Calendar.current
|
||||
.dateComponents([.day], from: created, to: now).day ?? 0
|
||||
if await calStore.isStale() {
|
||||
flags.insert(.calibration)
|
||||
}
|
||||
}
|
||||
if !flags.isEmpty { reasons[r.id] = flags }
|
||||
}
|
||||
staleReasons = reasons
|
||||
calAgeDays = ages
|
||||
}
|
||||
|
||||
// MARK: - Manage sheet flow
|
||||
|
||||
/// Called from the manage sheet's `onDismiss`. A deferred capture
|
||||
/// request opens the save sheet only now, after the manage sheet has
|
||||
/// fully dismissed.
|
||||
func manageDismissed() {
|
||||
if captureAfterManageDismiss {
|
||||
captureAfterManageDismiss = false
|
||||
beginCapture()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,24 +24,41 @@ final class PrintSessionViewModel: ObservableObject {
|
||||
self.environment = environment
|
||||
}
|
||||
|
||||
private var printerEnumTask: Task<[Printer]?, Never>?
|
||||
|
||||
func refreshPrinters() {
|
||||
let cups = environment.cupsService
|
||||
Task { @MainActor in
|
||||
Task { @MainActor in _ = await enumeratePrinters() }
|
||||
}
|
||||
|
||||
/// Serialized queue enumeration — `listPrinters` uses fixed process
|
||||
/// ids, so overlapping calls would throw `duplicateID`. Concurrent
|
||||
/// callers coalesce onto the in-flight task (#146).
|
||||
@discardableResult
|
||||
func enumeratePrinters() async -> [Printer]? {
|
||||
if let pending = printerEnumTask { return await pending.value }
|
||||
let task = Task { @MainActor [weak self] () -> [Printer]? in
|
||||
guard let self else { return nil }
|
||||
do {
|
||||
let list = try await cups.listPrinters()
|
||||
printers = list
|
||||
if !list.contains(where: { $0.name == selectedPrinter }) {
|
||||
selectedPrinter = list.first { $0.isDefault }?.name
|
||||
let list = try await self.environment.cupsService.listPrinters()
|
||||
self.printers = list
|
||||
if !list.contains(where: { $0.name == self.selectedPrinter }) {
|
||||
self.selectedPrinter = list.first { $0.isDefault }?.name
|
||||
?? list.first?.name ?? ""
|
||||
}
|
||||
await reloadSelectedCapabilities()
|
||||
await self.reloadSelectedCapabilities()
|
||||
return list
|
||||
} catch {
|
||||
printNotice = Notice(
|
||||
self.printNotice = Notice(
|
||||
kind: .error,
|
||||
text: "Could not list printers: \(error.localizedDescription)"
|
||||
)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
printerEnumTask = task
|
||||
let result = await task.value
|
||||
printerEnumTask = nil
|
||||
return result
|
||||
}
|
||||
|
||||
func reloadSelectedCapabilities() async {
|
||||
|
||||
@@ -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 2–5.
|
||||
workflow.wizard.windowDidBecomeKey()
|
||||
recomputeDerived()
|
||||
}
|
||||
|
||||
// MARK: - Open
|
||||
|
||||
/// ⌘O / `btnProjectOpen` — `selectProjectFile` (`.icceryproj`
|
||||
/// only). Cancel is a no-op.
|
||||
func requestOpen() {
|
||||
guard !childSessionLive else {
|
||||
workflow.wizard.showNotice(
|
||||
"Finish the instrument session before opening a project.",
|
||||
kind: .warning)
|
||||
return
|
||||
}
|
||||
let url = UITestHooks.isEnabled
|
||||
? UITestHooks.projectOpenURL
|
||||
: fileDialogs.selectProjectFile()
|
||||
guard let url else { return }
|
||||
if isDirty {
|
||||
pendingAction = .open(url)
|
||||
showingDirtyAlert = true
|
||||
return
|
||||
}
|
||||
open(url)
|
||||
}
|
||||
|
||||
/// Open from the recents submenu — same path, no open panel. A
|
||||
/// missing file is dropped with a banner; no relocate is offered.
|
||||
func openRecent(_ entry: RecentProjectEntry) {
|
||||
guard !childSessionLive else {
|
||||
workflow.wizard.showNotice(
|
||||
"Finish the instrument session before opening a project.",
|
||||
kind: .warning)
|
||||
return
|
||||
}
|
||||
let url = URL(fileURLWithPath: entry.path)
|
||||
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||
Task { try? await recentsStore.remove(path: entry.path) }
|
||||
refreshRecents()
|
||||
workflow.wizard.showNotice("Project file is gone.", kind: .warning)
|
||||
return
|
||||
}
|
||||
if isDirty {
|
||||
pendingAction = .open(url)
|
||||
showingDirtyAlert = true
|
||||
return
|
||||
}
|
||||
open(url)
|
||||
}
|
||||
|
||||
/// Shared open path — validates, routes to relocate when `cwd` is
|
||||
/// gone, then applies. Failures leave live state untouched.
|
||||
func open(_ url: URL) {
|
||||
guard let loaded = loadProject(url) else { return }
|
||||
apply(loaded, from: url)
|
||||
}
|
||||
|
||||
/// Awaitable open for tests — identical to `open` but completes
|
||||
/// after apply finishes.
|
||||
func openAsync(_ url: URL) async {
|
||||
guard let loaded = loadProject(url) else { return }
|
||||
await applyAsync(loaded, from: url)
|
||||
}
|
||||
|
||||
/// Decode + validate + cwd-existence check. Returns the project
|
||||
/// ready to apply, or nil after presenting an error banner / the
|
||||
/// relocate sheet.
|
||||
private func loadProject(_ url: URL) -> ICCeryProject? {
|
||||
let loaded: ICCeryProject
|
||||
do {
|
||||
loaded = try ICCeryProject.load(from: url)
|
||||
} catch let error as ICCeryProject.ValidationError {
|
||||
workflow.wizard.showNotice(
|
||||
error.errorDescription ?? "Could not open project.",
|
||||
kind: .error)
|
||||
return nil
|
||||
} catch {
|
||||
workflow.wizard.showNotice(
|
||||
"Could not open project: \(error.localizedDescription)",
|
||||
kind: .error)
|
||||
return nil
|
||||
}
|
||||
var isDir: ObjCBool = false
|
||||
guard FileManager.default.fileExists(
|
||||
atPath: loaded.cwd, isDirectory: &isDir), isDir.boolValue
|
||||
else {
|
||||
pendingRelocate = (loaded, url)
|
||||
showingRelocateSheet = true
|
||||
return nil
|
||||
}
|
||||
return loaded
|
||||
}
|
||||
|
||||
/// Sync entry — the apply half runs in a Task so `media.apply`
|
||||
/// (async) can run inside it.
|
||||
private func apply(_ project: ICCeryProject, from url: URL) {
|
||||
Task { @MainActor [weak self] in
|
||||
await self?.applyAsync(project, from: url)
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply order (issue #149):
|
||||
/// 1. cwd + basename on the wizard **and** Stage 1 fields.
|
||||
/// 2. `mediaRecipeID` → `MediaLibraryViewModel.apply`; else
|
||||
/// `presetID` → existing preset apply. Missing recipe is not
|
||||
/// fatal — open still succeeds.
|
||||
/// 3. Re-probe via the existing window-focus path (#151).
|
||||
/// 4. Never auto-run `targen` / `colprof` / `chartread`.
|
||||
private func applyAsync(_ project: ICCeryProject, from url: URL) async {
|
||||
let cwdURL = URL(fileURLWithPath: project.cwd, isDirectory: true)
|
||||
|
||||
workflow.wizard.setTarget(
|
||||
basename: project.basename, workingDirectory: cwdURL)
|
||||
workflow.targetBasename = project.basename
|
||||
workflow.targetDirectory = cwdURL
|
||||
workflow.wizard.printerName = project.printerDisplayName
|
||||
?? project.printerID
|
||||
workflow.wizard.profileBasename = project.profileBasename
|
||||
workflow.wizard.sessionMode = .profile
|
||||
if let queue = project.printerID,
|
||||
workflow.print.printers.contains(where: { $0.name == queue }) {
|
||||
workflow.print.selectedPrinter = queue
|
||||
}
|
||||
|
||||
var appliedRecipe = false
|
||||
if let recipeID = project.mediaRecipeID {
|
||||
if workflow.media.recipes.isEmpty {
|
||||
await workflow.media.reloadAsync()
|
||||
}
|
||||
if let recipe = workflow.media.recipes
|
||||
.first(where: { $0.id == recipeID }) {
|
||||
appliedRecipe = await workflow.media.apply(recipe)
|
||||
}
|
||||
}
|
||||
if !appliedRecipe, let presetID = project.presetID,
|
||||
let preset = workflow.presets
|
||||
.first(where: { $0.id == presetID }) {
|
||||
workflow.applyPreset(preset)
|
||||
}
|
||||
|
||||
// Disk owns the stepper — same probe window focus uses.
|
||||
workflow.wizard.windowDidBecomeKey()
|
||||
|
||||
// JSON-vs-disk mismatch: notes say the profile is done but the
|
||||
// artefacts stop earlier — index drift, not gating truth.
|
||||
let artefacts = workflow.wizard.artefacts
|
||||
if project.lastVerification != nil && !artefacts.stage4Complete {
|
||||
let already = diskBehindNotes && projectURL == url
|
||||
diskBehindNotes = true
|
||||
if !already {
|
||||
let last = artefacts.stage3Complete ? ".ti3"
|
||||
: artefacts.stage2Complete ? ".ti2"
|
||||
: artefacts.stage1Complete ? ".ti1" : nil
|
||||
let detail = last.map { "artefacts on disk stop at \($0)." }
|
||||
?? "no artefacts found on disk."
|
||||
workflow.wizard.showNotice(
|
||||
"Project notes say profile done; \(detail)",
|
||||
kind: .info)
|
||||
}
|
||||
} else {
|
||||
diskBehindNotes = false
|
||||
}
|
||||
|
||||
self.project = project
|
||||
projectURL = url
|
||||
pushRecent(url: url, name: project.name)
|
||||
recomputeDerived()
|
||||
}
|
||||
|
||||
// MARK: - Relocate
|
||||
|
||||
/// `btnProjectRelocate` — pick a new cwd, rewrite the project file
|
||||
/// atomically, then continue Apply.
|
||||
func chooseRelocateFolder() {
|
||||
guard let pending = pendingRelocate else { return }
|
||||
let url = UITestHooks.isEnabled
|
||||
? UITestHooks.projectRelocateURL
|
||||
: fileDialogs.selectDirectory()
|
||||
guard let url else { return }
|
||||
var rewritten = pending.project
|
||||
rewritten.cwd = url.path
|
||||
rewritten.updated = Date()
|
||||
do {
|
||||
try rewritten.save(to: pending.url)
|
||||
} catch {
|
||||
workflow.wizard.showNotice(
|
||||
"Could not update project: \(error.localizedDescription)",
|
||||
kind: .error)
|
||||
return
|
||||
}
|
||||
pendingRelocate = nil
|
||||
showingRelocateSheet = false
|
||||
apply(rewritten, from: pending.url)
|
||||
}
|
||||
|
||||
/// `btnProjectRelocateCancel` — aborts the open; live session
|
||||
/// unchanged.
|
||||
func cancelRelocate() {
|
||||
pendingRelocate = nil
|
||||
showingRelocateSheet = false
|
||||
}
|
||||
|
||||
// MARK: - Save / Save As
|
||||
|
||||
/// ⌘S / `btnProjectSave` — writes the live session into the bound
|
||||
/// URL. A live `CAL_` stem refuses unless the persisted original
|
||||
/// resolves; `CAL_` is never persisted (R11).
|
||||
func saveProject() {
|
||||
Task { @MainActor in _ = await saveProjectAsync() }
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func saveProjectAsync() async -> Bool {
|
||||
guard let url = projectURL, project != nil else { return false }
|
||||
guard let basename = resolvedBasename else {
|
||||
refuseUnsavable()
|
||||
return false
|
||||
}
|
||||
return await write(to: url, basename: basename)
|
||||
}
|
||||
|
||||
/// ⇧⌘S / `menuProjectSaveAs` — always shows the save picker, then
|
||||
/// binds the chosen URL and pushes recents.
|
||||
func saveProjectAs() {
|
||||
guard let basename = resolvedBasename else {
|
||||
refuseUnsavable()
|
||||
return
|
||||
}
|
||||
guard let cwd = workflow.wizard.workingDirectory else {
|
||||
refuseUnsavable()
|
||||
return
|
||||
}
|
||||
let url = UITestHooks.isEnabled
|
||||
? UITestHooks.projectSaveURL
|
||||
: fileDialogs.selectProjectSavePath(
|
||||
basename: basename, startingAt: cwd)
|
||||
guard let url else { return }
|
||||
Task { @MainActor in _ = await write(to: url, basename: basename) }
|
||||
}
|
||||
|
||||
private func refuseUnsavable() {
|
||||
if CalibrationIdentity.isCalibration(workflow.wizard.basename) {
|
||||
workflow.wizard.showNotice(
|
||||
"Finish or exit calibration before saving a project.",
|
||||
kind: .warning)
|
||||
} else {
|
||||
workflow.wizard.showNotice(
|
||||
"Set a target basename and working folder before saving a project.",
|
||||
kind: .warning)
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the project payload from live session fields and writes
|
||||
/// it atomically. On success binds `url`, refreshes recents, and
|
||||
/// clears the stale-chip flag. Failure → error banner, bound URL
|
||||
/// unchanged.
|
||||
@discardableResult
|
||||
private func write(to url: URL, basename: String) async -> Bool {
|
||||
guard let cwd = workflow.wizard.workingDirectory,
|
||||
!cwd.path.isEmpty else {
|
||||
refuseUnsavable()
|
||||
return false
|
||||
}
|
||||
let stem = workflow.wizard.profileBasename ?? basename
|
||||
let snapshot = await lastVerificationSnapshot(stem: stem)
|
||||
?? project?.lastVerification
|
||||
let queue = workflow.print.selectedPrinter
|
||||
let display = workflow.print.printers
|
||||
.first { $0.name == queue }?.displayName
|
||||
?? workflow.wizard.printerName
|
||||
let existing = project
|
||||
let name = (existing?.name.isEmpty == false)
|
||||
? existing!.name : basename
|
||||
let updated = ICCeryProject(
|
||||
name: name,
|
||||
notes: existing?.notes ?? "",
|
||||
basename: basename,
|
||||
cwd: cwd.path,
|
||||
profileBasename: workflow.wizard.profileBasename,
|
||||
printerID: queue.isEmpty ? nil : queue,
|
||||
printerDisplayName: display,
|
||||
mediaRecipeID: workflow.media.selectedRecipeID == "none"
|
||||
? nil : workflow.media.selectedRecipeID,
|
||||
presetID: workflow.selectedPresetID == "none"
|
||||
? nil : workflow.selectedPresetID,
|
||||
calibrationURL: workflow.profile.calibrationFile.isEmpty
|
||||
? nil : workflow.profile.calibrationFile,
|
||||
lastVerification: snapshot,
|
||||
updated: Date())
|
||||
do {
|
||||
try updated.save(to: url)
|
||||
} catch {
|
||||
workflow.wizard.showNotice(
|
||||
"Could not save project: \(error.localizedDescription)",
|
||||
kind: .error)
|
||||
return false
|
||||
}
|
||||
project = updated
|
||||
projectURL = url
|
||||
diskBehindNotes = false
|
||||
pushRecent(url: url, name: updated.name)
|
||||
workflow.wizard.showNotice("Project saved: \(url.lastPathComponent)")
|
||||
recomputeDerived()
|
||||
return true
|
||||
}
|
||||
|
||||
/// Last `VerificationHistoryStore` record for this profile stem,
|
||||
/// if any — notes only.
|
||||
private func lastVerificationSnapshot(
|
||||
stem: String
|
||||
) async -> VerificationSnapshot? {
|
||||
guard let records = try? await environment.historyStore.load()
|
||||
else { return nil }
|
||||
guard let record = records.last(where: {
|
||||
$0.profileName == stem || $0.profileName == workflow.wizard.basename
|
||||
}) else { return nil }
|
||||
return VerificationSnapshot(record: record)
|
||||
}
|
||||
|
||||
// MARK: - Close
|
||||
|
||||
/// `menuProjectClose` — unbinds; live basename/cwd/artefacts stay.
|
||||
func requestClose() {
|
||||
guard isBound else { return }
|
||||
if isDirty {
|
||||
pendingAction = .close
|
||||
showingDirtyAlert = true
|
||||
return
|
||||
}
|
||||
closeProject()
|
||||
}
|
||||
|
||||
func closeProject() {
|
||||
projectURL = nil
|
||||
project = nil
|
||||
diskBehindNotes = false
|
||||
recomputeDerived()
|
||||
}
|
||||
|
||||
// MARK: - Dirty alert
|
||||
|
||||
/// `btnProjectDirtySave` / `btnProjectDirtyDiscard`. Save proceeds
|
||||
/// to the pending action only when the write succeeded.
|
||||
func resolveDirty(save: Bool) {
|
||||
showingDirtyAlert = false
|
||||
guard let action = pendingAction else { return }
|
||||
if save {
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
if await self.saveProjectAsync() {
|
||||
self.pendingAction = nil
|
||||
self.proceed(with: action)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
pendingAction = nil
|
||||
proceed(with: action)
|
||||
}
|
||||
}
|
||||
|
||||
/// `btnProjectDirtyCancel` — abandons the pending action.
|
||||
func cancelDirty() {
|
||||
pendingAction = nil
|
||||
showingDirtyAlert = false
|
||||
}
|
||||
|
||||
private func proceed(with action: PendingAction) {
|
||||
switch action {
|
||||
case .new:
|
||||
confirmNew()
|
||||
case .open(let url):
|
||||
open(url)
|
||||
case .close:
|
||||
closeProject()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Report
|
||||
|
||||
/// `menuProjectReport` — writes `{cwd}/{basename}-report.md`
|
||||
/// atomically (generated; overwrites). No picker.
|
||||
func saveReport() {
|
||||
guard canReport, let cwd = workflow.wizard.workingDirectory else {
|
||||
return
|
||||
}
|
||||
let basename = workflow.wizard.basename
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
let stem = self.workflow.wizard.profileBasename ?? basename
|
||||
let snapshot = await self.lastVerificationSnapshot(stem: stem)
|
||||
?? self.project?.lastVerification
|
||||
let queue = self.workflow.print.selectedPrinter
|
||||
let display = self.workflow.print.printers
|
||||
.first { $0.name == queue }?.displayName
|
||||
?? self.workflow.wizard.printerName
|
||||
let recipeName = self.workflow.media.recipes
|
||||
.first { $0.id == self.workflow.media.selectedRecipeID }?.name
|
||||
let payload = ICCeryProject(
|
||||
name: self.project?.name.isEmpty == false
|
||||
? self.project!.name : basename,
|
||||
notes: self.project?.notes ?? "",
|
||||
basename: basename,
|
||||
cwd: cwd.path,
|
||||
profileBasename: self.workflow.wizard.profileBasename,
|
||||
printerID: queue.isEmpty ? nil : queue,
|
||||
printerDisplayName: display,
|
||||
mediaRecipeID: self.workflow.media.selectedRecipeID == "none"
|
||||
? nil : self.workflow.media.selectedRecipeID,
|
||||
presetID: self.workflow.selectedPresetID == "none"
|
||||
? nil : self.workflow.selectedPresetID,
|
||||
calibrationURL: self.workflow.profile.calibrationFile.isEmpty
|
||||
? nil : self.workflow.profile.calibrationFile,
|
||||
lastVerification: snapshot,
|
||||
updated: Date())
|
||||
do {
|
||||
let written = try ProjectReport.write(
|
||||
project: payload,
|
||||
recipeName: recipeName,
|
||||
artefacts: self.workflow.wizard.artefacts)
|
||||
self.workflow.wizard.showNotice(
|
||||
"Wrote \(written.lastPathComponent)")
|
||||
} catch {
|
||||
self.workflow.wizard.showNotice(
|
||||
"Report failed: \(error.localizedDescription)",
|
||||
kind: .error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Recents / reveal
|
||||
|
||||
/// Rebuilds the recents list; entries whose file is gone are
|
||||
/// dropped here (submenu build), not at launch. Corrupt → `[]`,
|
||||
/// file kept (R12).
|
||||
func refreshRecents() {
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
self.recents = try await self.recentsStore.pruneMissing()
|
||||
} catch {
|
||||
self.recents = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func pushRecent(url: URL, name: String) {
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
try? await self.recentsStore.add(url: url, name: name)
|
||||
self.refreshRecents()
|
||||
}
|
||||
}
|
||||
|
||||
/// `menuProjectRecentsClear` — wipes `recent_projects.json` only;
|
||||
/// `.icceryproj` files are never deleted.
|
||||
func clearRecents() {
|
||||
Task { @MainActor [weak self] in
|
||||
try? await self?.recentsStore.clear()
|
||||
self?.recents = []
|
||||
}
|
||||
}
|
||||
|
||||
/// `btnProjectReveal` — reveals the bound **project file** in
|
||||
/// Finder, not the cwd.
|
||||
func revealInFinder() {
|
||||
guard let projectURL else { return }
|
||||
NSWorkspace.shared.activateFileViewerSelecting([projectURL])
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ struct RootView: View {
|
||||
/// Observed directly: nested ObservableObjects are not tracked
|
||||
/// through the parent's `objectWillChange`.
|
||||
@ObservedObject private var model: WizardViewModel
|
||||
@ObservedObject private var project: ProjectSession
|
||||
@State private var showingSettings = false
|
||||
@State private var showingAbout = false
|
||||
@State private var showingAllHelp = false
|
||||
@@ -16,6 +17,7 @@ struct RootView: View {
|
||||
init(workflow: TargetWorkflowViewModel) {
|
||||
self.workflow = workflow
|
||||
self._model = ObservedObject(wrappedValue: workflow.wizard)
|
||||
self._project = ObservedObject(wrappedValue: workflow.project)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -56,6 +58,25 @@ struct RootView: View {
|
||||
.sheet(isPresented: $workflow.showingManagePresets) {
|
||||
ManagePresetsDialog(workflow: workflow)
|
||||
}
|
||||
// Media library sheets live on RootView, never inside the
|
||||
// 270 pt sidebar column (#146).
|
||||
.sheet(isPresented: $workflow.showingSaveMedia) {
|
||||
SaveMediaRecipeDialog(workflow: workflow)
|
||||
}
|
||||
.sheet(
|
||||
isPresented: $workflow.showingManageMedia,
|
||||
onDismiss: { workflow.media.manageDismissed() }
|
||||
) {
|
||||
ManageMediaDialog(workflow: workflow)
|
||||
}
|
||||
// Spot-read console sheet (issue #148). Dismiss runs the same
|
||||
// `q\n` + ~500 ms + kill path as the sheet's Stop button.
|
||||
.sheet(
|
||||
isPresented: $workflow.showingSpotRead,
|
||||
onDismiss: { workflow.spotRead.sheetClosed() }
|
||||
) {
|
||||
SpotReadView(model: workflow.spotRead)
|
||||
}
|
||||
.sheet(isPresented: $showingAbout) {
|
||||
AboutView { showingAbout = false }
|
||||
}
|
||||
@@ -63,7 +84,41 @@ struct RootView: View {
|
||||
get: { workflow.wizard.showingGamutViewer },
|
||||
set: { workflow.wizard.showingGamutViewer = $0 }
|
||||
)) {
|
||||
GamutView(profileGamURL: workflow.wizard.gamutProfileURL)
|
||||
GamutView(
|
||||
environment: workflow.environment,
|
||||
profileGamURL: workflow.wizard.gamutProfileURL,
|
||||
showingAllHelp: $showingAllHelp)
|
||||
}
|
||||
// Project file chrome (issue #149): window title, New confirm
|
||||
// alert, dirty alert, relocate sheet. Panels never appear from
|
||||
// a View — UITestHooks inject fixture paths instead.
|
||||
.onReceive(project.$windowTitle) { title in
|
||||
for window in NSApp.windows where !(window is NSPanel) {
|
||||
window.title = title
|
||||
}
|
||||
}
|
||||
.alert("Start a new project?", isPresented: $project.showingNewAlert) {
|
||||
Button("Cancel", role: .cancel) {}
|
||||
.accessibilityIdentifier("btnProjectNewCancel")
|
||||
Button("Start") { project.confirmNew() }
|
||||
.accessibilityIdentifier("btnProjectNewConfirm")
|
||||
} message: {
|
||||
Text("The working folder and targets on disk are not deleted.")
|
||||
.accessibilityIdentifier("projectNewAlert")
|
||||
}
|
||||
.alert(
|
||||
"Save the current project first?",
|
||||
isPresented: $project.showingDirtyAlert
|
||||
) {
|
||||
Button("Save") { project.resolveDirty(save: true) }
|
||||
.accessibilityIdentifier("btnProjectDirtySave")
|
||||
Button("Don't Save") { project.resolveDirty(save: false) }
|
||||
.accessibilityIdentifier("btnProjectDirtyDiscard")
|
||||
Button("Cancel", role: .cancel) { project.cancelDirty() }
|
||||
.accessibilityIdentifier("btnProjectDirtyCancel")
|
||||
}
|
||||
.sheet(isPresented: $project.showingRelocateSheet) {
|
||||
ProjectRelocateSheet(project: project)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ struct SettingsView: View {
|
||||
Text($0.label).tag($0.code)
|
||||
}
|
||||
}
|
||||
Text("Display-only — Stage 2's instrument select is used for actual runs.")
|
||||
Text("Seeds Spot Read and Stage 3 when the instrument is plugged in. printtarg -i is still chosen on Stage 2.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
|
||||
@@ -9,6 +9,10 @@ struct SidebarView: View {
|
||||
/// through the parent's `objectWillChange`.
|
||||
@ObservedObject private var model: WizardViewModel
|
||||
@ObservedObject private var profile: ProfileWorkflowViewModel
|
||||
@ObservedObject private var media: MediaLibraryViewModel
|
||||
@ObservedObject private var printSession: PrintSessionViewModel
|
||||
@ObservedObject private var measurement: MeasurementWorkflowViewModel
|
||||
@ObservedObject private var project: ProjectSession
|
||||
var onOpenSettings: () -> Void
|
||||
var onOpenAbout: () -> Void
|
||||
@Binding var showingAllHelp: Bool
|
||||
@@ -22,6 +26,10 @@ struct SidebarView: View {
|
||||
self.workflow = workflow
|
||||
self._model = ObservedObject(wrappedValue: workflow.wizard)
|
||||
self._profile = ObservedObject(wrappedValue: workflow.profile)
|
||||
self._media = ObservedObject(wrappedValue: workflow.media)
|
||||
self._printSession = ObservedObject(wrappedValue: workflow.print)
|
||||
self._measurement = ObservedObject(wrappedValue: workflow.measurement)
|
||||
self._project = ObservedObject(wrappedValue: workflow.project)
|
||||
self.onOpenSettings = onOpenSettings
|
||||
self.onOpenAbout = onOpenAbout
|
||||
self._showingAllHelp = showingAllHelp
|
||||
@@ -90,6 +98,56 @@ struct SidebarView: View {
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
// Media library (`#mediaSelect`) — issue #146. Selection
|
||||
// applies the recipe immediately, like presets; names render
|
||||
// via Text only (#114). Never reuses `presetSelect` (#137).
|
||||
Picker("Media", selection: Binding(
|
||||
get: { media.selectedRecipeID },
|
||||
set: { media.selectRecipe($0) }
|
||||
)) {
|
||||
Text("No media recipe").tag("none")
|
||||
ForEach(media.recipes) { recipe in
|
||||
Text(recipe.name).tag(recipe.id)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.accessibilityIdentifier("mediaSelect")
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.helpOverlay(
|
||||
"Saved printer + paper + ink + .cal bound to a preset.",
|
||||
showing: $showingAllHelp)
|
||||
|
||||
if let reasons = media.staleReasons[media.selectedRecipeID],
|
||||
!reasons.isEmpty {
|
||||
Text(reasons.contains(.printer)
|
||||
? "Printer not installed" : "Calibration stale")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
.padding(.horizontal, 12)
|
||||
.accessibilityIdentifier("mediaRecipeStale")
|
||||
.helpOverlay(
|
||||
"Re-run Stage 0 or pick a different recipe.",
|
||||
showing: $showingAllHelp)
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Button("Capture") { media.beginCapture() }
|
||||
.disabled(printSession.selectedPrinter.isEmpty)
|
||||
.accessibilityIdentifier("btnMediaLibraryCapture")
|
||||
.helpOverlay(
|
||||
"Select a printer in Stage 2 first",
|
||||
showing: $showingAllHelp)
|
||||
Button("Manage") { workflow.showingManageMedia = true }
|
||||
.accessibilityIdentifier("btnMediaLibraryManage")
|
||||
.helpOverlay(
|
||||
"Apply or delete saved media recipes.",
|
||||
showing: $showingAllHelp)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
// Calibrate Printer (`#btnCalibratePrinter`).
|
||||
Button(action: { model.enterCalibration() }) {
|
||||
Label("Calibrate Printer", systemImage: "slider.horizontal.3")
|
||||
@@ -104,9 +162,32 @@ struct SidebarView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.controlSize(.large)
|
||||
.helpOverlay(
|
||||
"View the profile gamut in 3D against sRGB.",
|
||||
showing: $showingAllHelp)
|
||||
.accessibilityIdentifier("btnViewGamut")
|
||||
.padding(.horizontal, 12)
|
||||
|
||||
// Spot Read sheet (`#btnSpotRead`) — issue #148. Enabled
|
||||
// only with a working folder (#59) and while no Stage 3
|
||||
// chartread child is live; opening never kills
|
||||
// `chartread_{basename}`.
|
||||
Button(action: { workflow.showingSpotRead = true }) {
|
||||
Label("Spot Read", systemImage: "eyedropper")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.controlSize(.large)
|
||||
.disabled(model.workingDirectory == nil || measurement.isChartreadRunning)
|
||||
.helpOverlay(
|
||||
model.workingDirectory == nil
|
||||
? "Set a working folder in Stage 1 first."
|
||||
: (measurement.isChartreadRunning
|
||||
? "Stop the Stage 3 chart read first."
|
||||
: "Read a single patch as Lab/XYZ from the instrument."),
|
||||
showing: $showingAllHelp)
|
||||
.accessibilityIdentifier("btnSpotRead")
|
||||
.padding(.horizontal, 12)
|
||||
|
||||
Divider().overlay(Theme.border)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
@@ -126,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)
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
import SwiftUI
|
||||
import ICCeryCore
|
||||
|
||||
/// Spot Read sheet (issue #148) — one patch Lab/XYZ from the live
|
||||
/// instrument. A `RootView` sheet, not a wizard stage and not a Stage 3
|
||||
/// tab; all identifiers are `spot*` — Stage 3 `chartread` ids are never
|
||||
/// reused here.
|
||||
struct SpotReadView: View {
|
||||
@ObservedObject var model: SpotReadViewModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
header
|
||||
if !model.sidecarAvailable {
|
||||
missingSidecar
|
||||
} else {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
instrumentCard
|
||||
promptLine
|
||||
transport
|
||||
lastSampleCard
|
||||
historySection
|
||||
}
|
||||
}
|
||||
}
|
||||
footer
|
||||
}
|
||||
.padding(16)
|
||||
.frame(width: 560, height: 640)
|
||||
.background(Theme.background)
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier("spotReadView")
|
||||
.onAppear { model.sheetOpened() }
|
||||
.onDisappear { model.sheetClosed() }
|
||||
}
|
||||
|
||||
// MARK: - Header / missing sidecar
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text("Spot Read")
|
||||
.font(.title3)
|
||||
.foregroundStyle(Theme.text)
|
||||
Spacer()
|
||||
if model.isRunning {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var missingSidecar: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("spotread sidecar missing — run fetch-argyll")
|
||||
.foregroundStyle(Theme.text)
|
||||
.accessibilityIdentifier("spotSidecarMissing")
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Instrument card (clones Stage 3 look, own ids)
|
||||
|
||||
private var instrumentCard: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Text("Instrument")
|
||||
.font(.headline)
|
||||
.foregroundStyle(Theme.text)
|
||||
Spacer()
|
||||
Button(action: { model.detectInstruments() }) {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
.disabled(!model.canDetect)
|
||||
.accessibilityIdentifier("btnSpotDetectInstruments")
|
||||
}
|
||||
|
||||
if let error = model.detectionError {
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
.accessibilityIdentifier("spotDetectError")
|
||||
}
|
||||
|
||||
Picker("Instrument", selection: Binding(
|
||||
get: { instrumentTag },
|
||||
set: { newTag in
|
||||
if newTag.isEmpty {
|
||||
model.selectedInstrument = .auto
|
||||
} else if let device = model.instruments.first(where: { "\($0.port)" == newTag }) {
|
||||
model.selectedInstrument = .device(device)
|
||||
}
|
||||
}
|
||||
)) {
|
||||
Text("Auto (first available port)").tag("")
|
||||
ForEach(model.instruments) { device in
|
||||
Text(device.displayName).tag("\(device.port)")
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.disabled(model.isRunning)
|
||||
.accessibilityIdentifier("spotInstrumentSelect")
|
||||
|
||||
if model.defaultMissing {
|
||||
Text("Saved default instrument not present")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
.accessibilityIdentifier("spotDefaultMissing")
|
||||
}
|
||||
|
||||
Toggle("Also set as default instrument", isOn: Binding(
|
||||
get: { model.setAsDefault },
|
||||
set: { model.applyDefaultToggle($0) }
|
||||
))
|
||||
.accessibilityIdentifier("spotSetDefault")
|
||||
|
||||
if model.selectedInstrument.isXY {
|
||||
Text("XY tables use Stage 3. Spot Read is a handheld / reflective probe.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.accent)
|
||||
.accessibilityIdentifier("spotXYHint")
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(Theme.panel)
|
||||
}
|
||||
|
||||
private var instrumentTag: String {
|
||||
switch model.selectedInstrument {
|
||||
case .auto:
|
||||
return ""
|
||||
case .device(let device):
|
||||
return "\(device.port)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Prompt line
|
||||
|
||||
private var promptLine: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text("Status")
|
||||
.font(.headline)
|
||||
.foregroundStyle(Theme.text)
|
||||
Spacer()
|
||||
Text(model.prompt)
|
||||
.font(.callout)
|
||||
.foregroundStyle(Theme.text)
|
||||
.accessibilityIdentifier("spotPrompt")
|
||||
}
|
||||
if let error = model.lastError {
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
.accessibilityIdentifier("spotLastError")
|
||||
.accessibilityValue(error)
|
||||
}
|
||||
if !model.log.isEmpty {
|
||||
ProcessLogView(
|
||||
lines: model.log,
|
||||
minHeight: 60,
|
||||
maxHeight: 100,
|
||||
containerId: "spotLogContainer",
|
||||
logId: "spotLog"
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(Theme.panel)
|
||||
}
|
||||
|
||||
// MARK: - Transport
|
||||
|
||||
private var transport: some View {
|
||||
HStack(spacing: 12) {
|
||||
if !model.isRunning {
|
||||
Button("Start") { model.start() }
|
||||
.disabled(!model.canStart)
|
||||
.accessibilityIdentifier("btnSpotStart")
|
||||
} else {
|
||||
switch model.state {
|
||||
case .calibrating:
|
||||
Button("Calibrate") { model.calibrate() }
|
||||
.accessibilityIdentifier("btnSpotCalibrate")
|
||||
case .awaitingStrip:
|
||||
Button("Read") { model.trigger() }
|
||||
.accessibilityIdentifier("btnSpotTrigger")
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
Button("Stop") { model.stopIfNeeded() }
|
||||
.accessibilityIdentifier("btnSpotStop")
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
|
||||
// MARK: - Last sample
|
||||
|
||||
@ViewBuilder
|
||||
private var lastSampleCard: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Last sample")
|
||||
.font(.headline)
|
||||
.foregroundStyle(Theme.text)
|
||||
|
||||
if let sample = model.displayedSample {
|
||||
HStack(spacing: 16) {
|
||||
let rgb = LabColorMath.labToSRGB(sample.lab)
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color(red: rgb.r, green: rgb.g, blue: rgb.b))
|
||||
.frame(width: 32, height: 32)
|
||||
.overlay(RoundedRectangle(cornerRadius: 4).stroke(Theme.border))
|
||||
.accessibilityIdentifier("spotSwatch")
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 12) {
|
||||
Text(String(format: "L* %.1f", sample.lab.l))
|
||||
.accessibilityIdentifier("spotLabL")
|
||||
Text(String(format: "a* %.1f", sample.lab.a))
|
||||
.accessibilityIdentifier("spotLabA")
|
||||
Text(String(format: "b* %.1f", sample.lab.b))
|
||||
.accessibilityIdentifier("spotLabB")
|
||||
}
|
||||
.font(.callout)
|
||||
.foregroundStyle(Theme.text)
|
||||
|
||||
if let xyz = sample.xyz {
|
||||
Text(String(format: "XYZ %.2f %.2f %.2f", xyz.x, xyz.y, xyz.z))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityIdentifier("spotXYZ")
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text(sample.port.map { "\(sample.instrumentName) · port \($0)" }
|
||||
?? sample.instrumentName)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityIdentifier("spotLastInstrument")
|
||||
|
||||
if let de = model.displayedDeltaE {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(deltaEColor)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(String(format: "ΔE %.2f", de))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier("spotDeltaE")
|
||||
}
|
||||
}
|
||||
|
||||
if model.isDisplayedLabImplausible {
|
||||
Text("Implausible L*")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
.accessibilityIdentifier("spotLabImplausible")
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier("spotLastSample")
|
||||
} else {
|
||||
Text("No readings yet.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityIdentifier("spotLastEmpty")
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(Theme.panel)
|
||||
}
|
||||
|
||||
private var deltaEColor: Color {
|
||||
switch model.deltaEClassification {
|
||||
case .good, nil: return .green
|
||||
case .warning: return .orange
|
||||
case .bad: return .red
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - History
|
||||
|
||||
@ViewBuilder
|
||||
private var historySection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("History")
|
||||
.font(.headline)
|
||||
.foregroundStyle(Theme.text)
|
||||
|
||||
if model.samples.isEmpty {
|
||||
Text("No history.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityIdentifier("spotHistoryEmpty")
|
||||
} else {
|
||||
List {
|
||||
ForEach(Array(model.samples.enumerated()), id: \.element.id) { index, sample in
|
||||
historyRow(index: index, sample: sample)
|
||||
}
|
||||
}
|
||||
.frame(minHeight: 120)
|
||||
.accessibilityIdentifier("spotHistoryTable")
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(Theme.panel)
|
||||
}
|
||||
|
||||
private func historyRow(index: Int, sample: SpotReadSample) -> some View {
|
||||
let previous = index + 1 < model.samples.count ? model.samples[index + 1] : nil
|
||||
let deltaE = previous.map { ColorDifference.deltaE00($0.lab, sample.lab) }
|
||||
return Button(action: { model.selectFromHistory(sample) }) {
|
||||
HStack(spacing: 10) {
|
||||
Text(sample.timestamp, style: .time)
|
||||
.frame(width: 70, alignment: .leading)
|
||||
Text(String(format: "%.1f", sample.lab.l))
|
||||
.frame(width: 44, alignment: .trailing)
|
||||
Text(String(format: "%.1f", sample.lab.a))
|
||||
.frame(width: 44, alignment: .trailing)
|
||||
Text(String(format: "%.1f", sample.lab.b))
|
||||
.frame(width: 44, alignment: .trailing)
|
||||
Text(deltaE.map { String(format: "%.2f", $0) } ?? "")
|
||||
.frame(width: 44, alignment: .trailing)
|
||||
Text(sample.instrumentName)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.text)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("spotHistoryRow-\(sample.id.uuidString)")
|
||||
}
|
||||
|
||||
// MARK: - Footer
|
||||
|
||||
private var footer: some View {
|
||||
HStack(spacing: 12) {
|
||||
Button("Copy Lab") { model.copyLab() }
|
||||
.disabled(model.displayedSample == nil)
|
||||
.accessibilityIdentifier("btnSpotCopyLab")
|
||||
Button("Export CSV…") { model.exportCsv() }
|
||||
.disabled(model.samples.isEmpty)
|
||||
.accessibilityIdentifier("btnSpotExportCsv")
|
||||
Spacer()
|
||||
Button("Close") { dismiss() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
.accessibilityIdentifier("btnCloseSpotRead")
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
import AppKit
|
||||
import Combine
|
||||
import Foundation
|
||||
import ICCeryCore
|
||||
|
||||
/// Spot-read console state and interaction (issue #148).
|
||||
///
|
||||
/// Runs the bundled `spotread` sidecar under the single-lease process id
|
||||
/// `spotread`; Stage 3 `chartread` is untouched. `defaultInstrument`
|
||||
/// seeds the instrument picker on sheet open — it is never written into
|
||||
/// `printtarg -i` or `targen` argv (R15).
|
||||
@MainActor
|
||||
final class SpotReadViewModel: ObservableObject {
|
||||
|
||||
let workflow: TargetWorkflowViewModel
|
||||
let environment: AppEnvironment
|
||||
private let fileDialogs = FileDialogService.shared
|
||||
|
||||
// MARK: - Instrument card
|
||||
|
||||
@Published var instruments: [InstrumentDevice] = []
|
||||
@Published var selectedInstrument: InstrumentSelection = .auto
|
||||
@Published var isDetecting = false
|
||||
@Published var detectionError: String?
|
||||
/// `spotDefaultMissing` — set when `defaultInstrument` is saved but
|
||||
/// no detected device matches it.
|
||||
@Published var defaultMissing = false
|
||||
/// `spotSetDefault` toggle state.
|
||||
@Published var setAsDefault = false
|
||||
|
||||
// MARK: - Session
|
||||
|
||||
@Published var isRunning = false
|
||||
@Published var state: ChartreadState = .idle
|
||||
@Published var prompt = "Press Start to open the instrument."
|
||||
@Published var lastError: String?
|
||||
@Published var log: [String] = []
|
||||
|
||||
// MARK: - Samples / history (in-memory, cap 50, newest first)
|
||||
|
||||
@Published private(set) var samples: [SpotReadSample] = []
|
||||
@Published private(set) var displayedSample: SpotReadSample?
|
||||
@Published private(set) var displayedDeltaE: Double?
|
||||
|
||||
private let historyLimit = 50
|
||||
private var streamTask: Task<Void, Never>?
|
||||
|
||||
init(workflow: TargetWorkflowViewModel, environment: AppEnvironment) {
|
||||
self.workflow = workflow
|
||||
self.environment = environment
|
||||
}
|
||||
|
||||
// MARK: - Derived state
|
||||
|
||||
/// Whether the bundled `spotread` sidecar resolves to an executable.
|
||||
/// `BinaryResolver` only — never `$PATH`, never `chartread`.
|
||||
var sidecarAvailable: Bool {
|
||||
let url = environment.runner.binaryResolver.resolve("spotread")
|
||||
return environment.runner.binaryResolver.exists(url)
|
||||
}
|
||||
|
||||
var isChartreadRunning: Bool { workflow.measurement.isChartreadRunning }
|
||||
|
||||
var canStart: Bool {
|
||||
sidecarAvailable && !isDetecting && !isRunning && !isChartreadRunning
|
||||
&& workflow.wizard.effectiveWorkingDirectory != nil
|
||||
}
|
||||
|
||||
var canDetect: Bool { !isDetecting && !isRunning }
|
||||
|
||||
var deltaEClassification: SwatchClassification? {
|
||||
guard let de = displayedDeltaE else { return nil }
|
||||
let settings = environment.settingsStore.load()
|
||||
return ColorDifference.classify(
|
||||
deltaE: de,
|
||||
goodMax: settings.deltaEGoodMax,
|
||||
warningMax: settings.deltaEWarningMax)
|
||||
}
|
||||
|
||||
/// `spotLabImplausible` — L* outside 0…100 still displays, unclamped.
|
||||
var isDisplayedLabImplausible: Bool {
|
||||
guard let l = displayedSample?.lab.l else { return false }
|
||||
return l < 0 || l > 100
|
||||
}
|
||||
|
||||
// MARK: - Sheet lifecycle
|
||||
|
||||
/// Called from `SpotReadView.onAppear`. Resets the in-memory session
|
||||
/// and runs detection once; a missing sidecar gets a wizard notice.
|
||||
func sheetOpened() {
|
||||
samples = []
|
||||
displayedSample = nil
|
||||
displayedDeltaE = nil
|
||||
log = []
|
||||
lastError = nil
|
||||
state = .idle
|
||||
prompt = "Press Start to open the instrument."
|
||||
|
||||
guard sidecarAvailable else {
|
||||
workflow.wizard.showNotice(
|
||||
"spotread sidecar missing — run fetch-argyll", kind: .error)
|
||||
return
|
||||
}
|
||||
detectInstruments()
|
||||
}
|
||||
|
||||
/// Called from `onDisappear` *and* the sheet's `onDismiss` — clearing
|
||||
/// the flag alone is not enough; a live child must be quit and
|
||||
/// killed (R14).
|
||||
func sheetClosed() {
|
||||
stopIfNeeded()
|
||||
samples = []
|
||||
displayedSample = nil
|
||||
displayedDeltaE = nil
|
||||
log = []
|
||||
defaultMissing = false
|
||||
}
|
||||
|
||||
// MARK: - Detection
|
||||
|
||||
func detectInstruments() {
|
||||
guard canDetect else { return }
|
||||
isDetecting = true
|
||||
detectionError = nil
|
||||
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
// `instlist` is an exclusive lease (#116) — never spawn a
|
||||
// second one; surface the busy state instead.
|
||||
if await self.environment.runner.processManager.isRunning(ProcessID.instlist) {
|
||||
self.detectionError = "Instrument detection is already running."
|
||||
self.isDetecting = false
|
||||
return
|
||||
}
|
||||
do {
|
||||
let devices = try await self.environment.runner.detectInstruments()
|
||||
self.instruments = devices
|
||||
self.seedDefault(from: devices)
|
||||
if case .device(let selected) = self.selectedInstrument,
|
||||
!devices.contains(where: { $0.port == selected.port }) {
|
||||
self.selectedInstrument = .auto
|
||||
}
|
||||
} catch {
|
||||
self.detectionError = error.localizedDescription
|
||||
}
|
||||
self.isDetecting = false
|
||||
}
|
||||
}
|
||||
|
||||
/// Seed the picker from `AppSettings.defaultInstrument`; no match →
|
||||
/// `.auto` + `spotDefaultMissing`.
|
||||
private func seedDefault(from devices: [InstrumentDevice]) {
|
||||
guard let code = environment.settingsStore.load().defaultInstrument,
|
||||
!code.isEmpty else {
|
||||
defaultMissing = false
|
||||
return
|
||||
}
|
||||
if let match = devices.first(where: { Self.matches(code: code, device: $0) }) {
|
||||
selectedInstrument = .device(match)
|
||||
defaultMissing = false
|
||||
} else {
|
||||
selectedInstrument = .auto
|
||||
defaultMissing = true
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an `instlist` device corresponds to a `printtarg -i` /
|
||||
/// settings instrument code (`i1`, `CM`, `p3`, `SS`, `20`/`22`/`41`/`51`).
|
||||
static func matches(code: String, device: InstrumentDevice) -> Bool {
|
||||
let haystack = "\(device.name) \(device.type)".lowercased()
|
||||
switch code {
|
||||
case "i1": return haystack.contains("i1pro") && !haystack.contains("i1pro 3") && !haystack.contains("i1pro3")
|
||||
case "p3": return haystack.contains("i1pro 3") || haystack.contains("i1pro3")
|
||||
case "CM": return haystack.contains("colormunki")
|
||||
case "SS": return haystack.contains("specbos") || haystack.contains("spectraval") || haystack.contains("spectroscan") || haystack.contains("spectro scan")
|
||||
case "20": return haystack.contains("display 2")
|
||||
case "22": return haystack.contains("display")
|
||||
case "41": return haystack.contains("spyder 4") || haystack.contains("spyder 5") || haystack.contains("spyder4") || haystack.contains("spyder5")
|
||||
case "51": return haystack.contains("spyder x")
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Reverse of `matches` — most specific codes first.
|
||||
static func code(for device: InstrumentDevice) -> String? {
|
||||
for code in ["p3", "51", "41", "22", "20", "CM", "SS", "i1"]
|
||||
where matches(code: code, device: device) {
|
||||
return code
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// `spotSetDefault` — writes `AppSettings.defaultInstrument` only.
|
||||
/// Never touches `printtarg -i` or `targen`.
|
||||
func applyDefaultToggle(_ on: Bool) {
|
||||
setAsDefault = on
|
||||
var settings = environment.settingsStore.load()
|
||||
if on, case .device(let device) = selectedInstrument {
|
||||
settings.defaultInstrument = Self.code(for: device)
|
||||
} else if !on {
|
||||
settings.defaultInstrument = nil
|
||||
}
|
||||
try? environment.settingsStore.save(settings)
|
||||
}
|
||||
|
||||
// MARK: - Session control
|
||||
|
||||
func start() {
|
||||
guard sidecarAvailable else {
|
||||
lastError = "spotread sidecar missing — run fetch-argyll"
|
||||
return
|
||||
}
|
||||
guard !isChartreadRunning else {
|
||||
lastError = "Stop the Stage 3 chart read first."
|
||||
return
|
||||
}
|
||||
guard let cwd = workflow.wizard.effectiveWorkingDirectory else {
|
||||
lastError = "Set a working folder in Stage 1 first."
|
||||
return
|
||||
}
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
// `spotread` is an exclusive lease — a second Start while a
|
||||
// child is live is an error, not a kill + respawn (#116).
|
||||
if await self.environment.runner.processManager.isRunning(ProcessID.spotread) {
|
||||
self.lastError = "A spotread session is already running."
|
||||
return
|
||||
}
|
||||
self.begin(config: self.buildConfig(cwd: cwd))
|
||||
}
|
||||
}
|
||||
|
||||
private func buildConfig(cwd: URL) -> SpotReadConfig {
|
||||
let port: Int?
|
||||
let name: String
|
||||
switch selectedInstrument {
|
||||
case .auto:
|
||||
port = nil
|
||||
name = "Auto"
|
||||
case .device(let device):
|
||||
port = device.port
|
||||
name = device.name
|
||||
}
|
||||
return SpotReadConfig(
|
||||
workingDirectory: cwd,
|
||||
selectedPort: selectedInstrument.chartreadPort,
|
||||
enableLEDs: environment.settingsStore.load().enableI1Pro2Leds,
|
||||
isXY: selectedInstrument.isXY,
|
||||
instrumentName: name,
|
||||
instrumentPort: port
|
||||
)
|
||||
}
|
||||
|
||||
private func begin(config: SpotReadConfig) {
|
||||
isRunning = true
|
||||
state = .idle
|
||||
lastError = nil
|
||||
prompt = "Waiting for a reading…"
|
||||
|
||||
let stream = environment.runner.runSpotread(config: config)
|
||||
streamTask = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
for await event in stream {
|
||||
self.handle(event: event)
|
||||
}
|
||||
self.isRunning = false
|
||||
self.state = .idle
|
||||
if self.lastError == nil {
|
||||
self.prompt = "Press Start to open the instrument."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(event: SpotReadEvent) {
|
||||
switch event {
|
||||
case .prompt(let result):
|
||||
state = result.state
|
||||
prompt = promptText(for: result.state)
|
||||
|
||||
case .sample(let sample):
|
||||
let previous = samples.first
|
||||
samples.insert(sample, at: 0)
|
||||
if samples.count > historyLimit {
|
||||
samples.removeLast()
|
||||
}
|
||||
displayedSample = sample
|
||||
displayedDeltaE = previous.map {
|
||||
ColorDifference.deltaE00($0.lab, sample.lab)
|
||||
}
|
||||
|
||||
case .log(let batch):
|
||||
log.append(contentsOf: batch)
|
||||
|
||||
case .exit(let code):
|
||||
if code != 0 {
|
||||
lastError = "spotread exited with code \(code)"
|
||||
}
|
||||
|
||||
case .failed(let error):
|
||||
lastError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func promptText(for state: ChartreadState) -> String {
|
||||
switch state {
|
||||
case .calibrating:
|
||||
return "Place the instrument on the calibration tile, then Calibrate."
|
||||
case .awaitingStrip:
|
||||
return "Place on the patch, then Read."
|
||||
case .reading, .promptContinue:
|
||||
return "Waiting for a reading…"
|
||||
case .warning:
|
||||
return "Instrument warning — stop and restart if it persists."
|
||||
case .error:
|
||||
return "Read error — Stop, then Start again."
|
||||
default:
|
||||
return "Waiting for a reading…"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Transport
|
||||
|
||||
/// `btnSpotCalibrate` — same bytes Stage 3 sends for calibrate.
|
||||
func calibrate() {
|
||||
send(.trigger)
|
||||
}
|
||||
|
||||
/// `btnSpotTrigger` — the Read key (`" \n"`).
|
||||
func trigger() {
|
||||
send(.trigger)
|
||||
}
|
||||
|
||||
private func send(_ input: ChartreadInput) {
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self, self.isRunning else { return }
|
||||
try? await self.environment.runner.sendSpotreadInput(input)
|
||||
}
|
||||
}
|
||||
|
||||
/// `btnSpotStop` / sheet dismiss: `q\n`, ~500 ms, then kill if the
|
||||
/// child is still live.
|
||||
func stopIfNeeded() {
|
||||
guard isRunning else { return }
|
||||
streamTask?.cancel()
|
||||
streamTask = nil
|
||||
let processManager = environment.runner.processManager
|
||||
Task { @MainActor in
|
||||
try? await processManager.sendStdin(
|
||||
id: ProcessID.spotread, bytes: ChartreadInput.quit.bytes)
|
||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||
await processManager.kill(id: ProcessID.spotread)
|
||||
}
|
||||
isRunning = false
|
||||
state = .idle
|
||||
prompt = "Press Start to open the instrument."
|
||||
}
|
||||
|
||||
// MARK: - History / export
|
||||
|
||||
/// Click a history row: copies that sample into the last-sample card.
|
||||
/// Never re-triggers the instrument.
|
||||
func selectFromHistory(_ sample: SpotReadSample) {
|
||||
displayedSample = sample
|
||||
if let index = samples.firstIndex(of: sample), index + 1 < samples.count {
|
||||
displayedDeltaE = ColorDifference.deltaE00(samples[index + 1].lab, sample.lab)
|
||||
} else {
|
||||
displayedDeltaE = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// `btnSpotCopyLab` — `L* a* b*` of the displayed sample as plain
|
||||
/// text (`50.0 1.2 -3.4`).
|
||||
func copyLab() {
|
||||
guard let sample = displayedSample else { return }
|
||||
let text = String(format: "%.1f %.1f %.1f", sample.lab.l, sample.lab.a, sample.lab.b)
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(text, forType: .string)
|
||||
}
|
||||
|
||||
/// `btnSpotExportCsv` — RFC-4180 via `selectCsvSavePath`. Cancel is
|
||||
/// a no-op. Rows are newest-first, matching the history list.
|
||||
func exportCsv() {
|
||||
guard !samples.isEmpty else { return }
|
||||
let url = UITestHooks.isEnabled
|
||||
? UITestHooks.csvExportURL
|
||||
: fileDialogs.selectCsvSavePath()
|
||||
guard let url else { return }
|
||||
|
||||
var out = "timestamp,L,a,b,dE00,instrument,port\r\n"
|
||||
for (index, sample) in samples.enumerated() {
|
||||
let deltaE = index + 1 < samples.count
|
||||
? String(format: "%.2f", ColorDifference.deltaE00(samples[index + 1].lab, sample.lab))
|
||||
: ""
|
||||
out += "\(csvField(iso8601(sample.timestamp))),\(f1(sample.lab.l)),\(f1(sample.lab.a)),\(f1(sample.lab.b)),\(deltaE),\(csvField(sample.instrumentName)),\(sample.port.map(String.init) ?? "")\r\n"
|
||||
}
|
||||
|
||||
do {
|
||||
try out.write(to: url, atomically: true, encoding: .utf8)
|
||||
workflow.wizard.showNotice("Spot readings exported: \(url.lastPathComponent)")
|
||||
} catch {
|
||||
workflow.wizard.showNotice(
|
||||
"Export failed: \(error.localizedDescription)", kind: .error)
|
||||
}
|
||||
}
|
||||
|
||||
private func f1(_ value: Double) -> String {
|
||||
String(format: "%.1f", value)
|
||||
}
|
||||
|
||||
private func iso8601(_ date: Date) -> String {
|
||||
ISO8601DateFormatter().string(from: date)
|
||||
}
|
||||
|
||||
private func csvField(_ text: String) -> String {
|
||||
guard text.contains(",") || text.contains("\"") || text.contains("\n") else {
|
||||
return text
|
||||
}
|
||||
return "\"\(text.replacingOccurrences(of: "\"", with: "\"\""))\""
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,16 @@ final class TargetWorkflowViewModel: ObservableObject {
|
||||
@Published var savePresetName = ""
|
||||
@Published var savePresetDesc = ""
|
||||
|
||||
// MARK: - Media library (issue #146)
|
||||
|
||||
@Published var showingSaveMedia = false
|
||||
@Published var showingManageMedia = false
|
||||
|
||||
// MARK: - Spot read (issue #148)
|
||||
|
||||
/// `RootView` sheet binding for the spot-read console.
|
||||
@Published var showingSpotRead = false
|
||||
|
||||
/// Stage 3 measurement workflow, owned at the app level so it persists
|
||||
/// across stage switches and can observe settings changes.
|
||||
@Published var measurement: MeasurementWorkflowViewModel
|
||||
@@ -107,6 +117,13 @@ final class TargetWorkflowViewModel: ObservableObject {
|
||||
@Published var calibration: CalibrationViewModel!
|
||||
/// Stage 2 unmanaged print session.
|
||||
@Published var print: PrintSessionViewModel!
|
||||
/// Media recipe library — needs a complete `self`.
|
||||
@Published var media: MediaLibraryViewModel!
|
||||
/// Spot-read console — needs `wizard` / `measurement`.
|
||||
@Published var spotRead: SpotReadViewModel!
|
||||
/// Project file session (issue #149), created last — needs `media`
|
||||
/// for recipe apply and `spotRead` for the live-child check.
|
||||
@Published var project: ProjectSession!
|
||||
|
||||
init(environment: AppEnvironment = .live()) {
|
||||
self.environment = environment
|
||||
@@ -126,6 +143,18 @@ final class TargetWorkflowViewModel: ObservableObject {
|
||||
profile: self.profile,
|
||||
environment: environment
|
||||
)
|
||||
self.media = MediaLibraryViewModel(
|
||||
workflow: self,
|
||||
environment: environment
|
||||
)
|
||||
self.spotRead = SpotReadViewModel(
|
||||
workflow: self,
|
||||
environment: environment
|
||||
)
|
||||
self.project = ProjectSession(
|
||||
workflow: self,
|
||||
environment: environment
|
||||
)
|
||||
reloadPresets()
|
||||
}
|
||||
|
||||
|
||||
@@ -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,96 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import ICCeryCore
|
||||
|
||||
/// Issue #146 — `MediaLibraryStore` persistence contract.
|
||||
final class MediaLibraryStoreTests: XCTestCase {
|
||||
|
||||
private var url: URL!
|
||||
|
||||
override func setUp() {
|
||||
url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("media-lib-\(UUID().uuidString).json")
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
url = nil
|
||||
}
|
||||
|
||||
private func recipe(id: String, name: String = "R") -> MediaRecipe {
|
||||
MediaRecipe(
|
||||
id: id, name: name, printerID: "q",
|
||||
colourSpace: "rgb", presetID: "preset-std-rgb")
|
||||
}
|
||||
|
||||
func testRoundTrip() async throws {
|
||||
let store = MediaLibraryStore(url: url)
|
||||
let r = recipe(id: "recipe-1", name: "Epson Rag")
|
||||
try await store.upsert(r)
|
||||
let loaded = try await store.load()
|
||||
XCTAssertEqual(loaded, [r])
|
||||
}
|
||||
|
||||
func testCorruptFileThrowsAndPreservesBytes() async throws {
|
||||
try "not json".write(to: url, atomically: true, encoding: .utf8)
|
||||
let before = try Data(contentsOf: url)
|
||||
|
||||
let store = MediaLibraryStore(url: url)
|
||||
await assertAsyncThrows(expectedType: DecodingError.self) {
|
||||
try await store.load()
|
||||
}
|
||||
// upsert must also propagate — a corrupt file is never wiped.
|
||||
await assertAsyncThrows(expectedType: DecodingError.self) {
|
||||
try await store.upsert(recipe(id: "x"))
|
||||
}
|
||||
XCTAssertEqual(try Data(contentsOf: url), before)
|
||||
}
|
||||
|
||||
func testDelete() async throws {
|
||||
let store = MediaLibraryStore(url: url)
|
||||
try await store.upsert(recipe(id: "a"))
|
||||
try await store.upsert(recipe(id: "b"))
|
||||
|
||||
let removed = try await store.delete(id: "a")
|
||||
XCTAssertTrue(removed)
|
||||
let remaining = try await store.load().map(\.id)
|
||||
XCTAssertEqual(remaining, ["b"])
|
||||
|
||||
let again = try await store.delete(id: "a")
|
||||
XCTAssertFalse(again)
|
||||
}
|
||||
|
||||
func testCapacityReached() async throws {
|
||||
let store = MediaLibraryStore(url: url, capacity: 2)
|
||||
try await store.upsert(recipe(id: "1"))
|
||||
try await store.upsert(recipe(id: "2"))
|
||||
await assertAsyncThrows(
|
||||
expectedType: MediaLibraryStore.MediaLibraryError.self
|
||||
) {
|
||||
try await store.upsert(recipe(id: "3"))
|
||||
} errorHandler: {
|
||||
XCTAssertEqual($0, .capacityReached(2))
|
||||
}
|
||||
let stored = try await store.load()
|
||||
XCTAssertEqual(stored.count, 2)
|
||||
}
|
||||
|
||||
func testUpsertPreservesCreatedBumpsUpdated() async throws {
|
||||
let store = MediaLibraryStore(url: url)
|
||||
var r = recipe(id: "recipe-1")
|
||||
r.created = Date(timeIntervalSince1970: 1_000_000)
|
||||
r.updated = Date(timeIntervalSince1970: 1_000_000)
|
||||
try await store.upsert(r)
|
||||
|
||||
var edit = r
|
||||
edit.name = "Renamed"
|
||||
edit.updated = Date(timeIntervalSince1970: 2_000_000)
|
||||
try await store.upsert(edit)
|
||||
|
||||
let loaded = try await store.load()
|
||||
XCTAssertEqual(loaded.count, 1)
|
||||
XCTAssertEqual(loaded[0].name, "Renamed")
|
||||
XCTAssertEqual(loaded[0].created, r.created)
|
||||
XCTAssertGreaterThan(loaded[0].updated, r.updated)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import ICCeryCore
|
||||
@testable import ICCery
|
||||
|
||||
/// Issue #146 — `MediaLibraryViewModel` apply / capture / staleness
|
||||
/// under an isolated `TestAppEnvironment` with a mock CUPS `bin` dir.
|
||||
@MainActor
|
||||
final class MediaLibraryViewModelTests: XCTestCase {
|
||||
|
||||
// CTI3 fixture mirrored from CalibrationStoreTests.
|
||||
private static let sampleCal = """
|
||||
CTI3
|
||||
DESCRIPTOR "Test printer"
|
||||
COLOR_REP "RGB"
|
||||
DEVICE_CLASS "OUTPUT"
|
||||
MAX_TAC "300"
|
||||
NUMBER_OF_FIELDS 5
|
||||
NUMBER_OF_SETS 3
|
||||
BEGIN_DATA_FORMAT
|
||||
SAMPLE_ID INPUT_VALUE R G B
|
||||
END_DATA_FORMAT
|
||||
BEGIN_DATA
|
||||
1 0 0 0 0
|
||||
2 128 64 64 64
|
||||
3 255 255 255 255
|
||||
END_DATA
|
||||
"""
|
||||
|
||||
/// Same fixture plus an old CREATED keyword so the age check fires.
|
||||
private static let staleCal = """
|
||||
CTI3
|
||||
DESCRIPTOR "Test printer"
|
||||
CREATED "2020-01-01T00:00:00Z"
|
||||
COLOR_REP "RGB"
|
||||
DEVICE_CLASS "OUTPUT"
|
||||
NUMBER_OF_FIELDS 5
|
||||
NUMBER_OF_SETS 3
|
||||
BEGIN_DATA_FORMAT
|
||||
SAMPLE_ID INPUT_VALUE R G B
|
||||
END_DATA_FORMAT
|
||||
BEGIN_DATA
|
||||
1 0 0 0 0
|
||||
2 128 64 64 64
|
||||
3 255 255 255 255
|
||||
END_DATA
|
||||
"""
|
||||
|
||||
private var env: TestAppEnvironment!
|
||||
private var workflow: TargetWorkflowViewModel!
|
||||
private var media: MediaLibraryViewModel!
|
||||
|
||||
override func setUp() async throws {
|
||||
env = try TestAppEnvironment.make()
|
||||
try installMockCups()
|
||||
workflow = TargetWorkflowViewModel(environment: env.environment)
|
||||
media = workflow.media
|
||||
await media.reloadAsync()
|
||||
}
|
||||
|
||||
override func tearDown() async throws {
|
||||
env?.cleanup()
|
||||
env = nil
|
||||
workflow = nil
|
||||
media = nil
|
||||
}
|
||||
|
||||
/// Mock `lpstat`/`lpoptions` inside the env's `cups-bin` (the
|
||||
/// `CupsService.binaryDir` `TestAppEnvironment` points at). Queues:
|
||||
/// `Mock_Queue` (default) and `Other_Queue`.
|
||||
private func installMockCups() throws {
|
||||
let bin = env.root.appendingPathComponent("cups-bin")
|
||||
try FileManager.default.createDirectory(
|
||||
at: bin, withIntermediateDirectories: true)
|
||||
|
||||
let lpstat = """
|
||||
#!/bin/sh
|
||||
case "$1" in
|
||||
-e) printf 'Mock_Queue\\nOther_Queue\\n' ;;
|
||||
-p) printf 'printer Mock_Queue is idle.\\nprinter Other_Queue is idle.\\n' ;;
|
||||
-d) printf 'system default destination: Mock_Queue\\n' ;;
|
||||
esac
|
||||
exit 0
|
||||
"""
|
||||
let lpoptions = """
|
||||
#!/bin/sh
|
||||
list=0
|
||||
queue=""
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-l) list=1 ;;
|
||||
-p) ;;
|
||||
*) queue="$arg" ;;
|
||||
esac
|
||||
done
|
||||
if [ "$list" = "1" ]; then
|
||||
printf 'PageSize/Media Size: *A4 Letter\\n'
|
||||
printf 'MediaType/Media Type: *Stationery Glossy\\n'
|
||||
exit 0
|
||||
fi
|
||||
printf "printer-info='Mock %s' printer-type=42\\n" "$queue"
|
||||
exit 0
|
||||
"""
|
||||
for (name, body) in ["lpstat": lpstat, "lpoptions": lpoptions] {
|
||||
let url = bin.appendingPathComponent(name)
|
||||
try body.write(to: url, atomically: true, encoding: .utf8)
|
||||
try FileManager.default.setAttributes(
|
||||
[.posixPermissions: 0o755], ofItemAtPath: url.path)
|
||||
}
|
||||
}
|
||||
|
||||
private func writeCal(
|
||||
_ contents: String = MediaLibraryViewModelTests.sampleCal,
|
||||
named name: String = "recipe.cal"
|
||||
) throws -> String {
|
||||
let url = env.root.appendingPathComponent(name)
|
||||
try contents.write(to: url, atomically: true, encoding: .utf8)
|
||||
return url.path
|
||||
}
|
||||
|
||||
private func makeRecipe(
|
||||
id: String = "recipe-t1",
|
||||
printerID: String = "Mock_Queue",
|
||||
colourSpace: String = "rgb",
|
||||
presetID: String = "preset-std-rgb",
|
||||
calibrationURL: String? = nil,
|
||||
applyCalibration: Bool = false
|
||||
) -> MediaRecipe {
|
||||
MediaRecipe(
|
||||
id: id,
|
||||
name: "Test Recipe",
|
||||
printerID: printerID,
|
||||
printerDisplayName: "Mock Queue Display",
|
||||
paperName: "Rag",
|
||||
inkSet: "PK",
|
||||
colourSpace: colourSpace,
|
||||
presetID: presetID,
|
||||
calibrationURL: calibrationURL,
|
||||
applyCalibration: applyCalibration)
|
||||
}
|
||||
|
||||
private func seed(_ recipe: MediaRecipe) async throws {
|
||||
try await env.environment.mediaStore.upsert(recipe)
|
||||
await media.reloadAsync()
|
||||
}
|
||||
|
||||
// MARK: - Apply
|
||||
|
||||
func testApplyHappyPath() async throws {
|
||||
let calPath = try writeCal()
|
||||
let r = makeRecipe(
|
||||
calibrationURL: calPath, applyCalibration: true)
|
||||
try await seed(r)
|
||||
|
||||
let applied = await media.apply(r)
|
||||
|
||||
XCTAssertTrue(applied)
|
||||
XCTAssertEqual(workflow.print.selectedPrinter, "Mock_Queue")
|
||||
XCTAssertEqual(workflow.wizard.printerName, "Mock Queue Display")
|
||||
XCTAssertTrue(workflow.profile.applyCalibration)
|
||||
XCTAssertEqual(workflow.profile.calibrationFile, calPath)
|
||||
XCTAssertEqual(media.selectedRecipeID, r.id)
|
||||
XCTAssertEqual(workflow.selectedPresetID, "preset-std-rgb")
|
||||
XCTAssertEqual(
|
||||
workflow.buildPrinttargConfig().calibrationFile, calPath)
|
||||
}
|
||||
|
||||
func testApplyMissingPresetRefuses() async throws {
|
||||
let r = makeRecipe(presetID: "preset-nonexistent")
|
||||
try await seed(r)
|
||||
|
||||
let applied = await media.apply(r)
|
||||
|
||||
XCTAssertFalse(applied)
|
||||
XCTAssertEqual(media.selectedRecipeID, "none")
|
||||
XCTAssertEqual(workflow.selectedPresetID, "none")
|
||||
XCTAssertTrue(
|
||||
workflow.wizard.notice?.text.contains("no longer exists") == true)
|
||||
}
|
||||
|
||||
func testApplyColourSpaceMismatchRefuses() async throws {
|
||||
let r = makeRecipe(colourSpace: "cmyk", presetID: "preset-std-rgb")
|
||||
try await seed(r)
|
||||
|
||||
let applied = await media.apply(r)
|
||||
|
||||
XCTAssertFalse(applied)
|
||||
XCTAssertEqual(media.selectedRecipeID, "none")
|
||||
XCTAssertTrue(
|
||||
workflow.wizard.notice?.text.contains("colour space") == true)
|
||||
}
|
||||
|
||||
func testApplyMissingCalFileFails() async throws {
|
||||
let missing = env.root.appendingPathComponent("gone.cal").path
|
||||
let r = makeRecipe(
|
||||
calibrationURL: missing, applyCalibration: true)
|
||||
try await seed(r)
|
||||
|
||||
let applied = await media.apply(r)
|
||||
|
||||
XCTAssertFalse(applied)
|
||||
XCTAssertFalse(workflow.profile.applyCalibration)
|
||||
XCTAssertEqual(workflow.profile.calibrationFile, missing)
|
||||
XCTAssertEqual(workflow.wizard.notice?.kind, .error)
|
||||
XCTAssertEqual(media.selectedRecipeID, "none")
|
||||
}
|
||||
|
||||
func testApplyCalPrefixedCalCannotArmK() async throws {
|
||||
let calPath = try writeCal(named: "CAL_target.cal")
|
||||
let r = makeRecipe(
|
||||
calibrationURL: calPath, applyCalibration: true)
|
||||
try await seed(r)
|
||||
|
||||
let applied = await media.apply(r)
|
||||
|
||||
// Success with warning — the refusal is permanent, re-clicking
|
||||
// cannot unstick it (decision 6).
|
||||
XCTAssertTrue(applied)
|
||||
XCTAssertFalse(workflow.profile.applyCalibration)
|
||||
XCTAssertEqual(workflow.profile.calibrationFile, calPath)
|
||||
XCTAssertNil(workflow.buildPrinttargConfig().calibrationFile)
|
||||
XCTAssertEqual(media.selectedRecipeID, r.id)
|
||||
XCTAssertEqual(workflow.wizard.notice?.kind, .warning)
|
||||
}
|
||||
|
||||
func testApplyLiveCalBasenameBlocks() async throws {
|
||||
let calPath = try writeCal()
|
||||
let r = makeRecipe(
|
||||
calibrationURL: calPath, applyCalibration: true)
|
||||
try await seed(r)
|
||||
workflow.wizard.basename = "CAL_live"
|
||||
|
||||
let applied = await media.apply(r)
|
||||
|
||||
XCTAssertTrue(applied)
|
||||
XCTAssertFalse(workflow.profile.applyCalibration)
|
||||
XCTAssertNil(workflow.buildPrinttargConfig().calibrationFile)
|
||||
}
|
||||
|
||||
func testApplyMissingQueueLeavesQueueUntouched() async throws {
|
||||
workflow.print.selectedPrinter = "Other_Queue"
|
||||
let r = makeRecipe(printerID: "No_Such_Queue")
|
||||
try await seed(r)
|
||||
|
||||
let applied = await media.apply(r)
|
||||
|
||||
XCTAssertFalse(applied)
|
||||
XCTAssertEqual(workflow.print.selectedPrinter, "Other_Queue")
|
||||
XCTAssertTrue(
|
||||
workflow.wizard.notice?.text.contains("is not installed") == true)
|
||||
XCTAssertEqual(media.selectedRecipeID, "none")
|
||||
}
|
||||
|
||||
// MARK: - Capture
|
||||
|
||||
func testCaptureCopiesPrinterAndPreset() async throws {
|
||||
workflow.print.printers = [
|
||||
Printer(name: "Mock_Queue", displayName: "Mock Queue Display")
|
||||
]
|
||||
workflow.print.selectedPrinter = "Mock_Queue"
|
||||
workflow.selectedPresetID = "preset-std-rgb"
|
||||
media.saveMediaName = "My Recipe"
|
||||
media.saveMediaPaper = "Rag"
|
||||
media.saveMediaInk = "PK"
|
||||
|
||||
let saved = await media.captureFromSession()
|
||||
|
||||
XCTAssertTrue(saved)
|
||||
let stored = await env.environment.mediaStore.all()
|
||||
XCTAssertEqual(stored.count, 1)
|
||||
XCTAssertEqual(stored[0].printerID, "Mock_Queue")
|
||||
XCTAssertEqual(stored[0].printerDisplayName, "Mock Queue Display")
|
||||
XCTAssertEqual(stored[0].presetID, "preset-std-rgb")
|
||||
XCTAssertEqual(stored[0].colourSpace, "rgb")
|
||||
XCTAssertEqual(media.selectedRecipeID, stored[0].id)
|
||||
}
|
||||
|
||||
func testCaptureNoPresetAutoSnapshots() async throws {
|
||||
workflow.print.printers = [
|
||||
Printer(name: "Mock_Queue", displayName: "Mock Queue Display")
|
||||
]
|
||||
workflow.print.selectedPrinter = "Mock_Queue"
|
||||
workflow.selectedPresetID = "none"
|
||||
media.saveMediaName = "Snap"
|
||||
media.saveMediaPaper = "Rag"
|
||||
media.saveMediaInk = "MK"
|
||||
|
||||
let saved = await media.captureFromSession()
|
||||
|
||||
XCTAssertTrue(saved)
|
||||
let stored = await env.environment.mediaStore.all()
|
||||
XCTAssertEqual(stored.count, 1)
|
||||
XCTAssertTrue(stored[0].presetID.hasPrefix("custom-"))
|
||||
XCTAssertTrue(
|
||||
env.environment.presetStore.customs()
|
||||
.contains { $0.id == stored[0].presetID })
|
||||
XCTAssertEqual(workflow.selectedPresetID, stored[0].presetID)
|
||||
}
|
||||
|
||||
func testCaptureRequiresPrinter() async {
|
||||
workflow.print.selectedPrinter = ""
|
||||
media.saveMediaName = "n"
|
||||
media.saveMediaPaper = "p"
|
||||
media.saveMediaInk = "i"
|
||||
|
||||
let saved = await media.captureFromSession()
|
||||
|
||||
XCTAssertFalse(saved)
|
||||
XCTAssertNotNil(media.saveMediaError)
|
||||
}
|
||||
|
||||
func testCaptureForcesOffCalToggleForCalFile() async throws {
|
||||
let calPath = try writeCal(named: "CAL_target.cal")
|
||||
workflow.profile.calibrationFile = calPath
|
||||
workflow.profile.applyCalibration = true
|
||||
workflow.print.printers = [Printer(name: "Mock_Queue")]
|
||||
workflow.print.selectedPrinter = "Mock_Queue"
|
||||
workflow.selectedPresetID = "preset-std-rgb"
|
||||
media.saveMediaName = "n"
|
||||
media.saveMediaPaper = "p"
|
||||
media.saveMediaInk = "i"
|
||||
media.saveMediaApplyCal = true // forced off by calApplyable
|
||||
|
||||
XCTAssertFalse(media.calApplyable)
|
||||
let saved = await media.captureFromSession()
|
||||
|
||||
XCTAssertTrue(saved)
|
||||
let stored = await env.environment.mediaStore.all()
|
||||
XCTAssertEqual(stored[0].calibrationURL, calPath) // verbatim
|
||||
XCTAssertFalse(stored[0].applyCalibration)
|
||||
}
|
||||
|
||||
// MARK: - Staleness
|
||||
|
||||
func testStaleCalFlagged() async throws {
|
||||
let calPath = try writeCal(
|
||||
MediaLibraryViewModelTests.staleCal, named: "old.cal")
|
||||
let r = makeRecipe(calibrationURL: calPath)
|
||||
try await seed(r)
|
||||
workflow.print.printers = [Printer(name: "Mock_Queue")]
|
||||
|
||||
await media.refreshStalenessAsync()
|
||||
|
||||
XCTAssertTrue(
|
||||
media.staleReasons[r.id]?.contains(.calibration) == true)
|
||||
XCTAssertNotNil(media.calAgeDays[r.id])
|
||||
}
|
||||
|
||||
func testAbsentQueueFlaggedOnlyWhenListNonEmpty() async throws {
|
||||
let r = makeRecipe(printerID: "No_Such_Queue")
|
||||
try await seed(r)
|
||||
|
||||
// Un-enumerated list is indeterminate → no flag.
|
||||
workflow.print.printers = []
|
||||
await media.refreshStalenessAsync()
|
||||
XCTAssertNil(media.staleReasons[r.id])
|
||||
|
||||
// Absent from a non-empty list → .printer.
|
||||
workflow.print.printers = [Printer(name: "Other_Queue")]
|
||||
await media.refreshStalenessAsync()
|
||||
XCTAssertTrue(
|
||||
media.staleReasons[r.id]?.contains(.printer) == true)
|
||||
|
||||
// Present but unselected → no flag.
|
||||
workflow.print.printers = [
|
||||
Printer(name: "Other_Queue"), Printer(name: "No_Such_Queue"),
|
||||
]
|
||||
workflow.print.selectedPrinter = "Other_Queue"
|
||||
await media.refreshStalenessAsync()
|
||||
XCTAssertNil(media.staleReasons[r.id])
|
||||
}
|
||||
|
||||
// MARK: - Delete
|
||||
|
||||
func testDeleteResetsSelection() async throws {
|
||||
let r = makeRecipe()
|
||||
try await seed(r)
|
||||
media.selectedRecipeID = r.id
|
||||
|
||||
media.delete(r)
|
||||
try await Task.sleep(nanoseconds: 200_000_000)
|
||||
|
||||
XCTAssertEqual(media.selectedRecipeID, "none")
|
||||
let stored = await env.environment.mediaStore.all()
|
||||
XCTAssertTrue(stored.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Corrupt library
|
||||
|
||||
func testCorruptLibraryKeepsFileAndWarns() async throws {
|
||||
// Fresh environment so the store's `loaded` flag is still false.
|
||||
let env2 = try TestAppEnvironment.make()
|
||||
defer { env2.cleanup() }
|
||||
try "garbage".write(
|
||||
to: env2.mediaLibraryURL, atomically: true, encoding: .utf8)
|
||||
let workflow2 = TargetWorkflowViewModel(
|
||||
environment: env2.environment)
|
||||
await workflow2.media.reloadAsync()
|
||||
|
||||
XCTAssertTrue(workflow2.media.recipes.isEmpty)
|
||||
XCTAssertEqual(workflow2.media.selectedRecipeID, "none")
|
||||
XCTAssertEqual(workflow2.wizard.notice?.kind, .warning)
|
||||
XCTAssertEqual(try Data(contentsOf: env2.mediaLibraryURL),
|
||||
"garbage".data(using: .utf8))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import ICCeryCore
|
||||
|
||||
/// Issue #146 — `MediaRecipe` Codable + validation contract.
|
||||
final class MediaRecipeTests: XCTestCase {
|
||||
|
||||
private func makeRecipe() -> MediaRecipe {
|
||||
MediaRecipe(
|
||||
id: "recipe-abc",
|
||||
name: "Epson Rag",
|
||||
notes: "notes",
|
||||
printerID: "epson_p900",
|
||||
printerDisplayName: "Epson SureColor P900",
|
||||
paperName: "Rag Photographique",
|
||||
driverMediaType: "PhotographicGlossy",
|
||||
inkSet: "PK",
|
||||
colourSpace: "rgb",
|
||||
presetID: "preset-std-rgb",
|
||||
calibrationURL: "/tmp/prof.cal",
|
||||
applyCalibration: true,
|
||||
created: Date(timeIntervalSince1970: 1_700_000_000),
|
||||
updated: Date(timeIntervalSince1970: 1_700_000_100)
|
||||
)
|
||||
}
|
||||
|
||||
func testRoundTripSnakeCase() throws {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
let data = try encoder.encode(makeRecipe())
|
||||
let object = try JSONSerialization.jsonObject(with: data) as! [String: Any]
|
||||
|
||||
for key in [
|
||||
"printer_id", "printer_display_name", "paper_name",
|
||||
"driver_media_type", "ink_set", "colour_space", "preset_id",
|
||||
"calibration_url", "apply_calibration", "created", "updated",
|
||||
"id", "name", "notes",
|
||||
] {
|
||||
XCTAssertNotNil(object[key], "missing key \(key)")
|
||||
}
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
let decoded = try decoder.decode(MediaRecipe.self, from: data)
|
||||
XCTAssertEqual(decoded, makeRecipe())
|
||||
}
|
||||
|
||||
func testMissingRequiredKeyThrows() {
|
||||
for key in ["id", "name", "printer_id", "colour_space", "preset_id"] {
|
||||
var dict: [String: Any] = [
|
||||
"id": "r1", "name": "n", "printer_id": "q",
|
||||
"colour_space": "rgb", "preset_id": "p",
|
||||
]
|
||||
dict.removeValue(forKey: key)
|
||||
let data = try! JSONSerialization.data(withJSONObject: dict)
|
||||
XCTAssertThrowsError(
|
||||
try JSONDecoder().decode(MediaRecipe.self, from: data),
|
||||
"expected throw without \(key)")
|
||||
}
|
||||
}
|
||||
|
||||
func testUnknownKeysIgnored() throws {
|
||||
let dict: [String: Any] = [
|
||||
"id": "r1", "name": "n", "printer_id": "q",
|
||||
"colour_space": "rgb", "preset_id": "p",
|
||||
"future_field": "ignored",
|
||||
]
|
||||
let data = try JSONSerialization.data(withJSONObject: dict)
|
||||
let recipe = try JSONDecoder().decode(MediaRecipe.self, from: data)
|
||||
XCTAssertEqual(recipe.id, "r1")
|
||||
XCTAssertEqual(recipe.notes, "")
|
||||
XCTAssertFalse(recipe.applyCalibration)
|
||||
}
|
||||
|
||||
func testValidatedGoldens() {
|
||||
var r = makeRecipe()
|
||||
|
||||
r.name = " "
|
||||
XCTAssertThrowsError(try r.validated()) {
|
||||
XCTAssertEqual($0 as? MediaRecipe.ValidationError, .emptyName)
|
||||
}
|
||||
|
||||
r = makeRecipe()
|
||||
r.printerID = ""
|
||||
XCTAssertThrowsError(try r.validated()) {
|
||||
XCTAssertEqual($0 as? MediaRecipe.ValidationError, .emptyPrinterID)
|
||||
}
|
||||
|
||||
r = makeRecipe()
|
||||
r.presetID = ""
|
||||
XCTAssertThrowsError(try r.validated()) {
|
||||
XCTAssertEqual($0 as? MediaRecipe.ValidationError, .emptyPresetID)
|
||||
}
|
||||
|
||||
r = makeRecipe()
|
||||
r.colourSpace = "lab"
|
||||
XCTAssertThrowsError(try r.validated()) {
|
||||
XCTAssertEqual(
|
||||
$0 as? MediaRecipe.ValidationError, .invalidColourSpace("lab"))
|
||||
}
|
||||
|
||||
for bad in ["../evil.cal", "rel/path.cal", "/tmp/a\0b.cal"] {
|
||||
r = makeRecipe()
|
||||
r.calibrationURL = bad
|
||||
XCTAssertThrowsError(try r.validated(), "expected throw for \(bad)") {
|
||||
XCTAssertEqual(
|
||||
$0 as? MediaRecipe.ValidationError,
|
||||
.invalidCalibrationURL(bad))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testColourSpaceNormalisedToLowercase() throws {
|
||||
var r = makeRecipe()
|
||||
r.colourSpace = "RGB"
|
||||
let validated = try r.validated()
|
||||
XCTAssertEqual(validated.colourSpace, "rgb")
|
||||
}
|
||||
|
||||
func testCalPrefixedCalNameIsSchemaValid() throws {
|
||||
var r = makeRecipe()
|
||||
// CAL_ refusal is an apply-time policy, not a schema error.
|
||||
r.calibrationURL = "/tmp/CAL_target.cal"
|
||||
XCTAssertNoThrow(try r.validated())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import ICCeryCore
|
||||
@testable import ICCery
|
||||
|
||||
/// Issue #149 — `ProjectSession` open/save/new/close against an
|
||||
/// isolated `TestAppEnvironment`. Disk artefacts always win over the
|
||||
/// project JSON (R18); a `CAL_` basename is never persisted (R11).
|
||||
@MainActor
|
||||
final class ProjectSessionTests: XCTestCase {
|
||||
|
||||
private var env: TestAppEnvironment!
|
||||
private var workflow: TargetWorkflowViewModel!
|
||||
private var project: ProjectSession!
|
||||
private var cwd: URL!
|
||||
|
||||
override func setUp() async throws {
|
||||
env = try TestAppEnvironment.make()
|
||||
workflow = TargetWorkflowViewModel(environment: env.environment)
|
||||
project = workflow.project
|
||||
cwd = env.root.appendingPathComponent("proj-cwd")
|
||||
try FileManager.default.createDirectory(
|
||||
at: cwd, withIntermediateDirectories: true)
|
||||
// Recents are pushed asynchronously; drain before asserting.
|
||||
await flush()
|
||||
}
|
||||
|
||||
override func tearDown() async throws {
|
||||
env?.cleanup()
|
||||
env = nil
|
||||
workflow = nil
|
||||
project = nil
|
||||
cwd = nil
|
||||
}
|
||||
|
||||
/// Lets pending `Task`s in the view models run.
|
||||
private func flush() async {
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
}
|
||||
|
||||
private func artefact(_ ext: String, stem: String = "job") throws {
|
||||
try "x".write(
|
||||
to: cwd.appendingPathComponent("\(stem).\(ext)"),
|
||||
atomically: true, encoding: .utf8)
|
||||
}
|
||||
|
||||
private func writeProjectFile(
|
||||
_ name: String = "job.icceryproj",
|
||||
basename: String = "job",
|
||||
cwdOverride: String? = nil,
|
||||
lastVerification: Bool = false,
|
||||
mediaRecipeID: String? = nil,
|
||||
presetID: String? = nil
|
||||
) throws -> URL {
|
||||
let snapshot: VerificationSnapshot? = lastVerification
|
||||
? VerificationSnapshot(
|
||||
date: Date(), avgDE00: 0.7, maxDE00: 1.9,
|
||||
status: "excellent", profileFilename: "\(basename).icc")
|
||||
: nil
|
||||
let p = ICCeryProject(
|
||||
name: "Fixture Project",
|
||||
basename: basename,
|
||||
cwd: cwdOverride ?? cwd.path,
|
||||
mediaRecipeID: mediaRecipeID,
|
||||
presetID: presetID,
|
||||
lastVerification: snapshot)
|
||||
let url = env.root.appendingPathComponent(name)
|
||||
try p.save(to: url)
|
||||
return url
|
||||
}
|
||||
|
||||
// MARK: - Open
|
||||
|
||||
func testOpenAppliesBasenameCwdAndBinds() async throws {
|
||||
try artefact("ti1")
|
||||
try artefact("ti2")
|
||||
try artefact("ti3")
|
||||
let url = try writeProjectFile()
|
||||
|
||||
await project.openAsync(url)
|
||||
|
||||
XCTAssertEqual(workflow.wizard.basename, "job")
|
||||
XCTAssertEqual(workflow.wizard.workingDirectory?.path, cwd.path)
|
||||
XCTAssertEqual(workflow.targetBasename, "job")
|
||||
XCTAssertEqual(workflow.targetDirectory?.path, cwd.path)
|
||||
XCTAssertEqual(project.projectURL, url)
|
||||
XCTAssertEqual(project.project?.name, "Fixture Project")
|
||||
XCTAssertEqual(project.windowTitle, "ICCery — Fixture Project")
|
||||
XCTAssertFalse(project.isDirty)
|
||||
XCTAssertTrue(workflow.wizard.isUnlocked(.buildProfile))
|
||||
XCTAssertFalse(workflow.wizard.isUnlocked(.verifyInstall))
|
||||
}
|
||||
|
||||
func testOpenDiskWinsOverJsonStage() async throws {
|
||||
// JSON claims a finished profile; disk only has .ti2 (R18).
|
||||
try artefact("ti2")
|
||||
let url = try writeProjectFile(lastVerification: true)
|
||||
|
||||
await project.openAsync(url)
|
||||
|
||||
XCTAssertFalse(workflow.wizard.isUnlocked(.buildProfile))
|
||||
XCTAssertFalse(workflow.wizard.isUnlocked(.verifyInstall))
|
||||
XCTAssertTrue(project.diskBehindNotes)
|
||||
let notice = workflow.wizard.notice
|
||||
XCTAssertEqual(notice?.kind, .info)
|
||||
XCTAssertTrue(
|
||||
notice?.text.contains("artefacts on disk stop at .ti2") == true,
|
||||
"got: \(notice?.text ?? "nil")")
|
||||
}
|
||||
|
||||
func testOpenWithTi3ButNoIccLocksStage5Only() async throws {
|
||||
try artefact("ti3")
|
||||
let url = try writeProjectFile(lastVerification: true)
|
||||
|
||||
await project.openAsync(url)
|
||||
|
||||
XCTAssertTrue(workflow.wizard.isUnlocked(.buildProfile))
|
||||
XCTAssertFalse(workflow.wizard.isUnlocked(.verifyInstall))
|
||||
XCTAssertTrue(project.diskBehindNotes)
|
||||
}
|
||||
|
||||
func testOpenSchema2LeavesLiveStateUntouched() async throws {
|
||||
workflow.wizard.setTarget(basename: "live", workingDirectory: cwd)
|
||||
let url = env.root.appendingPathComponent("v2.icceryproj")
|
||||
try """
|
||||
{"schema_version": 2, "basename": "other", "cwd": "\(cwd.path)"}
|
||||
""".write(to: url, atomically: true, encoding: .utf8)
|
||||
|
||||
await project.openAsync(url)
|
||||
|
||||
XCTAssertNil(project.projectURL)
|
||||
XCTAssertEqual(workflow.wizard.basename, "live")
|
||||
XCTAssertEqual(workflow.wizard.notice?.kind, .error)
|
||||
XCTAssertTrue(
|
||||
workflow.wizard.notice?.text.contains("not schema 1") == true)
|
||||
}
|
||||
|
||||
func testOpenMissingCwdPresentsRelocateSheet() async throws {
|
||||
let gone = env.root.appendingPathComponent("no-such-dir").path
|
||||
let url = try writeProjectFile(cwdOverride: gone)
|
||||
workflow.wizard.setTarget(basename: "live", workingDirectory: cwd)
|
||||
|
||||
await project.openAsync(url)
|
||||
|
||||
XCTAssertTrue(project.showingRelocateSheet)
|
||||
XCTAssertNotNil(project.pendingRelocate)
|
||||
XCTAssertNil(project.projectURL)
|
||||
XCTAssertEqual(workflow.wizard.basename, "live")
|
||||
}
|
||||
|
||||
func testRelocateCancelAbortsOpen() async throws {
|
||||
let gone = env.root.appendingPathComponent("no-such-dir").path
|
||||
let url = try writeProjectFile(cwdOverride: gone)
|
||||
|
||||
await project.openAsync(url)
|
||||
project.cancelRelocate()
|
||||
|
||||
XCTAssertFalse(project.showingRelocateSheet)
|
||||
XCTAssertNil(project.projectURL)
|
||||
}
|
||||
|
||||
func testOpenUnknownMediaRecipeStillOpens() async throws {
|
||||
let url = try writeProjectFile(mediaRecipeID: "recipe-absent")
|
||||
|
||||
await project.openAsync(url)
|
||||
await flush()
|
||||
|
||||
XCTAssertEqual(project.projectURL, url)
|
||||
XCTAssertEqual(workflow.wizard.basename, "job")
|
||||
// The unknown recipe was ignored, not fatal.
|
||||
XCTAssertEqual(workflow.media.selectedRecipeID, "none")
|
||||
}
|
||||
|
||||
func testOpenAppliesPresetWhenNoRecipe() async throws {
|
||||
let url = try writeProjectFile(presetID: "preset-std-rgb")
|
||||
|
||||
await project.openAsync(url)
|
||||
await flush()
|
||||
|
||||
XCTAssertEqual(workflow.selectedPresetID, "preset-std-rgb")
|
||||
}
|
||||
|
||||
// MARK: - Save
|
||||
|
||||
func testSaveRefusesEmptyBasename() async throws {
|
||||
let url = try writeProjectFile()
|
||||
await project.openAsync(url)
|
||||
await flush()
|
||||
workflow.wizard.basename = ""
|
||||
|
||||
XCTAssertFalse(project.canSave)
|
||||
let saved = await project.saveProjectAsync()
|
||||
XCTAssertFalse(saved)
|
||||
// The file keeps its original basename.
|
||||
let reloaded = try ICCeryProject.load(from: url)
|
||||
XCTAssertEqual(reloaded.basename, "job")
|
||||
}
|
||||
|
||||
func testSaveRefusesEmptyCwd() async throws {
|
||||
let url = try writeProjectFile()
|
||||
await project.openAsync(url)
|
||||
await flush()
|
||||
workflow.wizard.workingDirectory = nil
|
||||
|
||||
XCTAssertFalse(project.canSave)
|
||||
let saved = await project.saveProjectAsync()
|
||||
XCTAssertFalse(saved)
|
||||
}
|
||||
|
||||
func testCalBasenameRefusedWithoutPersistedOriginal() async throws {
|
||||
let url = try writeProjectFile()
|
||||
await project.openAsync(url)
|
||||
await flush()
|
||||
// Bare CAL_ stem — no persisted original to trust (R11).
|
||||
workflow.wizard.basename = "CAL_job"
|
||||
workflow.wizard.calibrationOriginalBasename = ""
|
||||
|
||||
XCTAssertTrue(project.canSave) // enabled so the banner shows
|
||||
let saved = await project.saveProjectAsync()
|
||||
|
||||
XCTAssertFalse(saved)
|
||||
XCTAssertTrue(
|
||||
workflow.wizard.notice?.text.contains(
|
||||
"Finish or exit calibration") == true)
|
||||
let raw = try String(contentsOf: url, encoding: .utf8)
|
||||
XCTAssertFalse(raw.contains("CAL_"))
|
||||
XCTAssertEqual(try ICCeryProject.load(from: url).basename, "job")
|
||||
}
|
||||
|
||||
func testCalBasenameSavesPersistedOriginal() async throws {
|
||||
let url = try writeProjectFile()
|
||||
await project.openAsync(url)
|
||||
await flush()
|
||||
workflow.wizard.basename = "CAL_job"
|
||||
workflow.wizard.calibrationOriginalBasename = "job"
|
||||
|
||||
let saved = await project.saveProjectAsync()
|
||||
|
||||
XCTAssertTrue(saved)
|
||||
let reloaded = try ICCeryProject.load(from: url)
|
||||
XCTAssertEqual(reloaded.basename, "job")
|
||||
XCTAssertFalse(rawContainsCal(url))
|
||||
}
|
||||
|
||||
private func rawContainsCal(_ url: URL) -> Bool {
|
||||
guard let raw = try? String(contentsOf: url, encoding: .utf8) else {
|
||||
return false
|
||||
}
|
||||
return raw.contains("CAL_")
|
||||
}
|
||||
|
||||
func testSaveCapturesLiveFields() async throws {
|
||||
let url = try writeProjectFile()
|
||||
await project.openAsync(url)
|
||||
await flush()
|
||||
workflow.print.printers = [
|
||||
Printer(name: "Mock_Queue", displayName: "Mock Queue Display")
|
||||
]
|
||||
workflow.print.selectedPrinter = "Mock_Queue"
|
||||
workflow.selectedPresetID = "preset-std-rgb"
|
||||
workflow.profile.calibrationFile = "/tmp/live.cal"
|
||||
|
||||
XCTAssertTrue(project.isDirty)
|
||||
await flush() // title recompute is deferred one main turn
|
||||
XCTAssertTrue(project.windowTitle.hasSuffix("•"))
|
||||
|
||||
let saved = await project.saveProjectAsync()
|
||||
XCTAssertTrue(saved)
|
||||
let reloaded = try ICCeryProject.load(from: url)
|
||||
XCTAssertEqual(reloaded.printerID, "Mock_Queue")
|
||||
XCTAssertEqual(
|
||||
reloaded.printerDisplayName, "Mock Queue Display")
|
||||
XCTAssertEqual(reloaded.presetID, "preset-std-rgb")
|
||||
XCTAssertEqual(reloaded.calibrationURL, "/tmp/live.cal")
|
||||
XCTAssertFalse(project.isDirty)
|
||||
}
|
||||
|
||||
// MARK: - New / Close
|
||||
|
||||
func testNewClearsBasenameKeepsArtefacts() async throws {
|
||||
try artefact("ti3")
|
||||
let url = try writeProjectFile()
|
||||
await project.openAsync(url)
|
||||
await flush()
|
||||
XCTAssertTrue(workflow.wizard.isUnlocked(.buildProfile))
|
||||
|
||||
project.requestNew()
|
||||
XCTAssertTrue(project.showingNewAlert)
|
||||
project.confirmNew()
|
||||
|
||||
XCTAssertEqual(workflow.wizard.basename, "")
|
||||
XCTAssertEqual(workflow.targetBasename, "")
|
||||
XCTAssertNil(project.projectURL)
|
||||
XCTAssertEqual(project.windowTitle, "ICCery")
|
||||
XCTAssertEqual(workflow.media.selectedRecipeID, "none")
|
||||
XCTAssertEqual(workflow.wizard.sessionMode, .profile)
|
||||
// The artefact is untouched; the stepper just can't see it.
|
||||
XCTAssertTrue(
|
||||
FileManager.default.fileExists(
|
||||
atPath: cwd.appendingPathComponent("job.ti3").path))
|
||||
XCTAssertFalse(workflow.wizard.isUnlocked(.buildProfile))
|
||||
}
|
||||
|
||||
func testNewWhileChildLiveBannersInstead() async throws {
|
||||
workflow.measurement.isChartreadRunning = true
|
||||
project.requestNew()
|
||||
XCTAssertFalse(project.showingNewAlert)
|
||||
XCTAssertNotNil(workflow.wizard.notice)
|
||||
}
|
||||
|
||||
func testDirtyNewShowsDirtyAlert() async throws {
|
||||
let url = try writeProjectFile()
|
||||
await project.openAsync(url)
|
||||
await flush()
|
||||
workflow.wizard.basename = "changed"
|
||||
|
||||
project.requestNew()
|
||||
XCTAssertFalse(project.showingNewAlert)
|
||||
XCTAssertTrue(project.showingDirtyAlert)
|
||||
|
||||
// Don't Save → New proceeds.
|
||||
project.resolveDirty(save: false)
|
||||
XCTAssertEqual(workflow.wizard.basename, "")
|
||||
XCTAssertNil(project.projectURL)
|
||||
}
|
||||
|
||||
func testCloseKeepsLiveSession() async throws {
|
||||
try artefact("ti1")
|
||||
let url = try writeProjectFile()
|
||||
await project.openAsync(url)
|
||||
await flush()
|
||||
|
||||
project.requestClose()
|
||||
|
||||
XCTAssertNil(project.projectURL)
|
||||
XCTAssertEqual(workflow.wizard.basename, "job")
|
||||
XCTAssertEqual(workflow.wizard.workingDirectory?.path, cwd.path)
|
||||
XCTAssertEqual(project.windowTitle, "ICCery")
|
||||
}
|
||||
|
||||
func testDirtyCloseSaveThenCloses() async throws {
|
||||
let url = try writeProjectFile()
|
||||
await project.openAsync(url)
|
||||
await flush()
|
||||
workflow.wizard.basename = "renamed"
|
||||
|
||||
project.requestClose()
|
||||
XCTAssertTrue(project.showingDirtyAlert)
|
||||
|
||||
project.resolveDirty(save: true)
|
||||
try await Task.sleep(nanoseconds: 300_000_000)
|
||||
|
||||
XCTAssertNil(project.projectURL)
|
||||
XCTAssertEqual(try ICCeryProject.load(from: url).basename,
|
||||
"renamed")
|
||||
}
|
||||
|
||||
// MARK: - Report / recents
|
||||
|
||||
func testSaveReportWritesMarkdown() async throws {
|
||||
try artefact("ti1")
|
||||
try artefact("ti3")
|
||||
let url = try writeProjectFile(lastVerification: true)
|
||||
await project.openAsync(url)
|
||||
await flush()
|
||||
|
||||
project.saveReport()
|
||||
try await Task.sleep(nanoseconds: 300_000_000)
|
||||
|
||||
let report = cwd.appendingPathComponent("job-report.md")
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: report.path))
|
||||
let text = try String(contentsOf: report, encoding: .utf8)
|
||||
XCTAssertTrue(text.contains("job.ti1 | exists"))
|
||||
XCTAssertTrue(text.contains("job.ti2 | missing"))
|
||||
XCTAssertTrue(text.contains("job.ti3 | exists"))
|
||||
XCTAssertTrue(text.contains("avg ΔE₀₀ 0.70"))
|
||||
XCTAssertTrue(
|
||||
workflow.wizard.notice?.text.contains("job-report.md") == true)
|
||||
}
|
||||
|
||||
func testOpenPushesRecent() async throws {
|
||||
let url = try writeProjectFile()
|
||||
await project.openAsync(url)
|
||||
try await Task.sleep(nanoseconds: 300_000_000)
|
||||
|
||||
let recents = project.recents
|
||||
XCTAssertEqual(recents.first?.path, url.path)
|
||||
XCTAssertEqual(recents.first?.name, "Fixture Project")
|
||||
}
|
||||
|
||||
func testOpenRecentMissingFileDropped() async throws {
|
||||
let store = env.environment.recentProjectsStore
|
||||
let ghost = env.root.appendingPathComponent("ghost.icceryproj")
|
||||
try await store.add(url: ghost, name: "Ghost")
|
||||
|
||||
project.openRecent(
|
||||
RecentProjectEntry(name: "Ghost", path: ghost.path))
|
||||
try await Task.sleep(nanoseconds: 300_000_000)
|
||||
|
||||
XCTAssertTrue(
|
||||
workflow.wizard.notice?.text.contains("gone") == true)
|
||||
let loaded = try await store.load()
|
||||
XCTAssertTrue(loaded.isEmpty)
|
||||
XCTAssertNil(project.projectURL)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import ICCeryCore
|
||||
|
||||
/// Issue #149 — `recent_projects.json`: cap 20, newest first, dedupe
|
||||
/// by path, missing files pruned on submenu build, Clear Menu wipes the
|
||||
/// recents file only, corrupt file kept (R12).
|
||||
final class RecentProjectsStoreTests: XCTestCase {
|
||||
|
||||
private var root: URL!
|
||||
|
||||
override func setUp() async throws {
|
||||
root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("iccery-recents-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(
|
||||
at: root, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
override func tearDown() async throws {
|
||||
if let root { try? FileManager.default.removeItem(at: root) }
|
||||
root = nil
|
||||
}
|
||||
|
||||
private var storeURL: URL {
|
||||
root.appendingPathComponent("recent_projects.json")
|
||||
}
|
||||
|
||||
private func projectFile(_ name: String) throws -> URL {
|
||||
let url = root.appendingPathComponent("\(name).icceryproj")
|
||||
try "{}".write(to: url, atomically: true, encoding: .utf8)
|
||||
return url
|
||||
}
|
||||
|
||||
func testCapTwentyNewestFirst() async throws {
|
||||
let store = RecentProjectsStore(url: storeURL)
|
||||
for i in 0..<25 {
|
||||
let url = try projectFile("p\(i)")
|
||||
try await store.add(url: url, name: "P\(i)")
|
||||
}
|
||||
let all = try await store.load()
|
||||
XCTAssertEqual(all.count, 20)
|
||||
XCTAssertEqual(all.first?.name, "P24")
|
||||
XCTAssertEqual(all.last?.name, "P5")
|
||||
}
|
||||
|
||||
func testAddDeduplicatesByPath() async throws {
|
||||
let store = RecentProjectsStore(url: storeURL)
|
||||
let url = try projectFile("dup")
|
||||
try await store.add(url: url, name: "First")
|
||||
try await store.add(url: try projectFile("other"), name: "Other")
|
||||
try await store.add(url: url, name: "Second")
|
||||
|
||||
let all = try await store.load()
|
||||
XCTAssertEqual(all.count, 2)
|
||||
XCTAssertEqual(all.first?.name, "Second")
|
||||
XCTAssertEqual(
|
||||
all.filter { $0.path == url.path }.count, 1)
|
||||
}
|
||||
|
||||
func testPruneMissingDropsGoneFiles() async throws {
|
||||
let store = RecentProjectsStore(url: storeURL)
|
||||
let live = try projectFile("live")
|
||||
try await store.add(url: live, name: "Live")
|
||||
try await store.add(
|
||||
url: root.appendingPathComponent("gone.icceryproj"),
|
||||
name: "Gone")
|
||||
|
||||
let pruned = try await store.pruneMissing()
|
||||
XCTAssertEqual(pruned.count, 1)
|
||||
XCTAssertEqual(pruned.first?.name, "Live")
|
||||
|
||||
// The rewrite persisted the drop.
|
||||
let reloaded = try await RecentProjectsStore(url: storeURL).load()
|
||||
XCTAssertEqual(reloaded.count, 1)
|
||||
}
|
||||
|
||||
func testRemoveByPath() async throws {
|
||||
let store = RecentProjectsStore(url: storeURL)
|
||||
let url = try projectFile("a")
|
||||
try await store.add(url: url, name: "A")
|
||||
let removed = try await store.remove(path: url.path)
|
||||
XCTAssertTrue(removed)
|
||||
let loaded = try await store.load()
|
||||
XCTAssertTrue(loaded.isEmpty)
|
||||
let second = try await store.remove(path: url.path)
|
||||
XCTAssertFalse(second)
|
||||
}
|
||||
|
||||
func testClearWipesRecentsFileOnly() async throws {
|
||||
let store = RecentProjectsStore(url: storeURL)
|
||||
let projectURL = try projectFile("keep")
|
||||
try await store.add(url: projectURL, name: "Keep")
|
||||
|
||||
try await store.clear()
|
||||
|
||||
let loaded = try await store.load()
|
||||
XCTAssertTrue(loaded.isEmpty)
|
||||
// The `.icceryproj` itself survives Clear Menu.
|
||||
XCTAssertTrue(
|
||||
FileManager.default.fileExists(atPath: projectURL.path))
|
||||
let raw = try String(contentsOf: storeURL, encoding: .utf8)
|
||||
XCTAssertTrue(raw.contains("["))
|
||||
}
|
||||
|
||||
func testCorruptFileThrowsAndKeepsBytes() async throws {
|
||||
try "not json".write(
|
||||
to: storeURL, atomically: true, encoding: .utf8)
|
||||
let store = RecentProjectsStore(url: storeURL)
|
||||
|
||||
await assertAsyncThrows(expectedType: DecodingError.self) {
|
||||
try await store.load()
|
||||
}
|
||||
XCTAssertEqual(
|
||||
try String(contentsOf: storeURL, encoding: .utf8), "not json")
|
||||
}
|
||||
|
||||
func testEntryStoresBookmarkAndPath() async throws {
|
||||
let store = RecentProjectsStore(url: storeURL)
|
||||
let url = try projectFile("b")
|
||||
try await store.add(url: url, name: "B")
|
||||
let entry = try await store.load().first
|
||||
XCTAssertEqual(entry?.path, url.path)
|
||||
XCTAssertNotNil(entry?.bookmark)
|
||||
XCTAssertFalse(entry?.bookmarkHash.isEmpty ?? true)
|
||||
}
|
||||
|
||||
func testBookmarkHashStableAcrossStores() async throws {
|
||||
let a = RecentProjectEntry(name: "x", path: "/tmp/p.icceryproj")
|
||||
let b = RecentProjectEntry(name: "y", path: "/tmp/p.icceryproj")
|
||||
XCTAssertEqual(a.bookmarkHash, b.bookmarkHash)
|
||||
let c = RecentProjectEntry(name: "z", path: "/tmp/q.icceryproj")
|
||||
XCTAssertNotEqual(a.bookmarkHash, c.bookmarkHash)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import XCTest
|
||||
@testable import ICCeryCore
|
||||
|
||||
/// `SpotReadArgs` goldens (issue #148):
|
||||
/// `spotread -v -e [-c port] [-Y l]` — never `-u`, never a basename,
|
||||
/// `-c` only for ports > 1, `-Y l` only when the LED setting is on.
|
||||
final class SpotReadArgsTests: XCTestCase {
|
||||
|
||||
func testAutoOmitsPort() {
|
||||
let args = SpotReadArgs.build(config: SpotReadConfig())
|
||||
XCTAssertEqual(args, ["-v", "-e"])
|
||||
}
|
||||
|
||||
func testPort1OmitsC() {
|
||||
let args = SpotReadArgs.build(config: SpotReadConfig(selectedPort: 1))
|
||||
XCTAssertEqual(args, ["-v", "-e"])
|
||||
}
|
||||
|
||||
func testPort2IncludesC() {
|
||||
let args = SpotReadArgs.build(config: SpotReadConfig(selectedPort: 2))
|
||||
XCTAssertEqual(args, ["-v", "-e", "-c", "2"])
|
||||
}
|
||||
|
||||
func testLedFlag() {
|
||||
let args = SpotReadArgs.build(config: SpotReadConfig(enableLEDs: true))
|
||||
XCTAssertEqual(args, ["-v", "-e", "-Y", "l"])
|
||||
}
|
||||
|
||||
func testPortAndLeds() {
|
||||
let args = SpotReadArgs.build(
|
||||
config: SpotReadConfig(selectedPort: 2, enableLEDs: true))
|
||||
XCTAssertEqual(args, ["-v", "-e", "-c", "2", "-Y", "l"])
|
||||
}
|
||||
|
||||
func testNeverU() {
|
||||
for config in [
|
||||
SpotReadConfig(),
|
||||
SpotReadConfig(selectedPort: 2),
|
||||
SpotReadConfig(enableLEDs: true),
|
||||
SpotReadConfig(selectedPort: 3, enableLEDs: true),
|
||||
] {
|
||||
XCTAssertFalse(SpotReadArgs.build(config: config).contains("-u"))
|
||||
XCTAssertFalse(SpotReadArgs.build(config: config).contains("-d"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import XCTest
|
||||
@testable import ICCeryCore
|
||||
|
||||
/// `SpotReadClassifier` / `SpotReadParser` against real `spotread`
|
||||
/// phrasing (issue #148). The calibration-tile line classifies through
|
||||
/// `ChartreadClassifier`; the spot prompt and its continuation lines
|
||||
/// need the spot-specific matchers.
|
||||
final class SpotReadClassifierTests: XCTestCase {
|
||||
|
||||
// Real `spotread` stdout (calibration then spot prompt).
|
||||
private let calibrateLines = [
|
||||
"Spot read needs a calibration before continuing",
|
||||
"Place instrument on spot reading white calibration tile,",
|
||||
" and then hit any key to continue,",
|
||||
"or hit Esc or Q to abort:",
|
||||
]
|
||||
private let spotPromptLines = [
|
||||
"Place instrument on a spot to be measured,",
|
||||
" and hit a key to take a reading,",
|
||||
"or hit Esc or Q to abort:",
|
||||
]
|
||||
|
||||
func testCalibrationPrompt() {
|
||||
var state = ChartreadState.idle
|
||||
for line in calibrateLines {
|
||||
state = SpotReadClassifier.classify(line: line, previousState: state).state
|
||||
}
|
||||
XCTAssertEqual(state, .calibrating)
|
||||
}
|
||||
|
||||
func testSpotPromptIsAwaitingTrigger() {
|
||||
var state = ChartreadState.calibrating
|
||||
for line in spotPromptLines {
|
||||
state = SpotReadClassifier.classify(line: line, previousState: state).state
|
||||
}
|
||||
XCTAssertEqual(state, .awaitingStrip)
|
||||
}
|
||||
|
||||
func testAbortLineDoesNotBecomeWarning() {
|
||||
// "or hit Esc or Q to abort:" contains no '?' but does contain
|
||||
// "abort" — it must stay on the current prompt, never flip to
|
||||
// a warning.
|
||||
let r = SpotReadClassifier.classify(
|
||||
line: "or hit Esc or Q to abort:", previousState: .awaitingStrip)
|
||||
XCTAssertEqual(r.state, .awaitingStrip)
|
||||
}
|
||||
|
||||
func testParseResultLine() throws {
|
||||
let parsed = SpotReadParser.parse(
|
||||
line: "Result is XYZ: 18.51 20.05 15.71, D50 Lab: 51.9 -8.3 12.2")
|
||||
let lab = try XCTUnwrap(parsed?.lab)
|
||||
XCTAssertEqual(lab.l, 51.9, accuracy: 0.001)
|
||||
XCTAssertEqual(lab.a, -8.3, accuracy: 0.001)
|
||||
XCTAssertEqual(lab.b, 12.2, accuracy: 0.001)
|
||||
let xyz = try XCTUnwrap(parsed?.xyz)
|
||||
XCTAssertEqual(xyz.x, 18.51, accuracy: 0.001)
|
||||
XCTAssertEqual(xyz.y, 20.05, accuracy: 0.001)
|
||||
XCTAssertEqual(xyz.z, 15.71, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testParseLabOnlyLine() throws {
|
||||
let parsed = SpotReadParser.parse(line: "Result is Lab: 40.0 1.2 -3.4")
|
||||
let lab = try XCTUnwrap(parsed?.lab)
|
||||
XCTAssertEqual(lab.l, 40.0, accuracy: 0.001)
|
||||
XCTAssertNil(parsed?.xyz)
|
||||
}
|
||||
|
||||
func testNonSampleLineParsesNil() {
|
||||
XCTAssertNil(SpotReadParser.parse(line: "Place instrument on a spot to be measured,"))
|
||||
XCTAssertNil(SpotReadParser.parse(line: "Calibration successful."))
|
||||
XCTAssertNil(SpotReadParser.parse(line: ""))
|
||||
}
|
||||
|
||||
func testDeltaEBetweenFixtures() {
|
||||
let a = SpotReadParser.parse(
|
||||
line: "Result is XYZ: 18.51 20.05 15.71, D50 Lab: 51.9 -8.3 12.2")!.lab
|
||||
let b = SpotReadParser.parse(
|
||||
line: "Result is XYZ: 19.00 20.50 16.00, D50 Lab: 52.3 -8.0 12.6")!.lab
|
||||
XCTAssertEqual(ColorDifference.deltaE00(a, a), 0, accuracy: 0.0001)
|
||||
XCTAssertGreaterThan(ColorDifference.deltaE00(a, b), 0)
|
||||
XCTAssertEqual(
|
||||
ColorDifference.classify(deltaE: 1.0, goodMax: 2.0, warningMax: 5.0),
|
||||
.good)
|
||||
XCTAssertEqual(
|
||||
ColorDifference.classify(deltaE: 3.0, goodMax: 2.0, warningMax: 5.0),
|
||||
.warning)
|
||||
XCTAssertEqual(
|
||||
ColorDifference.classify(deltaE: 6.0, goodMax: 2.0, warningMax: 5.0),
|
||||
.bad)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import ICCeryCore
|
||||
@testable import ICCery
|
||||
|
||||
/// Issue #148 — `SpotReadViewModel` under an isolated
|
||||
/// `TestAppEnvironment` with per-test mock `spotread`/`instlist`
|
||||
/// sidecars in a temp bin dir.
|
||||
@MainActor
|
||||
final class SpotReadViewModelTests: XCTestCase {
|
||||
|
||||
private var env: TestAppEnvironment!
|
||||
private var workflow: TargetWorkflowViewModel!
|
||||
private var spot: SpotReadViewModel!
|
||||
private var binDir: URL!
|
||||
|
||||
override func setUp() async throws {
|
||||
binDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("spot-bin-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(
|
||||
at: binDir, withIntermediateDirectories: true)
|
||||
// `bundledArgyllRoot` also points at the temp bin dir so the
|
||||
// real sidecars copied into the host app by the build phase do
|
||||
// not mask a missing `spotread` in the override dir.
|
||||
env = try TestAppEnvironment.make(
|
||||
argyllBinDir: binDir, bundledArgyllRoot: binDir)
|
||||
workflow = TargetWorkflowViewModel(environment: env.environment)
|
||||
spot = workflow.spotRead
|
||||
workflow.wizard.setTarget(basename: "spot", workingDirectory: env.root)
|
||||
}
|
||||
|
||||
override func tearDown() async throws {
|
||||
spot?.stopIfNeeded()
|
||||
try? await Task.sleep(nanoseconds: 700_000_000)
|
||||
env?.cleanup()
|
||||
try? FileManager.default.removeItem(at: binDir)
|
||||
env = nil
|
||||
workflow = nil
|
||||
spot = nil
|
||||
binDir = nil
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func writeMock(_ name: String, _ body: String) throws {
|
||||
let url = binDir.appendingPathComponent(name)
|
||||
try body.write(to: url, atomically: true, encoding: .utf8)
|
||||
try FileManager.default.setAttributes(
|
||||
[.posixPermissions: 0o755], ofItemAtPath: url.path)
|
||||
}
|
||||
|
||||
private func installInstlist(_ devicesJson: String) throws {
|
||||
try writeMock("instlist", """
|
||||
#!/bin/sh
|
||||
printf '%s' '\(devicesJson)'
|
||||
exit 0
|
||||
""")
|
||||
}
|
||||
|
||||
private func installSpotread(lab: String = "51.9 -8.3 12.2") throws {
|
||||
try writeMock("spotread", """
|
||||
#!/bin/sh
|
||||
echo "Spot read needs a calibration before continuing"
|
||||
echo "Place instrument on spot reading white calibration tile,"
|
||||
echo " and then hit any key to continue,"
|
||||
echo "or hit Esc or Q to abort:"
|
||||
IFS= read -r line || exit 0
|
||||
echo "Calibration successful."
|
||||
while true; do
|
||||
echo "Place instrument on a spot to be measured,"
|
||||
echo " and hit a key to take a reading,"
|
||||
echo "or hit Esc or Q to abort:"
|
||||
IFS= read -r line || exit 0
|
||||
case "$line" in
|
||||
q*|Q*) exit 0 ;;
|
||||
esac
|
||||
echo "Result is XYZ: 18.51 20.05 15.71, D50 Lab: \(lab)"
|
||||
done
|
||||
""")
|
||||
}
|
||||
|
||||
private func waitFor(
|
||||
_ predicate: @escaping () async -> Bool,
|
||||
timeout: TimeInterval = 10
|
||||
) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if await predicate() { return true }
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
}
|
||||
return await predicate()
|
||||
}
|
||||
|
||||
private func waitForSync(
|
||||
_ predicate: @escaping () -> Bool,
|
||||
timeout: TimeInterval = 10
|
||||
) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if predicate() { return true }
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
}
|
||||
return predicate()
|
||||
}
|
||||
|
||||
// MARK: - Missing sidecar
|
||||
|
||||
func testMissingSidecarNoSpawn() async throws {
|
||||
// bin dir has no spotread → resolver override misses and the
|
||||
// bundled path does not exist either.
|
||||
XCTAssertFalse(spot.sidecarAvailable)
|
||||
spot.sheetOpened()
|
||||
XCTAssertEqual(workflow.wizard.notice?.kind, .error)
|
||||
|
||||
spot.start()
|
||||
XCTAssertEqual(spot.lastError, "spotread sidecar missing — run fetch-argyll")
|
||||
XCTAssertFalse(spot.isRunning)
|
||||
let running = await env.environment.runner.processManager.isRunning(ProcessID.spotread)
|
||||
XCTAssertFalse(running)
|
||||
}
|
||||
|
||||
// MARK: - defaultInstrument seeding
|
||||
|
||||
func testDefaultInstrumentSeedsPicker() async throws {
|
||||
try installSpotread()
|
||||
try installInstlist("""
|
||||
{"event":"instruments","devices":[
|
||||
{"port":1,"name":"X-Rite i1Pro","type":"i1"},
|
||||
{"port":2,"name":"ColorMunki Photo","type":"CM"}]}
|
||||
""")
|
||||
var settings = env.environment.settingsStore.load()
|
||||
settings.defaultInstrument = "CM"
|
||||
try env.environment.settingsStore.save(settings)
|
||||
|
||||
spot.sheetOpened()
|
||||
let ok1 = await waitFor { !self.spot.isDetecting && !self.spot.instruments.isEmpty }
|
||||
XCTAssertTrue(ok1)
|
||||
guard case .device(let device) = spot.selectedInstrument else {
|
||||
XCTFail("Expected device selection, got .auto")
|
||||
return
|
||||
}
|
||||
XCTAssertEqual(device.port, 2)
|
||||
XCTAssertFalse(spot.defaultMissing)
|
||||
}
|
||||
|
||||
func testDefaultInstrumentNotPresent() async throws {
|
||||
try installSpotread()
|
||||
try installInstlist("""
|
||||
{"event":"instruments","devices":[
|
||||
{"port":1,"name":"X-Rite i1Pro","type":"i1"}]}
|
||||
""")
|
||||
var settings = env.environment.settingsStore.load()
|
||||
settings.defaultInstrument = "51" // Spyder X — absent
|
||||
try env.environment.settingsStore.save(settings)
|
||||
|
||||
spot.sheetOpened()
|
||||
let ok2 = await waitFor { !self.spot.isDetecting }
|
||||
XCTAssertTrue(ok2)
|
||||
XCTAssertEqual(spot.selectedInstrument, .auto)
|
||||
XCTAssertTrue(spot.defaultMissing)
|
||||
}
|
||||
|
||||
func testSetDefaultToggleWritesSettingsOnly() async throws {
|
||||
try installSpotread()
|
||||
try installInstlist("""
|
||||
{"event":"instruments","devices":[
|
||||
{"port":2,"name":"ColorMunki Photo","type":"CM"}]}
|
||||
""")
|
||||
spot.sheetOpened()
|
||||
let ok3 = await waitFor { !self.spot.instruments.isEmpty }
|
||||
XCTAssertTrue(ok3)
|
||||
spot.selectedInstrument = .device(spot.instruments[0])
|
||||
spot.applyDefaultToggle(true)
|
||||
XCTAssertEqual(env.environment.settingsStore.load().defaultInstrument, "CM")
|
||||
// printtarg instrument is untouched (R15).
|
||||
XCTAssertEqual(workflow.instrument, .i1)
|
||||
spot.applyDefaultToggle(false)
|
||||
XCTAssertNil(env.environment.settingsStore.load().defaultInstrument)
|
||||
}
|
||||
|
||||
// MARK: - Exclusive lease
|
||||
|
||||
func testDuplicateSpotreadIdRejected() async throws {
|
||||
try writeMock("spotread", "#!/bin/sh\nsleep 30\n")
|
||||
let pm = env.environment.runner.processManager
|
||||
let bin = env.environment.runner.binaryResolver.resolve("spotread")
|
||||
try await pm.runStreaming(id: ProcessID.spotread, binary: bin, arguments: [])
|
||||
let ok4 = await pm.isRunning(ProcessID.spotread)
|
||||
XCTAssertTrue(ok4)
|
||||
do {
|
||||
try await pm.runStreaming(id: ProcessID.spotread, binary: bin, arguments: [])
|
||||
XCTFail("Expected duplicateID")
|
||||
} catch let error as ProcessError {
|
||||
guard case .duplicateID(let id) = error else {
|
||||
XCTFail("Expected duplicateID, got \(error)")
|
||||
return
|
||||
}
|
||||
XCTAssertEqual(id, "spotread")
|
||||
}
|
||||
await pm.kill(id: ProcessID.spotread)
|
||||
}
|
||||
|
||||
// MARK: - Session
|
||||
|
||||
func testMockSpotreadProducesSample() async throws {
|
||||
try installSpotread()
|
||||
spot.sheetOpened()
|
||||
spot.start()
|
||||
let ok5 = await waitFor { self.spot.state == .calibrating }
|
||||
XCTAssertTrue(ok5, "expected calibrating prompt")
|
||||
|
||||
spot.calibrate()
|
||||
let ok6 = await waitFor { self.spot.state == .awaitingStrip }
|
||||
XCTAssertTrue(ok6, "expected read prompt")
|
||||
|
||||
spot.trigger()
|
||||
let ok7 = await waitFor { !self.spot.samples.isEmpty }
|
||||
XCTAssertTrue(ok7, "expected a sample")
|
||||
let sample = try XCTUnwrap(spot.samples.first)
|
||||
XCTAssertEqual(sample.lab.l, 51.9, accuracy: 0.001)
|
||||
XCTAssertNotNil(sample.xyz)
|
||||
XCTAssertNil(spot.displayedDeltaE) // first sample hides ΔE
|
||||
|
||||
spot.trigger()
|
||||
let ok8 = await waitFor { self.spot.samples.count >= 2 }
|
||||
XCTAssertTrue(ok8, "expected a second sample")
|
||||
XCTAssertNotNil(spot.displayedDeltaE)
|
||||
XCTAssertEqual(spot.displayedDeltaE ?? -1, 0, accuracy: 0.0001) // identical Lab
|
||||
|
||||
spot.stopIfNeeded()
|
||||
XCTAssertFalse(spot.isRunning)
|
||||
let deadline = Date().addingTimeInterval(5)
|
||||
var alive = await env.environment.runner.processManager.isRunning(ProcessID.spotread)
|
||||
while alive && Date() < deadline {
|
||||
try await Task.sleep(nanoseconds: 100_000_000)
|
||||
alive = await env.environment.runner.processManager.isRunning(ProcessID.spotread)
|
||||
}
|
||||
XCTAssertFalse(alive, "spotread child must not outlive Stop")
|
||||
}
|
||||
|
||||
func testStartBlockedWhileChartreadRunning() async throws {
|
||||
try installSpotread()
|
||||
// Simulate a live Stage 3 chartread child.
|
||||
workflow.measurement.isChartreadRunning = true
|
||||
spot.sheetOpened()
|
||||
spot.start()
|
||||
XCTAssertEqual(spot.lastError, "Stop the Stage 3 chart read first.")
|
||||
XCTAssertFalse(spot.isRunning)
|
||||
}
|
||||
}
|
||||
@@ -19,10 +19,24 @@ struct TestAppEnvironment {
|
||||
var historyURL: URL {
|
||||
root.appendingPathComponent("verification_history.json")
|
||||
}
|
||||
var mediaLibraryURL: URL {
|
||||
root.appendingPathComponent("media_library.json")
|
||||
}
|
||||
var recentProjectsURL: URL {
|
||||
root.appendingPathComponent("recent_projects.json")
|
||||
}
|
||||
|
||||
/// Creates an isolated environment under `NSTemporaryDirectory()`.
|
||||
/// Call `cleanup()` when finished.
|
||||
static func make() throws -> TestAppEnvironment {
|
||||
/// `argyllBinDir` overrides the `BinaryResolver` tool directory so
|
||||
/// tests can point at mock sidecar scripts (#148).
|
||||
/// `bundledArgyllRoot` replaces the real app-bundle sidecar root so
|
||||
/// tests can simulate a missing sidecar even when the build phase
|
||||
/// copied real binaries into the host app.
|
||||
static func make(
|
||||
argyllBinDir: URL? = nil,
|
||||
bundledArgyllRoot: URL? = nil
|
||||
) throws -> TestAppEnvironment {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("iccery-test-env-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(
|
||||
@@ -41,7 +55,9 @@ struct TestAppEnvironment {
|
||||
presetStore: PresetStore(settingsStore: settingsStore),
|
||||
runner: ArgyllRunner(
|
||||
processManager: processManager,
|
||||
binaryResolver: BinaryResolver(overrideDir: nil)
|
||||
binaryResolver: BinaryResolver(
|
||||
bundledRoot: bundledArgyllRoot ?? AppPaths.bundledArgyllDir,
|
||||
overrideDir: argyllBinDir)
|
||||
),
|
||||
cupsService: CupsService(
|
||||
processManager: processManager,
|
||||
@@ -49,6 +65,12 @@ struct TestAppEnvironment {
|
||||
),
|
||||
historyStore: VerificationHistoryStore(
|
||||
url: root.appendingPathComponent("verification_history.json")
|
||||
),
|
||||
mediaStore: MediaLibraryStore(
|
||||
url: root.appendingPathComponent("media_library.json")
|
||||
),
|
||||
recentProjectsStore: RecentProjectsStore(
|
||||
url: root.appendingPathComponent("recent_projects.json")
|
||||
)
|
||||
)
|
||||
return TestAppEnvironment(root: root, environment: environment)
|
||||
|
||||
@@ -46,7 +46,12 @@ final class AboutHelpUITests: XCTestCase {
|
||||
launchApp()
|
||||
|
||||
let openAbout = app.buttons["openAboutBtn"]
|
||||
XCTAssertTrue(openAbout.waitForExistence(timeout: 10))
|
||||
if !openAbout.waitForExistence(timeout: 10) {
|
||||
// CI triage (#128): print the a11y tree so an empty or
|
||||
// unexpected hierarchy shows up directly in the job log.
|
||||
print("AXTREE-BEGIN windows=\(app.windows.count)\n\(app.debugDescription)\nAXTREE-END")
|
||||
}
|
||||
XCTAssertTrue(openAbout.exists)
|
||||
openAbout.click()
|
||||
|
||||
_ = waitFor("aboutVersion", timeout: 10)
|
||||
@@ -64,14 +69,20 @@ final class AboutHelpUITests: XCTestCase {
|
||||
let toggle = app.buttons["btnToggleAllHelp"]
|
||||
XCTAssertTrue(toggle.waitForExistence(timeout: 10))
|
||||
|
||||
let sidebar = app.groups.containing(.button, identifier: "openSettingsBtn").element
|
||||
let before = sidebar.frame
|
||||
// SDK 13.1 emits no AXGroup for the sidebar root, and an
|
||||
// identifier on the container clobbers child identifiers
|
||||
// (#130) — measure a stable sidebar child instead. Query the
|
||||
// pop-up by type: the Picker's "Preset" label inherits the same
|
||||
// identifier, so an .any query matches twice.
|
||||
let sidebarChild = app.popUpButtons["presetSelect"]
|
||||
XCTAssertTrue(sidebarChild.waitForExistence(timeout: 10))
|
||||
let before = sidebarChild.frame
|
||||
|
||||
toggle.click()
|
||||
let after = sidebar.frame
|
||||
let after = sidebarChild.frame
|
||||
|
||||
XCTAssertEqual(before.size.height, after.size.height,
|
||||
"Toggling global help must not reflow the sidebar height.")
|
||||
XCTAssertEqual(before, after,
|
||||
"Toggling global help must not reflow the sidebar.")
|
||||
XCTAssertTrue(app.descendants(matching: .any)["openSettingsBtn"].exists)
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Mock spotread for Milestone10SpotReadUITests.
|
||||
|
||||
Emits the real spotread prompt phrasing; each trigger line produces one
|
||||
"Result is XYZ: …, D50 Lab: …" sample. Override the emitted colour with
|
||||
MOCK_SPOTREAD_LAB / MOCK_SPOTREAD_XYZ. 'q' quits with exit 0.
|
||||
Usage: spotread -v -e [-c port] [-Y l]
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
LAB = os.environ.get("MOCK_SPOTREAD_LAB", "51.9 -8.3 12.2")
|
||||
XYZ = os.environ.get("MOCK_SPOTREAD_XYZ", "18.51 20.05 15.71")
|
||||
|
||||
|
||||
def read_line():
|
||||
try:
|
||||
return sys.stdin.readline()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def main():
|
||||
print("Spot read needs a calibration before continuing")
|
||||
print("Place instrument on spot reading white calibration tile,")
|
||||
print(" and then hit any key to continue,")
|
||||
print("or hit Esc or Q to abort:")
|
||||
sys.stdout.flush()
|
||||
if not read_line():
|
||||
return 0
|
||||
print("Calibration successful.")
|
||||
|
||||
while True:
|
||||
print("Place instrument on a spot to be measured,")
|
||||
print(" and hit a key to take a reading,")
|
||||
print("or hit Esc or Q to abort:")
|
||||
sys.stdout.flush()
|
||||
line = read_line()
|
||||
if not line:
|
||||
return 0
|
||||
if line.strip().lower().startswith("q"):
|
||||
return 0
|
||||
print("Result is XYZ: %s, D50 Lab: %s" % (XYZ, LAB))
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,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,162 @@
|
||||
import XCTest
|
||||
|
||||
/// Milestone 10 UI tests — issue #146 media recipe library. Mock CUPS
|
||||
/// binaries (`ICCERY_CUPS_BIN_DIR` → `Fixtures/bin`) emit
|
||||
/// `Mock_Epson_7450` / `Mock_Canon_Pro`; recipes are seeded by writing
|
||||
/// `<ICCERY_TEST_ROOT>/AppData/media_library.json` before launch —
|
||||
/// `AppPaths` redirects app data under `ICCERY_TEST_ROOT`. All queries
|
||||
/// are by identifier only ("Media" also appears in help overlays).
|
||||
@MainActor
|
||||
final class Milestone10MediaLibraryUITests: XCTestCase {
|
||||
|
||||
private var app: XCUIApplication!
|
||||
private var testRoot: URL!
|
||||
private var binDir: URL!
|
||||
|
||||
override func setUp() async throws {
|
||||
continueAfterFailure = false
|
||||
testRoot = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("iccery-ui10-\(UUID().uuidString)")
|
||||
binDir = URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Fixtures/bin")
|
||||
|
||||
let appData = testRoot.appendingPathComponent("AppData", isDirectory: true)
|
||||
try FileManager.default.createDirectory(
|
||||
at: appData, withIntermediateDirectories: true)
|
||||
// A recipe bound to a queue that is never enumerated.
|
||||
let fixture = """
|
||||
[
|
||||
{
|
||||
"id": "fixture-missing-queue",
|
||||
"name": "Missing Queue Recipe",
|
||||
"notes": "",
|
||||
"printer_id": "No_Such_Queue",
|
||||
"printer_display_name": "Missing Queue",
|
||||
"paper_name": "Rag",
|
||||
"ink_set": "PK",
|
||||
"colour_space": "rgb",
|
||||
"preset_id": "preset-std-rgb",
|
||||
"calibration_url": null,
|
||||
"apply_calibration": false,
|
||||
"created": "2026-09-12T00:00:00Z",
|
||||
"updated": "2026-09-12T00:00:00Z"
|
||||
}
|
||||
]
|
||||
"""
|
||||
try fixture.write(
|
||||
to: appData.appendingPathComponent("media_library.json"),
|
||||
atomically: true, encoding: .utf8)
|
||||
|
||||
app = XCUIApplication()
|
||||
app.launchEnvironment = [
|
||||
"ICCERY_UI_TESTING": "1",
|
||||
"ICCERY_TEST_ROOT": testRoot.path,
|
||||
"ICCERY_ARGYLL_BINARY_DIR": binDir.path,
|
||||
"ICCERY_CUPS_BIN_DIR": binDir.path,
|
||||
]
|
||||
}
|
||||
|
||||
override func tearDown() async throws {
|
||||
app?.terminate()
|
||||
app = nil
|
||||
if let testRoot {
|
||||
try? FileManager.default.removeItem(at: testRoot)
|
||||
}
|
||||
testRoot = nil
|
||||
}
|
||||
|
||||
private func launchApp() {
|
||||
app.launch()
|
||||
if !app.wait(for: .runningForeground, timeout: 10) {
|
||||
app.activate()
|
||||
}
|
||||
}
|
||||
|
||||
private func element(_ id: String) -> XCUIElement {
|
||||
let inApp = app.descendants(matching: .any)[id].firstMatch
|
||||
if inApp.exists { return inApp }
|
||||
return app.sheets.firstMatch.descendants(matching: .any)[id].firstMatch
|
||||
}
|
||||
|
||||
private func waitFor(_ id: String, timeout: TimeInterval = 10) -> XCUIElement {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
let el = element(id)
|
||||
if el.exists { return el }
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
}
|
||||
let el = element(id)
|
||||
XCTAssertTrue(el.exists, "Expected element \(id)")
|
||||
return el
|
||||
}
|
||||
|
||||
private func waitForEnabled(_ id: String, timeout: TimeInterval = 15) -> XCUIElement {
|
||||
let el = waitFor(id, timeout: timeout)
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if el.isEnabled { return el }
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
}
|
||||
return el
|
||||
}
|
||||
|
||||
func testMediaPickerDoesNotReusePresetSelect() throws {
|
||||
launchApp()
|
||||
|
||||
let preset = app.popUpButtons["presetSelect"]
|
||||
XCTAssertTrue(preset.waitForExistence(timeout: 10))
|
||||
let media = app.popUpButtons["mediaSelect"]
|
||||
XCTAssertTrue(media.waitForExistence(timeout: 10))
|
||||
}
|
||||
|
||||
func testCaptureRequiresNamePaperInk() throws {
|
||||
launchApp()
|
||||
|
||||
// Capture enables once the mock CUPS enumeration selects a queue.
|
||||
let capture = waitForEnabled("btnMediaLibraryCapture")
|
||||
XCTAssertTrue(capture.isEnabled)
|
||||
capture.click()
|
||||
|
||||
_ = waitFor("saveMediaRecipeDialog")
|
||||
let save = element("btnConfirmSaveMedia")
|
||||
XCTAssertTrue(save.exists)
|
||||
XCTAssertFalse(save.isEnabled)
|
||||
|
||||
for (id, text) in [
|
||||
("saveMediaName", "UI Recipe"),
|
||||
("saveMediaPaper", "Rag"),
|
||||
("saveMediaInk", "PK"),
|
||||
] {
|
||||
let field = element(id)
|
||||
field.click()
|
||||
field.typeText(text)
|
||||
}
|
||||
|
||||
XCTAssertTrue(save.isEnabled)
|
||||
}
|
||||
|
||||
func testManageApplyMissingPrinterShowsBanner() throws {
|
||||
launchApp()
|
||||
|
||||
let manage = app.buttons["btnMediaLibraryManage"]
|
||||
XCTAssertTrue(manage.waitForExistence(timeout: 10))
|
||||
manage.click()
|
||||
_ = waitFor("manageMediaDialog")
|
||||
|
||||
let apply = element("btnMediaLibraryApply-fixture-missing-queue")
|
||||
XCTAssertTrue(apply.waitForExistence(timeout: 10))
|
||||
apply.click()
|
||||
|
||||
let notice = waitFor("noticeText")
|
||||
let text = (notice.value as? String) ?? notice.label
|
||||
XCTAssertTrue(
|
||||
text.contains("is not installed"),
|
||||
"expected not-installed notice, got: \(text)")
|
||||
|
||||
// A failed apply keeps the manage sheet open and the sidebar
|
||||
// picker reverts.
|
||||
XCTAssertTrue(element("manageMediaDialog").exists)
|
||||
XCTAssertTrue(element("mediaSelect").exists)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import XCTest
|
||||
|
||||
/// Milestone 10 UI tests — issue #148 spot-read console. Mock Argyll
|
||||
/// sidecars (`ICCERY_ARGYLL_BINARY_DIR` → `Fixtures/bin`) provide
|
||||
/// `instlist`, `chartread`, and `spotread`; no real USB Detect is ever
|
||||
/// clicked. All queries are by identifier only.
|
||||
@MainActor
|
||||
final class Milestone10SpotReadUITests: XCTestCase {
|
||||
|
||||
private var app: XCUIApplication!
|
||||
private var testRoot: URL!
|
||||
private var binDir: URL!
|
||||
private var workDir: URL!
|
||||
|
||||
override func setUp() async throws {
|
||||
continueAfterFailure = false
|
||||
testRoot = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("iccery-ui10spot-\(UUID().uuidString)")
|
||||
binDir = URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Fixtures/bin")
|
||||
workDir = testRoot.appendingPathComponent("work")
|
||||
try FileManager.default.createDirectory(
|
||||
at: workDir, withIntermediateDirectories: true)
|
||||
|
||||
app = XCUIApplication()
|
||||
app.launchEnvironment = [
|
||||
"ICCERY_UI_TESTING": "1",
|
||||
"ICCERY_TEST_ROOT": testRoot.path,
|
||||
"ICCERY_ARGYLL_BINARY_DIR": binDir.path,
|
||||
// Redirect the bundled root too so the real sidecars copied
|
||||
// into the product by the build phase cannot mask a missing
|
||||
// override binary (`testMissingSidecarShowsMessage`).
|
||||
"ICCERY_ARGYLL_BUNDLED_ROOT": binDir.path,
|
||||
"ICCERY_CUPS_BIN_DIR": binDir.path,
|
||||
"ICCERY_TEST_SAVE_TARGET":
|
||||
workDir.appendingPathComponent("mytarget.ti1").path,
|
||||
"ICCERY_TEST_WORKDIR": workDir.path,
|
||||
"ICCERY_TEST_CSV_EXPORT":
|
||||
workDir.appendingPathComponent("spot-history.csv").path,
|
||||
]
|
||||
}
|
||||
|
||||
override func tearDown() async throws {
|
||||
app?.terminate()
|
||||
app = nil
|
||||
if let testRoot {
|
||||
try? FileManager.default.removeItem(at: testRoot)
|
||||
}
|
||||
testRoot = nil
|
||||
}
|
||||
|
||||
private func launchApp() {
|
||||
app.launch()
|
||||
if !app.wait(for: .runningForeground, timeout: 10) {
|
||||
app.activate()
|
||||
}
|
||||
}
|
||||
|
||||
/// Seed `wizard_state.json` with a working directory so `btnSpotRead`
|
||||
/// is enabled without driving the whole Stage 1/2 flow.
|
||||
private func seedWorkingDirectory() throws {
|
||||
let appData = testRoot.appendingPathComponent("AppData", isDirectory: true)
|
||||
try FileManager.default.createDirectory(
|
||||
at: appData, withIntermediateDirectories: true)
|
||||
let state = """
|
||||
{
|
||||
"currentStage": 0,
|
||||
"basename": "spotui",
|
||||
"cwd": "\(workDir.path)",
|
||||
"sessionMode": "profile"
|
||||
}
|
||||
"""
|
||||
try state.write(
|
||||
to: appData.appendingPathComponent("wizard_state.json"),
|
||||
atomically: true, encoding: .utf8)
|
||||
}
|
||||
|
||||
private func element(_ id: String) -> XCUIElement {
|
||||
let inApp = app.descendants(matching: .any)[id].firstMatch
|
||||
if inApp.exists { return inApp }
|
||||
return app.sheets.firstMatch.descendants(matching: .any)[id].firstMatch
|
||||
}
|
||||
|
||||
private func waitFor(_ id: String, timeout: TimeInterval = 10) -> XCUIElement {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
let el = element(id)
|
||||
if el.exists { return el }
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
}
|
||||
let el = element(id)
|
||||
XCTAssertTrue(el.exists, "Expected element \(id)")
|
||||
return el
|
||||
}
|
||||
|
||||
// MARK: - Sidebar gating
|
||||
|
||||
func testSpotReadButtonDisabledWithoutCwd() throws {
|
||||
launchApp()
|
||||
let button = app.buttons["btnSpotRead"]
|
||||
XCTAssertTrue(button.waitForExistence(timeout: 10))
|
||||
XCTAssertFalse(button.isEnabled)
|
||||
}
|
||||
|
||||
func testSpotReadButtonDisabledDuringChartread() throws {
|
||||
launchApp()
|
||||
// Drive to Stage 3 with the mock targen/printtarg fixtures.
|
||||
app.buttons["btnBrowse"].click()
|
||||
app.buttons["btnGenerate"].click()
|
||||
_ = waitFor("btnCreateLayout", timeout: 20)
|
||||
app.buttons["btnCreateLayout"].click()
|
||||
_ = waitFor("galleryPage-0", timeout: 20)
|
||||
_ = waitFor("btnAdvanceToStage3", timeout: 10)
|
||||
app.buttons["btnAdvanceToStage3"].click()
|
||||
_ = waitFor("stage3TargetBasename", timeout: 10)
|
||||
|
||||
// Start the mock chartread — it blocks on the calibrate prompt.
|
||||
app.buttons["btnStartRead"].click()
|
||||
_ = waitFor("btnCalibrate", timeout: 25)
|
||||
|
||||
let button = app.buttons["btnSpotRead"]
|
||||
XCTAssertTrue(button.exists)
|
||||
XCTAssertFalse(button.isEnabled)
|
||||
|
||||
// Clean up the live chartread child before teardown.
|
||||
if app.buttons["btnCancel"].exists {
|
||||
app.buttons["btnCancel"].click()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sheet contract
|
||||
|
||||
func testSheetHasOwnInstrumentIds() throws {
|
||||
try seedWorkingDirectory()
|
||||
launchApp()
|
||||
|
||||
let button = app.buttons["btnSpotRead"]
|
||||
XCTAssertTrue(button.waitForExistence(timeout: 10))
|
||||
let deadline = Date().addingTimeInterval(10)
|
||||
while !button.isEnabled, Date() < deadline {
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
}
|
||||
XCTAssertTrue(button.isEnabled)
|
||||
button.click()
|
||||
|
||||
_ = waitFor("spotReadView", timeout: 10)
|
||||
XCTAssertTrue(element("spotInstrumentSelect").waitForExistence(timeout: 10))
|
||||
// Stage 3 ids must not appear inside the sheet.
|
||||
XCTAssertFalse(
|
||||
app.sheets.firstMatch.descendants(matching: .any)["chartreadInstrumentSelect"].exists)
|
||||
XCTAssertFalse(
|
||||
app.sheets.firstMatch.descendants(matching: .any)["btnDetectInstruments"].exists)
|
||||
XCTAssertTrue(element("btnCloseSpotRead").exists)
|
||||
}
|
||||
|
||||
func testMissingSidecarShowsMessage() throws {
|
||||
// Point the override at an empty dir; the bundled root has no
|
||||
// real sidecars in this checkout, so resolve() misses both.
|
||||
let emptyBin = testRoot.appendingPathComponent("empty-bin")
|
||||
try FileManager.default.createDirectory(
|
||||
at: emptyBin, withIntermediateDirectories: true)
|
||||
app.launchEnvironment["ICCERY_ARGYLL_BINARY_DIR"] = emptyBin.path
|
||||
try seedWorkingDirectory()
|
||||
launchApp()
|
||||
|
||||
let button = app.buttons["btnSpotRead"]
|
||||
XCTAssertTrue(button.waitForExistence(timeout: 10))
|
||||
let deadline = Date().addingTimeInterval(10)
|
||||
while !button.isEnabled, Date() < deadline {
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
}
|
||||
button.click()
|
||||
|
||||
_ = waitFor("spotReadView", timeout: 10)
|
||||
XCTAssertTrue(element("spotSidecarMissing").waitForExistence(timeout: 10))
|
||||
XCTAssertFalse(element("btnSpotStart").exists)
|
||||
XCTAssertFalse(element("btnSpotDetectInstruments").exists)
|
||||
XCTAssertTrue(element("btnCloseSpotRead").exists)
|
||||
}
|
||||
|
||||
func testHistoryCopyDisabledWhenEmpty() throws {
|
||||
try seedWorkingDirectory()
|
||||
launchApp()
|
||||
|
||||
let button = app.buttons["btnSpotRead"]
|
||||
XCTAssertTrue(button.waitForExistence(timeout: 10))
|
||||
let deadline = Date().addingTimeInterval(10)
|
||||
while !button.isEnabled, Date() < deadline {
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
}
|
||||
button.click()
|
||||
|
||||
_ = waitFor("spotReadView", timeout: 10)
|
||||
XCTAssertTrue(element("spotHistoryEmpty").waitForExistence(timeout: 10))
|
||||
XCTAssertTrue(element("spotLastEmpty").exists)
|
||||
XCTAssertFalse(element("btnSpotCopyLab").isEnabled)
|
||||
XCTAssertFalse(element("btnSpotExportCsv").isEnabled)
|
||||
}
|
||||
|
||||
/// Full mock session: Start → Calibrate → Read produces one Lab
|
||||
/// sample and enables Copy/Export.
|
||||
func testMockSessionProducesSample() throws {
|
||||
try seedWorkingDirectory()
|
||||
launchApp()
|
||||
|
||||
let button = app.buttons["btnSpotRead"]
|
||||
XCTAssertTrue(button.waitForExistence(timeout: 10))
|
||||
let deadline = Date().addingTimeInterval(10)
|
||||
while !button.isEnabled, Date() < deadline {
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
}
|
||||
button.click()
|
||||
|
||||
_ = waitFor("spotReadView", timeout: 10)
|
||||
let start = element("btnSpotStart")
|
||||
XCTAssertTrue(start.waitForExistence(timeout: 10))
|
||||
start.click()
|
||||
|
||||
XCTAssertTrue(element("btnSpotCalibrate").waitForExistence(timeout: 15))
|
||||
element("btnSpotCalibrate").click()
|
||||
|
||||
XCTAssertTrue(element("btnSpotTrigger").waitForExistence(timeout: 15))
|
||||
element("btnSpotTrigger").click()
|
||||
|
||||
XCTAssertTrue(element("spotLastSample").waitForExistence(timeout: 15))
|
||||
XCTAssertTrue(element("spotLabL").exists)
|
||||
XCTAssertTrue(element("spotSwatch").exists)
|
||||
XCTAssertTrue(element("btnSpotCopyLab").isEnabled)
|
||||
XCTAssertTrue(element("btnSpotExportCsv").isEnabled)
|
||||
|
||||
element("btnSpotStop").click()
|
||||
element("btnCloseSpotRead").click()
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,16 @@ final class Milestone2UITests: XCTestCase {
|
||||
return el
|
||||
}
|
||||
|
||||
/// Assert an element stays absent after a short dwell — unlike
|
||||
/// `waitForExistence`, which always burns its full timeout on the
|
||||
/// negative path.
|
||||
private func assertAbsent(_ el: XCUIElement, dwell: TimeInterval = 0.5,
|
||||
_ message: String = "expected element to stay absent",
|
||||
file: StaticString = #filePath, line: UInt = #line) {
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(dwell))
|
||||
XCTAssertFalse(el.exists, message, file: file, line: line)
|
||||
}
|
||||
|
||||
private func staticText(_ exact: String) -> XCUIElement {
|
||||
let inApp = app.staticTexts[exact]
|
||||
if inApp.exists { return inApp }
|
||||
@@ -316,7 +326,7 @@ final class Milestone2UITests: XCTestCase {
|
||||
"identifier BEGINSWITH 'btnDeletePreset-'")
|
||||
XCTAssertTrue(deleteButtons.firstMatch.waitForExistence(timeout: 5))
|
||||
deleteButtons.firstMatch.click()
|
||||
XCTAssertFalse(staticText("UI Test Preset").waitForExistence(timeout: 3))
|
||||
assertAbsent(staticText("UI Test Preset"))
|
||||
}
|
||||
|
||||
/// Export a preset to JSON and re-import it (issue #11).
|
||||
@@ -348,7 +358,7 @@ final class Milestone2UITests: XCTestCase {
|
||||
let deleteButtons = buttonsMatching(
|
||||
"identifier BEGINSWITH 'btnDeletePreset-'")
|
||||
deleteButtons.firstMatch.click()
|
||||
XCTAssertFalse(staticText("RoundTrip").waitForExistence(timeout: 3))
|
||||
assertAbsent(staticText("RoundTrip"))
|
||||
|
||||
// Copy the export to the import path so the hook picks it up.
|
||||
try FileManager.default.copyItem(at: exportURL, to: importURL)
|
||||
|
||||
@@ -198,7 +198,37 @@ final class Milestone3UITests: XCTestCase {
|
||||
}
|
||||
XCTAssertTrue(app.buttons["btnPrintAll"].isEnabled)
|
||||
|
||||
app.buttons["btnPrintPage-0"].click()
|
||||
// The gallery cell's Print button sits at the window's bottom
|
||||
// edge where synthesized scroll-wheel events are inert on the
|
||||
// LazyVGrid (#132). Drag the NSScrollView's vertical AXScrollBar
|
||||
// thumb instead — a real scroll that re-renders the cell onscreen.
|
||||
var printPage = app.buttons["btnPrintPage-0"]
|
||||
let scrollDeadline = Date().addingTimeInterval(15)
|
||||
while !printPage.isHittable, Date() < scrollDeadline {
|
||||
let scroller = app.scrollBars.allElementsBoundByIndex
|
||||
.first { $0.frame.height > $0.frame.width }
|
||||
if let scroller {
|
||||
scroller.coordinate(withNormalizedOffset:
|
||||
CGVector(dx: 0.5, dy: 0.1))
|
||||
.press(forDuration: 0.1, thenDragTo:
|
||||
scroller.coordinate(withNormalizedOffset:
|
||||
CGVector(dx: 0.5, dy: 0.6)))
|
||||
} else {
|
||||
app.scrollViews["stage-2"].scroll(byDeltaX: 0, deltaY: -1)
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
|
||||
printPage = app.buttons["btnPrintPage-0"]
|
||||
}
|
||||
if printPage.isHittable {
|
||||
printPage.click()
|
||||
} else {
|
||||
// LazyVGrid cells can report a stale a11y frame — click the
|
||||
// point directly; the lp argv assert below still verifies.
|
||||
print("AXTREE-BEGIN frame=\(printPage.frame)\n" +
|
||||
"\(app.debugDescription)\nAXTREE-END")
|
||||
printPage.coordinate(withNormalizedOffset:
|
||||
CGVector(dx: 0.5, dy: 0.5)).click()
|
||||
}
|
||||
let argv = waitForLpLine()
|
||||
XCTAssertTrue(argv.contains("AP_ColorMatchingMode"), argv)
|
||||
XCTAssertTrue(argv.contains("page1.tif"), argv)
|
||||
|
||||
@@ -111,16 +111,9 @@ final class Milestone4UITests: XCTestCase {
|
||||
}
|
||||
app.buttons["btnCalibrate"].click()
|
||||
|
||||
// Trigger strip A.
|
||||
_ = waitFor("btnTrigger", timeout: 20)
|
||||
app.buttons["btnTrigger"].click()
|
||||
|
||||
// Trigger strip B.
|
||||
_ = waitFor("btnTrigger", timeout: 20)
|
||||
app.buttons["btnTrigger"].click()
|
||||
|
||||
// All strips read → Done & Save appears.
|
||||
_ = waitFor("btnDoneRead", timeout: 20)
|
||||
// Trigger each strip until all are read → Done & Save appears.
|
||||
driveStripsUntilDone()
|
||||
XCTAssertTrue(element("btnDoneRead").exists)
|
||||
app.buttons["btnDoneRead"].firstMatch.click()
|
||||
|
||||
// Averaging panel appears with one pass snapshot.
|
||||
@@ -186,11 +179,21 @@ final class Milestone4UITests: XCTestCase {
|
||||
start.click()
|
||||
_ = waitFor("btnCalibrate", timeout: 25)
|
||||
app.buttons["btnCalibrate"].click()
|
||||
_ = waitFor("btnTrigger", timeout: 20)
|
||||
app.buttons["btnTrigger"].click()
|
||||
_ = waitFor("btnTrigger", timeout: 20)
|
||||
app.buttons["btnTrigger"].click()
|
||||
_ = waitFor("btnDoneRead", timeout: 20)
|
||||
driveStripsUntilDone()
|
||||
XCTAssertTrue(element("btnDoneRead").exists)
|
||||
app.buttons["btnDoneRead"].firstMatch.click()
|
||||
}
|
||||
|
||||
/// Clicks Trigger for each remaining strip until `btnDoneRead`
|
||||
/// appears — the button is re-polled each pass so a click that
|
||||
/// races a state transition isn't lost.
|
||||
private func driveStripsUntilDone() {
|
||||
let deadline = Date().addingTimeInterval(40)
|
||||
while !element("btnDoneRead").exists, Date() < deadline {
|
||||
if app.buttons["btnTrigger"].waitForExistence(timeout: 10) {
|
||||
app.buttons["btnTrigger"].click()
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -695,7 +695,7 @@ Consumes ICC/ICM. Produces `{stem}.gam` next to the profile (Argyll default). Bu
|
||||
|---|---|
|
||||
| `dispwin` | Never spawned. ICCery#90 “Emissive display calibration (dispwin & dispread)” = Won't Fix |
|
||||
| `dispread` | Same |
|
||||
| `spotread`, `dispcal`, `collink`, `cctiff`, `spec2cie`, `illumread`, `synthacc` | Not referenced |
|
||||
| `dispcal`, `collink`, `cctiff`, `spec2cie`, `illumread`, `synthacc` | Not referenced |
|
||||
| Generic `spawn_process` | **Registered** (`lib.rs:55`, `commands.rs:6–14`) but **no JS caller**. Always `cwd=None`. Exists as an escape hatch |
|
||||
|
||||
---
|
||||
@@ -725,6 +725,7 @@ The `Child` itself lives only in the wait task (not in a map) so `wait()` cannot
|
||||
| `profcheck_{ti3_path}` | profcheck (full path) |
|
||||
| `iccgamut_{stem}` | iccgamut |
|
||||
| `instlist` | instlist (literal) |
|
||||
| `spotread` | spotread (literal, issue #148) |
|
||||
| caller-supplied | unused `spawn_process` |
|
||||
|
||||
### 12.3 Duplicate rejection (ICCery#116, `07d28eb`)
|
||||
@@ -830,6 +831,7 @@ From `lib.rs:54–119` plus the command bodies:
|
||||
| `run_profcheck` | profcheck | `profcheck_{ti3_path}` |
|
||||
| `extract_gamut` | iccgamut | `iccgamut_{stem}` |
|
||||
| `detect_instruments` | instlist | `instlist` |
|
||||
| `run_spotread` | spotread (`-v -e [-c port] [-Y l]`, no `-u`) | `spotread` |
|
||||
| `generate_calibration_target` | targen | `targen_{CAL_basename}` |
|
||||
|
||||
### Argyll runners (captured, no events)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -80,3 +80,14 @@ If an unpatched binary rejects `-Y l`, capture last stderr line and expand Proce
|
||||
## Interactive buttons vs real keys
|
||||
|
||||
See [05](05-argyll-fork.md) §12. Real strip-mode keys are `f/b/n/d/q`, Space, Return, `y/n`. UI labels "Skip" / "Undo" send `s\n` / `u\n` which the **mock** understands; upstream strip mode treats unknown letters as trigger. Preserve current UI behaviour or document a protocol change — do not silently change what bytes are sent without updating tests.
|
||||
|
||||
## Spot Read (issue #148)
|
||||
|
||||
The Spot Read sheet (`btnSpotRead` in the sidebar) runs the bundled
|
||||
`spotread` sidecar under the single-lease process id `spotread` — it is
|
||||
**not** Stage 3 and shares no identifiers or process ids with
|
||||
`chartread`. Stage 3 is unchanged: `chartread_{basename}` remains the
|
||||
only chart path. `spotread` argv is `-v -e [-c port] [-Y l]` — never
|
||||
`-u` (the v2.0 `-u` policy covers printtarg + chartread + profcheck
|
||||
only). Stdin reuses the `chartread` byte table (`" \n"` trigger,
|
||||
`"q\n"` quit + ~500 ms + kill).
|
||||
|
||||
@@ -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).
|
||||
|
||||
+12
-5
File diff suppressed because one or more lines are too long
@@ -7,7 +7,7 @@ Persisted at `{app_data}/settings.json` via `load_settings` / `save_settings`. I
|
||||
| Field | Default | Notes |
|
||||
|-------|---------|-------|
|
||||
| `argyll_binary_dir` | `null` | Overrides bundled sidecars. `resolve_binary` checks this first. |
|
||||
| `default_instrument` | `null` | **Stored but not applied to argv.** Stage 2 `#instrumentSelect` is the source of truth. Do not start honouring this without an explicit product decision. |
|
||||
| `default_instrument` | `null` | Stored; seeds the Spot Read instrument picker when that instrument is present (#148). **Never** written into `printtarg -i` or `targen` argv. Stage 2 `#instrumentSelect` remains the printtarg instrument. |
|
||||
| `log_level` | `null` | `error` / `warn` / `info` / `debug` / `trace`. `null` → Debug in debug builds, Info in release. Applied at startup **and** on save (#158). |
|
||||
| `delta_e_good_max` | `2.0` | Stage 3 swatch traffic-light "Good". Must be ≥ 0. |
|
||||
| `delta_e_warning_max` | `5.0` | Stage 3 "Warning" band. Must be **strictly greater** than good. |
|
||||
@@ -67,3 +67,25 @@ Built-ins cannot be deleted. Custom presets overlay by `id`. Import/export is JS
|
||||
All four: `instrument: "i1"`, `colprof_algorithm: "l"`, `random_seed: 1`, `no_randomize: false`, `colprof_fwa: "D50"`.
|
||||
|
||||
UI: `#presetSelect`, `#btnSavePresetModal` → `#savePresetDialog` (`savePresetName`, `savePresetDesc`, `btnConfirmSavePreset`), `#btnOpenPresetsDialog` → `#managePresetsDialog` (`managePresetsList`, `btnExportActivePreset`, `btnImportPreset`).
|
||||
|
||||
## Media library (`media_library.json`)
|
||||
|
||||
Persisted at `{app_data}/media_library.json` — a sibling of `settings.json`, never a field inside it (issue #146). A `MediaRecipe` binds a CUPS queue + paper + ink set + optional `.cal` to a `ProfilingPreset`. Cap: 200 entries; the 201st is refused with an error, never silently evicted. Corrupt JSON → keep the file, load `[]`, persistent warning banner.
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------|------|-------|
|
||||
| `id` | string | `recipe-<uuid>`, never user-typed |
|
||||
| `name`, `notes` | string | Rendered through `Text` only (#114) |
|
||||
| `printer_id` | string | CUPS queue id (`lpstat -e` name) |
|
||||
| `printer_display_name` | string | Human label; applied to `wizard.printerName` |
|
||||
| `paper_name`, `ink_set` | string | Library metadata only — never written to targen flags |
|
||||
| `driver_media_type` | string? | Last captured CUPS `media_type` (read-only) |
|
||||
| `colour_space` | `"rgb"` \| `"cmyk"` | Must match the bound preset |
|
||||
| `preset_id` | string | `ProfilingPreset.id` (built-in or custom) |
|
||||
| `calibration_url` | string? | Absolute `.cal` path, stored verbatim |
|
||||
| `apply_calibration` | bool | Forced off for `CAL_` stems or missing files |
|
||||
| `created`, `updated` | iso8601 | |
|
||||
|
||||
Apply path: recipe → `applyPreset` (#82 mapping, no second Stage 1 form) → queue re-enumerated (`lpstat -e`) → `printer_id` absent from a non-empty list warns "not installed" and leaves the queue untouched; an empty list is indeterminate and never flags. `CAL_` bound cal **or** a live `CAL_` wizard basename forces `applyCalibration` off — `printtarg -K` can never see a `CAL_` file (literal refusal; escape hatch is rename + re-capture). Staleness: `.printer` = absent from enumerated queues; `.calibration` = bound cal `CREATED + calibration_stale_days < now`.
|
||||
|
||||
Capture with no preset selected auto-snapshots the live form as a `custom-` preset and binds to it. UI: `#mediaSelect` (immediate apply, `#presetSelect`-style), `#btnMediaLibraryCapture` → `#saveMediaRecipeDialog`, `#btnMediaLibraryManage` → `#manageMediaDialog` (`mediaLibraryList`, `mediaRow-{id}`, `btnMediaLibraryApply-{id}`, `btnMediaLibraryDelete-{id}`), stale badge `#mediaRecipeStale`.
|
||||
|
||||
@@ -66,7 +66,7 @@ Do not create issues until labels and milestones exist.
|
||||
- Artefact gating on disk; atomic writes (`.tmp` + rename); user strings via SwiftUI `Text` only.
|
||||
- Branching: `develop` ← `milestone/mN-<name>` ← `feat/<issue#>-<slug>`; PRs via Gitea MCP.
|
||||
- Labels: every issue/PR has `Project/ICCery-v2` + one `Feature/*` or `Bug/*` + `Priority/*`.
|
||||
- Verify: `xcodebuild test -scheme ICCery -destination 'platform=macOS' ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO`; sidecar `codesign -dvv`.
|
||||
- Verify: `xcodebuild test -scheme ICCery -destination 'platform=macOS' ARCHS="$(uname -m)"` (host arch; universal reserved for release packaging); sidecar `codesign -dvv`.
|
||||
- Private ColorSync SPI: 2-arg `(PMPrintSession, CFStringRef) -> OSStatus`. Never pass integer `1`.
|
||||
|
||||
### `BUILD-PLAN.md`
|
||||
@@ -566,7 +566,7 @@ Labels: `Feature/DevOps`, `Priority/High`
|
||||
Milestone: M6
|
||||
|
||||
- **Self-hosted Mac runner** (Gitea has no `macos-latest` unless you attach one). Optional GitHub Actions mirror.
|
||||
- Pipeline: `fetch-argyll` → ad-hoc `codesign -s -` + `codesign -dvv` on every sidecar Mach-O (hard fail) → `xcodebuild build test -scheme ICCery ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO` → unit + mock fixtures (#215) → **dmgbuild** with background art (**not** Finder AppleScript, #189) → upload artefact.
|
||||
- Pipeline: `fetch-argyll` → ad-hoc `codesign -s -` + `codesign -dvv` on every sidecar Mach-O (hard fail) → `xcodebuild build test -scheme ICCery ARCHS="$(uname -m)"` (host arch; the dmgbuild leg still builds universal) → unit + mock fixtures (#215) → **dmgbuild** with background art (**not** Finder AppleScript, #189) → upload artefact.
|
||||
- App signing: Developer ID + **notarize/staple** for the `.app` / `.dmg`. Sidecars remain **ad-hoc** inside the bundle (#165). These are two different gates — do not conflate.
|
||||
- Confirm entitlements: sandbox **false**.
|
||||
- Spec: [04](04-argyll-binaries.md) §0.6, [05](05-argyll-fork.md) §8–9, [23](23-assets.md), [24](24-issues-invariants.md).
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ Unsigned CI artefacts (e.g. a `.zip` from a non-notarized workflow run) are **no
|
||||
scripts/fetch-argyll.sh # populates Vendor/Argyll and signs sidecars
|
||||
xcodegen generate --project .
|
||||
xcodebuild test -scheme ICCery -destination 'platform=macOS' \
|
||||
ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO \
|
||||
ARCHS="$(uname -m)" \
|
||||
CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY='-'
|
||||
```
|
||||
|
||||
|
||||
+7
-4
@@ -19,6 +19,7 @@ targets:
|
||||
- path: Resources
|
||||
excludes:
|
||||
- ICCery.entitlements
|
||||
- ICCery.Debug.entitlements
|
||||
- Argyll
|
||||
- path: Resources/Argyll
|
||||
type: folder
|
||||
@@ -63,12 +64,14 @@ targets:
|
||||
MACOSX_DEPLOYMENT_TARGET: "12.0"
|
||||
ARCHS: "$(ARCHS_STANDARD)"
|
||||
# Debug builds sign ad-hoc; hardened-runtime library validation would
|
||||
# reject the unsigned embedded ICCeryCore package framework when the
|
||||
# test host launches (run 31992, #119). Release keeps validation and
|
||||
# links the package statically anyway.
|
||||
# reject the embedded ICCeryCore package framework (no Team ID) when
|
||||
# the test host launches (run 31992, #119). DISABLE_LIBRARY_VALIDATION
|
||||
# does not inject the entitlement on Xcode 14.2, so use a dedicated
|
||||
# Debug entitlements file. Release keeps validation and links the
|
||||
# package statically anyway.
|
||||
configs:
|
||||
Debug:
|
||||
DISABLE_LIBRARY_VALIDATION: YES
|
||||
CODE_SIGN_ENTITLEMENTS: Resources/ICCery.Debug.entitlements
|
||||
|
||||
ICCeryCoreTests:
|
||||
type: bundle.unit-test
|
||||
|
||||
Reference in New Issue
Block a user