Compare commits

..
Author SHA1 Message Date
gronod fa9a2042bf chore: simplify megaplans gitignore to directory-level pattern
Remove redundant `docs/megaplans/*` pattern, keeping only `docs/megaplans` to ignore the entire directory.
2026-09-11 12:01:08 +01:00
130 changed files with 2156 additions and 10334 deletions
+5 -66
View File
@@ -14,59 +14,28 @@ jobs:
build-and-test:
# Prefer a self-hosted Mac runner if your Gitea has one. If not,
# macos-14 works for this pipeline.
runs-on: macos-12
runs-on: macos-14
env:
DERIVED: build/DerivedData-test
steps:
- name: Checkout
uses: actions/checkout@v4
- 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
# prebuilt release instead.
- name: Ensure host tools
run: scripts/ensure-host-tools.sh
- name: Generate Xcode project
run: xcodegen generate --spec project.yml
# 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)
- name: Build for testing (universal)
run: |
xcodebuild build-for-testing \
-scheme ICCery \
-destination 'platform=macOS' \
-derivedDataPath "$DERIVED" \
-configuration Debug \
ARCHS="$(uname -m)" \
ARCHS='arm64 x86_64' \
ONLY_ACTIVE_ARCH=NO \
CODE_SIGNING_ALLOWED=YES \
CODE_SIGN_IDENTITY='-'
# Xcode embeds the shared ICCeryCore package framework into the app
# and the test bundle without signing it. Ad-hoc hosts still require
# every loaded dylib to carry a cdhash — dyld killed the test host at
# launch (run 31992) — so sign every embedded copy once the build is
# done (embed steps run after any build script phase) (#119).
- name: Sign package product frameworks
run: |
find "$DERIVED/Build/Products/Debug" -depth -name '*_PackageProduct.framework' -print0 \
| while IFS= read -r -d '' fw; do
echo "signing $fw"
codesign --force --sign - --timestamp=none "$fw"
done
- name: Test unit (ICCeryCoreTests)
run: |
XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)"
@@ -133,8 +102,7 @@ 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 \
-skip-testing:ICCeryUITests/AboutHelpUITests/testAboutDialogShowsVersionAndBuildDate; then
if run_ui "full suite attempt $attempt" -only-testing:ICCeryUITests; then
exit 0
fi
if is_runner_attach_failure; then
@@ -150,43 +118,14 @@ 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
runs-on: macos-14
if: github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/tags/v')
steps:
- name: Checkout
uses: actions/checkout@v4
# scripts/package-release.sh runs `xcodegen generate` and dmgbuild;
# see build-and-test for why brew is not used on macOS 12 (#109).
- name: Ensure host tools
run: scripts/ensure-host-tools.sh
- name: Package release
run: scripts/package-release.sh
env:
-1
View File
@@ -27,5 +27,4 @@ ICCery.xcodeproj/
Release/
notarization/
build/
docs/megaplans/*
docs/megaplans
+1 -2
View File
@@ -45,10 +45,9 @@ PRs via Gitea MCP. Every issue/PR: `Project/ICCery-v2` + `Feature/*` or `Bug/*`
## Verify
```
xcodebuild test -scheme ICCery -destination 'platform=macOS' ARCHS="$(uname -m)"
xcodebuild test -scheme ICCery -destination 'platform=macOS' ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
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`.
+2 -7
View File
@@ -1,6 +1,6 @@
# BUILD-PLAN.md — ICCery v2 Mac
Spec snapshot: `docs/`. Source of tickets: Gitea milestones M1M6 + M10 (id 32) + Later.
Spec snapshot: `docs/`. Source of tickets: Gitea milestones M1M6 + Later.
## Sprint rule
Do not start milestone N+1 implementation until milestone N **CI/mock gate** is green.
@@ -17,14 +17,9 @@ Hardware gates block *release of that sprint*, not filing, and not starting codi
| M5 | Profile / verify / install | 2327 | colprof → `.icc`; profcheck parse; atomic history; install into temp dir | Full `.ti1``.icc`; profile visible in ColorSync Utility |
| M6 | Gamut, Stage 0, CGATS, release | 2832 | `.gam` fixtures; cal argv; CGATS round-trip; signed sidecars; dmgbuild | Stage 0 on a real printer; gamut of a real profile |
| M7 | Deduplicate & consolidate | 7986 | Shared runner loop; JSONFileStore; preset↔config maps; Notice/log helper; ProcessManager factory; PrintSession VM; identity + colour-type cleanup | N/A |
| M8 | Deduplicate & consolidate | 7986 | (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 | 146149 | 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 |
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.**
Issue **16 is not an M3 or M6 exit gate.**
## Branch taxonomy
+2 -2
View File
@@ -1,9 +1,9 @@
// swift-tools-version: 5.7
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "ICCeryCore",
platforms: [.macOS(.v12)],
platforms: [.macOS(.v14)],
products: [
.library(name: "ICCeryCore", targets: ["ICCeryCore"]),
],
@@ -192,18 +192,6 @@ 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).
@@ -212,7 +200,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: Self.testAwareDelay(100_000_000))
try? await Task.sleep(for: .milliseconds(100))
attempts += 1
}
}
@@ -255,7 +243,7 @@ public struct ArgyllRunner: Sendable {
if flushPartialLines {
dotFlushTask = Task { [processManager] in
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 500_000_000)
try? await Task.sleep(for: .milliseconds(500))
if Task.isCancelled { break }
await processManager.flushPartialLine(id: processId)
}
@@ -604,7 +592,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: Self.testAwareDelay(500_000_000))
try? await Task.sleep(for: .milliseconds(500))
}
}
@@ -742,148 +730,6 @@ 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`.
@@ -970,20 +816,6 @@ 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"
@@ -56,19 +56,23 @@ public enum PrinttargArgs {
}
args.append(contentsOf: ["-R", "\(config.customSeed)"])
case .raster:
args.append(contentsOf: ArgsBuilder.flag("-r", when: true))
args.append("-r")
}
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-d", config.label))
if let label = config.label?.trimmingCharacters(in: .whitespacesAndNewlines),
!label.isEmpty {
args.append(contentsOf: ["-d", label])
}
guard (72...600).contains(config.dpi) else {
throw PrinttargArgError.invalidDPI(config.dpi)
}
args.append(contentsOf: [config.bitDepth.flag, "\(config.dpi)"])
if !CalibrationIdentity.isCalibration(cleanBasename) {
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty(
config.calibrationEmbedOnly ? "-I" : "-K", config.calibrationFile))
if !CalibrationIdentity.isCalibration(cleanBasename),
let cal = config.calibrationFile?.trimmingCharacters(in: .whitespacesAndNewlines),
!cal.isEmpty {
args.append(contentsOf: [config.calibrationEmbedOnly ? "-I" : "-K", cal])
}
args.append(cleanBasename)
@@ -1,28 +0,0 @@
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
}
}
@@ -1,37 +0,0 @@
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
}
}
@@ -70,12 +70,18 @@ public enum TargenArgs {
if let n = config.neutralSteps, n > 0 {
args.append(contentsOf: ["-n", "\(n)"])
}
args.append(contentsOf: ArgsBuilder.optionUnlessApprox("-N", config.neutralConcentration, skip: 0.50))
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-c", config.preconditioningProfile))
args.append(contentsOf: ArgsBuilder.flag("-G", when: config.ofpsHighQuality == true))
args.append(contentsOf: ArgsBuilder.option("-A", config.ofpsAdaptation.map {
String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), $0)
}))
if let nConc = config.neutralConcentration, abs(nConc - 0.50) >= 0.001 {
args.append(contentsOf: ["-N", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), nConc)])
}
if let c = config.preconditioningProfile?.trimmingCharacters(in: .whitespacesAndNewlines), !c.isEmpty {
args.append(contentsOf: ["-c", c])
}
if config.ofpsHighQuality == true {
args.append("-G")
}
if let a = config.ofpsAdaptation {
args.append(contentsOf: ["-A", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), a)])
}
if let algFlag = config.fullSpreadAlgorithm?.flag {
args.append(algFlag)
}
@@ -85,9 +91,11 @@ public enum TargenArgs {
}
args.append(contentsOf: ["-l", "\(inkLimit)"])
}
args.append(contentsOf: ArgsBuilder.optionUnlessApprox("-V", config.darkEmphasis, skip: 1.0))
if let p = config.devicePower, p > 0 {
args.append(contentsOf: ArgsBuilder.optionUnlessApprox("-p", p, skip: 1.0))
if let v = config.darkEmphasis, abs(v - 1.0) >= 0.001 {
args.append(contentsOf: ["-V", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), v)])
}
if let p = config.devicePower, p > 0, abs(p - 1.0) >= 0.001 {
args.append(contentsOf: ["-p", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), p)])
}
args.append(cleanBasename)
@@ -72,8 +72,8 @@ public enum CGATSParser {
public static func parse(
_ contents: String,
sourceURL: URL? = nil
) throws -> CGATSDataset {
guard !contents.isEmpty else { throw CGATSParseError.emptyFile }
) throws(CGATSParseError) -> CGATSDataset {
guard !contents.isEmpty else { throw .emptyFile }
let ext = sourceURL?.pathExtension.lowercased() ?? ""
let isCSV = ext == "csv" || contents.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -101,10 +101,10 @@ public enum CGATSParser {
}
guard let formatStart, let formatEnd, formatEnd > formatStart + 1 else {
throw CGATSParseError.missingBeginDataFormat
throw .missingBeginDataFormat
}
guard let dataStart, let dataEnd, dataEnd > dataStart + 1 else {
throw CGATSParseError.missingBeginData
throw .missingBeginData
}
let rawFieldNames = splitFields(lines[formatStart + 1])
@@ -139,7 +139,7 @@ public enum CGATSParser {
let lineIndex = dataStart + offset
let rawRow = splitFields(lines[lineIndex])
guard rawRow.count == fieldNames.count else {
throw CGATSParseError.incorrectArity(line: lineIndex + 1, expected: fieldNames.count, got: rawRow.count)
throw .incorrectArity(line: lineIndex + 1, expected: fieldNames.count, got: rawRow.count)
}
var sample = RawSample(id: String(offset), lineIndex: lineIndex)
@@ -153,7 +153,7 @@ public enum CGATSParser {
groupMax[group, default: 0] = max(groupMax[group, default: 0], number)
}
} else if !cleaned.isEmpty {
throw CGATSParseError.nonNumericValue(field: name, value: raw, line: lineIndex + 1)
throw .nonNumericValue(field: name, value: raw, line: lineIndex + 1)
}
} else {
sample.strings[name] = raw
@@ -213,7 +213,7 @@ public enum CGATSParser {
private static func preprocess(
_ contents: String,
isCSV: Bool
) throws -> (CGATSFormat, [String]) {
) throws(CGATSParseError) -> (CGATSFormat, [String]) {
let allLines = contents.components(separatedBy: .newlines)
var lines = [String]()
@@ -239,7 +239,7 @@ public enum CGATSParser {
lines.append(line)
}
guard !lines.isEmpty else { throw CGATSParseError.emptyFile }
guard !lines.isEmpty else { throw .emptyFile }
// Wrap a bare CSV / ISO28178 file in the canonical CGATS block
// structure so the boundary-based parser below can handle it.
@@ -1,64 +0,0 @@
import Foundation
/// Approximate sRGB CIELab D50 conversion for the gamut inspect panel
/// (issue #147).
///
/// This is a fixed-matrix helper, **not** a colour-management module: it
/// never touches ICC profiles, ColorSync, or lcms. The UI labels its
/// output "approx. Lab, not ColorSync".
public enum ApproximateLab {
/// linear-sRGB XYZ (D65) matrix, IEC 61966-2-1.
private static let srgbToXYZ: [[Double]] = [
[0.4124, 0.3576, 0.1805],
[0.2126, 0.7152, 0.0722],
[0.0193, 0.1192, 0.9505],
]
/// 8-bit sRGB triple Lab D50 (approximate).
public static func srgb8ToLab(r: Int, g: Int, b: Int) -> LabColor {
srgbToLab(DisplayRGB(
r: Double(r) / 255.0,
g: Double(g) / 255.0,
b: Double(b) / 255.0))
}
/// 01 sRGB triple Lab D50 (approximate).
public static func srgbToLab(_ rgb: DisplayRGB) -> LabColor {
func linear(_ c: Double) -> Double {
c <= 0.04045 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4)
}
let v = [linear(rgb.r), linear(rgb.g), linear(rgb.b)]
// 01 XYZ D65 the 0100 scale `LabColorMath` works in.
let xyz65 = XYZColor(
x: (srgbToXYZ[0][0] * v[0] + srgbToXYZ[0][1] * v[1] + srgbToXYZ[0][2] * v[2]) * 100,
y: (srgbToXYZ[1][0] * v[0] + srgbToXYZ[1][1] * v[1] + srgbToXYZ[1][2] * v[2]) * 100,
z: (srgbToXYZ[2][0] * v[0] + srgbToXYZ[2][1] * v[1] + srgbToXYZ[2][2] * v[2]) * 100)
return LabColorMath.xyzToLab(adaptD65ToD50(xyz65))
}
/// Bradford D65 D50 chromatic adaptation the mirror of
/// `LabColorMath.adaptD50ToD65`.
private static func adaptD65ToD50(_ xyz: XYZColor) -> XYZColor {
let m = LabColorMath.bradford
let inv = LabColorMath.bradfordInv
let d65 = LabColorMath.d65White
let d50 = LabColorMath.d50White
let source = multiply(m, [xyz.x, xyz.y, xyz.z])
let srcWhite = multiply(m, [d65.X, d65.Y, d65.Z])
let dstWhite = multiply(m, [d50.X, d50.Y, d50.Z])
let scaled = [
source[0] * (dstWhite[0] / srcWhite[0]),
source[1] * (dstWhite[1] / srcWhite[1]),
source[2] * (dstWhite[2] / srcWhite[2]),
]
return XYZColor(
x: inv[0][0] * scaled[0] + inv[0][1] * scaled[1] + inv[0][2] * scaled[2],
y: inv[1][0] * scaled[0] + inv[1][1] * scaled[1] + inv[1][2] * scaled[2],
z: inv[2][0] * scaled[0] + inv[2][1] * scaled[1] + inv[2][2] * scaled[2])
}
private static func multiply(_ m: [[Double]], _ v: [Double]) -> [Double] {
m.map { row in zip(row, v).reduce(0) { $0 + $1.0 * $1.1 } }
}
}
@@ -1,115 +0,0 @@
import Foundation
import simd
/// Result of a point-in-gamut test (issue #147).
public enum GamutContainment: String, Sendable, Equatable {
/// The point lies inside the mesh volume.
case inside
/// The point lies outside the mesh volume.
case outside
/// The mesh has no faces to test against.
case unknown
}
/// Point-in-mesh containment and volume estimation for ``GamutMesh``.
///
/// Both tests run on the scene-space positions stored on
/// ``GamutVertex/position`` (`x = a*`, `y = L*`, `z = b*`), the same
/// mapping the SceneKit viewer uses. No colour management is involved.
public enum GamutGeometry {
/// Whether `lab` is inside `mesh`.
///
/// Ray-casts through the face list: an odd crossing count means the
/// point is inside a closed surface. A ray that grazes a vertex or
/// edge gives an ambiguous count, so the test retries with off-axis
/// directions before answering. Meshes without faces report
/// ``GamutContainment/unknown``.
public static func containment(of lab: LabColor, in mesh: GamutMesh) -> GamutContainment {
guard !mesh.faces.isEmpty else { return .unknown }
let origin = SIMD3<Double>(lab.a, lab.l, lab.b)
for direction in rayDirections {
if let inside = castRay(from: origin, direction: direction, mesh: mesh) {
return inside ? .inside : .outside
}
}
return .unknown
}
/// Approximate mesh volume in Lab-cubic units.
///
/// Sums signed tetrahedra from the vertex centroid to each face; for
/// a closed surface the magnitude equals the enclosed volume
/// regardless of face winding. Returns 0 for empty or face-less
/// meshes.
public static func volume(of mesh: GamutMesh) -> Double {
guard !mesh.faces.isEmpty, !mesh.vertices.isEmpty else { return 0 }
var centroid = SIMD3<Double>.zero
for vertex in mesh.vertices {
centroid += SIMD3<Double>(vertex.position)
}
centroid /= Double(mesh.vertices.count)
var sum = 0.0
let count = mesh.vertices.count
for face in mesh.faces {
guard Int(face.a) < count, Int(face.b) < count, Int(face.c) < count else {
continue
}
let a = SIMD3<Double>(mesh.vertices[Int(face.a)].position) - centroid
let b = SIMD3<Double>(mesh.vertices[Int(face.b)].position) - centroid
let c = SIMD3<Double>(mesh.vertices[Int(face.c)].position) - centroid
sum += simd_dot(a, simd_cross(b, c)) / 6.0
}
return abs(sum)
}
// MARK: - Ray casting
/// Primary +X ray, then off-axis retries for degenerate edge hits.
private static let rayDirections: [SIMD3<Double>] = [
SIMD3(1, 0, 0),
simd_normalize(SIMD3<Double>(0.71, 1.0, 0.53)),
simd_normalize(SIMD3<Double>(0.53, 0.71, 1.0)),
]
/// MöllerTrumbore crossing count. Returns `nil` when a crossing
/// lands on a triangle edge or vertex (ambiguous parity) so the
/// caller can retry with a different direction.
private static func castRay(
from origin: SIMD3<Double>,
direction dir: SIMD3<Double>,
mesh: GamutMesh
) -> Bool? {
let epsilon = 1e-9
var crossings = 0
let count = mesh.vertices.count
for face in mesh.faces {
guard Int(face.a) < count, Int(face.b) < count, Int(face.c) < count else {
continue
}
let va = SIMD3<Double>(mesh.vertices[Int(face.a)].position)
let vb = SIMD3<Double>(mesh.vertices[Int(face.b)].position)
let vc = SIMD3<Double>(mesh.vertices[Int(face.c)].position)
let e1 = vb - va
let e2 = vc - va
let p = simd_cross(dir, e2)
let det = simd_dot(e1, p)
if abs(det) < 1e-12 { continue } // ray parallel to face
let inv = 1.0 / det
let tvec = origin - va
let u = simd_dot(tvec, p) * inv
let q = simd_cross(tvec, e1)
let v = simd_dot(dir, q) * inv
let t = simd_dot(e2, q) * inv
guard t > epsilon else { continue }
if u < -epsilon || v < -epsilon || u + v > 1 + epsilon { continue }
// Crossing on an edge or vertex parity is ambiguous.
if u < epsilon || v < epsilon || u + v > 1 - epsilon { return nil }
crossings += 1
}
return crossings % 2 == 1
}
}
@@ -1,39 +0,0 @@
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
}
}
@@ -1,218 +0,0 @@
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"
}
}
@@ -1,117 +0,0 @@
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."
}
}
}
}
@@ -1,146 +0,0 @@
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
}
}
@@ -1,95 +0,0 @@
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)
}
}
@@ -1,162 +0,0 @@
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
}
}
@@ -1,48 +0,0 @@
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)
}
}
@@ -1,80 +0,0 @@
import Foundation
/// One patch measurement emitted by a running `spotread` child (#148).
public struct SpotReadSample: Codable, Sendable, Equatable, Identifiable {
public var id: UUID
public var timestamp: Date
/// D50 Lab (always present derived from XYZ when needed).
public var lab: LabColor
/// XYZ on the 0100 scale used by the fork, when the line carried it.
public var xyz: XYZColor?
public var instrumentName: String
public var port: Int?
/// Diagnostics only never rendered as HTML or shown in the table.
public var rawLine: String
public init(
id: UUID = UUID(),
timestamp: Date = Date(),
lab: LabColor,
xyz: XYZColor? = nil,
instrumentName: String = "",
port: Int? = nil,
rawLine: String = ""
) {
self.id = id
self.timestamp = timestamp
self.lab = lab
self.xyz = xyz
self.instrumentName = instrumentName
self.port = port
self.rawLine = rawLine
}
}
/// Parses `spotread` result lines into Lab / XYZ triples.
///
/// The fork's line shape is the upstream
/// `Result is XYZ: <x> <y> <z>, D50 Lab: <L> <a> <b>`; a Lab-only line
/// also parses, and an XYZ-only line derives Lab via
/// `LabColorMath.xyzToLab` (D50).
public enum SpotReadParser {
public static func parse(line: String) -> (xyz: XYZColor?, lab: LabColor)? {
guard line.range(of: "result is", options: .caseInsensitive) != nil
|| line.range(of: #"\bLab\b"#, options: .regularExpression) != nil
|| line.range(of: #"\bXYZ\b"#, options: .regularExpression) != nil
else { return nil }
var xyz: XYZColor?
var lab: LabColor?
if let m = triple(#"\bXYZ\b[:\s]"#, in: line) {
xyz = XYZColor(x: m.0, y: m.1, z: m.2)
}
if let m = triple(#"\bLab\b[:\s]"#, in: line) {
lab = LabColor(l: m.0, a: m.1, b: m.2)
}
if lab == nil, let xyz {
lab = LabColorMath.xyzToLab(xyz)
}
guard let lab else { return nil }
return (xyz, lab)
}
private static func triple(_ marker: String, in line: String) -> (Double, Double, Double)? {
let pattern = marker + #"\s*(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)"#
guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive),
let match = regex.firstMatch(
in: line, options: [], range: NSRange(line.startIndex..., in: line)),
match.numberOfRanges == 4,
let r1 = Range(match.range(at: 1), in: line),
let r2 = Range(match.range(at: 2), in: line),
let r3 = Range(match.range(at: 3), in: line),
let a = Double(line[r1]),
let b = Double(line[r2]),
let c = Double(line[r3])
else { return nil }
return (a, b, c)
}
}
@@ -4,8 +4,6 @@ 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)" }
@@ -559,7 +559,7 @@ public actor ProcessManager {
// Start a watchdog in case the `readabilityHandler` EOFs never
// arrive after the process exits (e.g. a hung pipe).
child.finalizeTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: 2_000_000_000)
try? await Task.sleep(for: .seconds(2))
guard let self else { return }
await self.forceKill(id: id)
await self.forceFinalize(id: id)
@@ -79,9 +79,16 @@ public enum PrintcalArgs {
var args: [String] = ["-v", "-e"]
args.append(contentsOf: ArgsBuilder.flag("-I", when: config.noInkLimit))
args.append(contentsOf: ArgsBuilder.flag("-z", when: config.verify))
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-a", config.previousCalPath))
if config.noInkLimit {
args.append("-I")
}
if config.verify {
args.append("-z")
}
if let previous = config.previousCalPath?.trimmingCharacters(in: .whitespacesAndNewlines),
!previous.isEmpty {
args.append(contentsOf: ["-a", previous])
}
if let tac = config.totalInkLimit, tac > 0 {
args.append(contentsOf: ["-m", String(format: "%.1f", tac)])
} else if let tac = config.totalInkLimit {
+7 -14
View File
@@ -8,13 +8,11 @@ All measurement, chart generation, and profile mathematics live in the [Gronod A
|---|---|
| Product | ICCery v2 for macOS |
| Bundle | `com.gronod.iccery2` |
| Floor | macOS 12.0 Monterey, universal `arm64` + `x86_64` |
| Floor | macOS 14 Sonoma, 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) — 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` |
| M8 | Deduplication/consolidation contracts & UAT-ready hardening (#79#86) — in flight on `milestone/m8-consolidation` |
| Licence | Proprietary source in [`LICENCE.md`](LICENCE.md); bundled Argyll sidecars remain AGPLv3 |
## What it does
@@ -59,11 +57,9 @@ Equivalent without Make:
xcodegen generate
xcodebuild test -scheme ICCery \
-destination 'platform=macOS' \
ARCHS="$(uname -m)"
ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
```
`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
@@ -128,13 +124,10 @@ docs/ functional spec + v2 ticket plan
## Tests
```bash
# full suite (host arch)
# full suite (universal)
xcodebuild test -scheme ICCery \
-destination 'platform=macOS' \
ARCHS="$(uname -m)"
# to compile-check both slices instead:
# ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
# examples
xcodebuild test -scheme ICCery -destination 'platform=macOS' \
@@ -178,11 +171,11 @@ Agent / branch rules: [`AGENTS.md`](AGENTS.md), [`BUILD-PLAN.md`](BUILD-PLAN.md)
```
develop
└── milestone/m10-studio # M10 integration branch
└── milestone/m8-consolidation # 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 #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.
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.
## Licence
-16
View File
@@ -1,16 +0,0 @@
<?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>
+2 -25
View File
@@ -13,8 +13,6 @@ 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
@@ -22,15 +20,11 @@ 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)
}
@@ -41,15 +35,12 @@ struct AppEnvironment: Sendable {
presetStore: PresetStore(settingsStore: settingsStore),
runner: ArgyllRunner(
processManager: .shared,
binaryResolver: BinaryResolver(
bundledRoot: bundledRoot, overrideDir: overrideDir)
binaryResolver: BinaryResolver(overrideDir: overrideDir)
),
cupsService: CupsService(
processManager: .shared,
binaryDir: cupsDir),
historyStore: VerificationHistoryStore(),
mediaStore: MediaLibraryStore(),
recentProjectsStore: RecentProjectsStore()
historyStore: VerificationHistoryStore()
)
}
}
@@ -83,20 +74,6 @@ 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)
+11 -3
View File
@@ -3,8 +3,8 @@ import ICCeryCore
/// Stage 0 calibration dashboard (issue #29, docs/07).
struct CalibrationView: View {
@ObservedObject var model: CalibrationViewModel
@ObservedObject var wizard: WizardViewModel
@Bindable var model: CalibrationViewModel
@Bindable var wizard: WizardViewModel
var body: some View {
VStack(alignment: .leading, spacing: 0) {
@@ -73,7 +73,7 @@ struct CalibrationView: View {
if let url = model.computedCalURL {
Toggle("Apply calibration to next profile", isOn: $model.applyToProfile)
.onChange(of: model.applyToProfile) { _ in model.updateApplyToProfile() }
.onChange(of: model.applyToProfile) { model.updateApplyToProfile() }
.accessibilityIdentifier("calApplyToggle")
Text("Loaded: \(url.lastPathComponent)")
@@ -95,7 +95,15 @@ struct CalibrationView: View {
.frame(minHeight: 80, maxHeight: 120)
}
}
if let error = model.lastError {
Section {
Text(error)
.foregroundStyle(.red)
}
}
}
.formStyle(.grouped)
HStack {
Spacer()
+36 -32
View File
@@ -1,12 +1,13 @@
import Combine
import Foundation
import Observation
import SwiftUI
import ICCeryCore
/// Stage 0 calibration workflow: generate wedge, print, measure, and
/// compute `.cal` curves.
@MainActor
final class CalibrationViewModel: ObservableObject {
@Observable
final class CalibrationViewModel {
let workflow: TargetWorkflowViewModel
let profile: ProfileWorkflowViewModel
@@ -14,16 +15,17 @@ final class CalibrationViewModel: ObservableObject {
// MARK: - Form state
@Published var colourSpace: ColourSpace = .cmyk
@Published var steps: Int = 21
@Published var whitePatches: Int = 4
@Published var includeNeutralEmphasis: Bool = false
@Published var inkLimit: String = "320"
@Published var applyToProfile: Bool = false
@Published var computedCalURL: URL?
@Published var calibrationLog: [String] = []
@Published var isGenerating = false
@Published var isComputing = false
var colourSpace: ColourSpace = .cmyk
var steps: Int = 21
var whitePatches: Int = 4
var includeNeutralEmphasis: Bool = false
var inkLimit: String = "320"
var applyToProfile: Bool = false
var computedCalURL: URL?
var calibrationLog: [String] = []
var isGenerating = false
var isComputing = false
var lastError: String?
init(workflow: TargetWorkflowViewModel, profile: ProfileWorkflowViewModel, environment: AppEnvironment) {
self.workflow = workflow
@@ -75,6 +77,10 @@ final class CalibrationViewModel: ObservableObject {
wizard.basename = identity.calibrationBasename
wizard.sessionMode = .calibration
isGenerating = true
calibrationLog = []
lastError = nil
let config = CalibrationTargenConfig(
colourSpace: colourSpace,
steps: steps,
@@ -86,19 +92,17 @@ final class CalibrationViewModel: ObservableObject {
)
Task { @MainActor in
defer { self.isGenerating = false }
do {
_ = try await ProcessRunSupport.runLogged(
setRunning: { self.isGenerating = $0 },
resetLog: { self.calibrationLog = [] },
onLog: { self.calibrationLog.append(contentsOf: $0) }
) { onLog in
try await self.environment.runner.runCalibrationTargen(
config: config, onLogBatch: onLog)
}
_ = try await self.environment.runner.runCalibrationTargen(config: config, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
self?.calibrationLog.append(contentsOf: batch)
})
self.wizard.refreshGating()
self.wizard.showNotice("Calibration target generated.")
self.wizard.go(to: .layOutPrint)
} catch {
self.lastError = error.localizedDescription
self.wizard.showNotice(
"Calibration target failed: \(error.localizedDescription)",
kind: .error
@@ -133,13 +137,15 @@ final class CalibrationViewModel: ObservableObject {
// "already exists" when the user declines overwrite. We do not
// silently clobber.
if FileManager.default.fileExists(atPath: outputURL.path) {
wizard.showNotice(
"\(outputURL.lastPathComponent) already exists. Rename or overwrite it first.",
kind: .error
)
lastError = "\(outputURL.lastPathComponent) already exists. Rename or overwrite it first."
wizard.showNotice(lastError!, kind: .error)
return
}
isComputing = true
calibrationLog = []
lastError = nil
let config = PrintcalConfig(
ti3Basename: calBasename,
workingDirectory: cwd,
@@ -152,21 +158,19 @@ final class CalibrationViewModel: ObservableObject {
)
Task { @MainActor in
defer { self.isComputing = false }
do {
let url = try await ProcessRunSupport.runLogged(
setRunning: { self.isComputing = $0 },
resetLog: { self.calibrationLog = [] },
onLog: { self.calibrationLog.append(contentsOf: $0) }
) { onLog in
try await self.environment.runner.runPrintcal(
config: config, onLogBatch: onLog)
}
let url = try await self.environment.runner.runPrintcal(config: config, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
self?.calibrationLog.append(contentsOf: batch)
})
self.computedCalURL = url
self.profile.calibrationFile = url.path
self.profile.applyCalibration = self.applyToProfile
self.wizard.showNotice("Calibration curves computed.")
self.wizard.restoreCalibration()
} catch {
self.lastError = error.localizedDescription
self.wizard.showNotice(
"Calibration curve computation failed: \(error.localizedDescription)",
kind: .error
+2 -35
View File
@@ -70,19 +70,6 @@ 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,
@@ -95,23 +82,6 @@ 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(
@@ -132,19 +102,16 @@ final class FileDialogService {
private func open(
extensions: [String],
startingAt start: URL?,
message: String?,
title: String? = nil,
allowsOtherFileTypes: Bool = true
message: String?
) -> URL? {
let panel = NSOpenPanel()
panel.canChooseDirectories = false
panel.canChooseFiles = true
panel.allowsMultipleSelection = false
panel.allowedContentTypes = utTypes(extensions)
panel.allowsOtherFileTypes = allowsOtherFileTypes
panel.allowsOtherFileTypes = true
panel.directoryURL = start
if let message { panel.message = message }
if let title { panel.title = title }
return run(panel)
}
+78 -503
View File
@@ -1,6 +1,5 @@
import SwiftUI
import SceneKit
import Metal
import ICCeryCore
import simd
@@ -63,300 +62,77 @@ internal struct GamutSceneGeometryBuilder {
}
}
/// Native SceneKit 3D gamut viewer (issues #28, #147).
/// Native SceneKit 3D gamut viewer.
///
/// 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*`.
/// 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.
struct GamutView: View {
@StateObject private var viewModel: GamutViewModel
@State private var viewModel: GamutViewModel
@State private var pause: () -> Void = {}
@FocusState private var isFocused: Bool
@Binding var showingAllHelp: Bool
init(
environment: AppEnvironment,
profileGamURL: URL? = nil,
showingAllHelp: Binding<Bool>
) {
_viewModel = StateObject(wrappedValue: GamutViewModel(
environment: environment, profileGamURL: profileGamURL))
_showingAllHelp = showingAllHelp
init(profileGamURL: URL? = nil) {
_viewModel = State(wrappedValue: GamutViewModel(profileGamURL: profileGamURL))
}
var body: some View {
VStack(spacing: 0) {
toolbar
Divider().overlay(Theme.border)
sceneArea
Divider().overlay(Theme.border)
statusLine
inspectPanel
ZStack {
GamutSceneView(
profileMesh: viewModel.profileMesh,
referenceMesh: viewModel.sRGBMesh,
onReset: $viewModel.resetCamera,
onPause: $pause
)
.focusable()
.focused($isFocused)
.focusEffectDisabled()
.onKeyPress(.init("R"), action: {
viewModel.resetCamera()
return .handled
})
.onAppear { isFocused = true }
VStack {
HStack {
Spacer()
Button(action: { viewModel.resetCamera() }) {
Text("Reset view")
}
.accessibilityIdentifier("btnResetGamutCamera")
.padding(8)
}
Spacer()
HStack {
Text(viewModel.status)
.font(.caption)
.padding(8)
.background(.thinMaterial)
.cornerRadius(6)
.accessibilityIdentifier("gamutStatusText")
Spacer()
}
.padding(8)
}
}
.frame(minWidth: 720, minHeight: 520)
.background(Theme.background)
.frame(minWidth: 500, minHeight: 400)
.onDisappear { pause() }
.accessibilityElement(children: .contain)
.accessibilityIdentifier("gamutView")
.sheet(isPresented: $viewModel.showingTiffPreview) {
tiffPreviewSheet
}
}
// MARK: - Toolbar
private var toolbar: some View {
HStack(spacing: 12) {
layerToggle(id: GamutViewModel.srgbLayerID, fallback: "sRGB")
layerToggle(id: GamutViewModel.profileLayerID, fallback: "Profile")
layerToggle(id: GamutViewModel.compareLayerID, fallback: "Compare")
Spacer()
addCompareMenu
Button("Remove compare") { viewModel.removeCompare() }
.disabled(viewModel.layer(id: GamutViewModel.compareLayerID) == nil)
.accessibilityIdentifier("btnGamutRemoveCompare")
Button("Sample TIFF…") { viewModel.openTiffSample() }
.accessibilityIdentifier("btnGamutSampleTiff")
.helpOverlay(
"Sample a colour from a target TIFF page.",
showing: $showingAllHelp)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
private func layerToggle(id: String, fallback: String) -> some View {
let layer = viewModel.layer(id: id)
return Toggle(isOn: Binding(
get: { layer != nil && viewModel.visibleIDs.contains(id) },
set: { on in
if on {
viewModel.visibleIDs.insert(id)
} else {
viewModel.visibleIDs.remove(id)
}
}
)) {
Text(layer?.displayName ?? fallback)
}
.toggleStyle(.checkbox)
.disabled(layer == nil || viewModel.viewerUnavailable)
.help(layer.map { $0.sourceURL.lastPathComponent } ?? "No profile .gam loaded")
.accessibilityIdentifier("gamutLayer-\(id)")
}
private var addCompareMenu: some View {
Menu("Add compare…") {
Button("Open .gam…") { viewModel.openCompareGam() }
.accessibilityIdentifier("btnGamutOpenGam")
Button("Open profile…") { viewModel.openCompareProfile() }
.accessibilityIdentifier("btnGamutOpenProfile")
}
.accessibilityIdentifier("btnGamutAddCompare")
.helpOverlay(
"Add a second profile or .gam mesh to compare against.",
showing: $showingAllHelp)
}
// MARK: - Scene
private var sceneArea: some View {
ZStack(alignment: .topTrailing) {
if viewModel.viewerUnavailable {
Text("3D gamut viewer is unavailable on this Mac; the rest of ICCery still works.")
.font(.callout)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.accessibilityIdentifier("gamutViewerUnavailable")
} else {
GamutSceneView(
layers: viewModel.layers,
visibleIDs: viewModel.visibleIDs,
onReset: $viewModel.resetCamera,
onPause: $pause,
onUnavailable: { viewModel.viewerUnavailable = true },
onInspect: { point, layerID in
if let layerID {
viewModel.inspectSceneHit(world: point, layerID: layerID)
} else {
viewModel.clearInspect()
}
}
)
.focusable()
.focused($isFocused)
.onAppear { isFocused = true }
}
Button(action: { viewModel.resetCamera() }) {
Text("Reset view")
}
.accessibilityIdentifier("btnResetGamutCamera")
.padding(8)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
// MARK: - Status
private var statusLine: some View {
HStack(spacing: 10) {
Text(viewModel.status)
.font(.caption)
.foregroundStyle(Theme.text)
.accessibilityIdentifier("gamutStatusText")
if let notice = viewModel.noticeText {
Text(notice)
.font(.caption)
.foregroundStyle(.secondary)
.accessibilityIdentifier("gamutNoticeText")
}
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
}
// MARK: - Inspect panel
private var inspectPanel: some View {
HStack(spacing: 12) {
if let lab = viewModel.inspectLab {
inspectSwatch
labReadout(lab)
containmentColumn
if viewModel.inspectIsApproximate {
Text("approx. Lab, not ColorSync")
.font(.caption2)
.foregroundStyle(.secondary)
.accessibilityIdentifier("gamutInspectApprox")
}
} else {
Text("Click the mesh, or enter Lab, to inspect.")
.font(.caption)
.foregroundStyle(.secondary)
.accessibilityIdentifier("gamutInspectIdle")
}
Spacer()
labEntryFields
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.frame(minHeight: 56)
.background(Theme.panel)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("gamutInspectPanel")
.helpOverlay(
"Inspect a Lab point against each loaded gamut.",
showing: $showingAllHelp)
}
@ViewBuilder
private var inspectSwatch: some View {
if let swatch = viewModel.inspectSwatch {
Color(red: swatch.r, green: swatch.g, blue: swatch.b)
.frame(width: 16, height: 16)
.clipShape(RoundedRectangle(cornerRadius: 2))
.overlay(RoundedRectangle(cornerRadius: 2).stroke(Theme.border))
.accessibilityIdentifier("gamutInspectSwatch")
}
}
private func labReadout(_ lab: LabColor) -> some View {
HStack(spacing: 10) {
Text(String(format: "L %.1f", lab.l))
.accessibilityIdentifier("gamutInspectL")
Text(String(format: "a %.1f", lab.a))
.accessibilityIdentifier("gamutInspectA")
Text(String(format: "b %.1f", lab.b))
.accessibilityIdentifier("gamutInspectB")
}
.font(.caption.monospacedDigit())
.foregroundStyle(Theme.text)
}
private var containmentColumn: some View {
HStack(spacing: 10) {
ForEach(viewModel.inspectResults, id: \.id) { result in
Text("\(result.name) \(containmentWord(result.containment))")
.font(.caption)
.foregroundStyle(Theme.text)
.accessibilityIdentifier("gamutInspect-\(result.id)")
}
}
}
private func containmentWord(_ containment: GamutContainment) -> String {
switch containment {
case .inside: return "in"
case .outside: return "out"
case .unknown: return "?"
}
}
private var labEntryFields: some View {
HStack(spacing: 6) {
Text("Lab 0100 · ±128")
.font(.caption2)
.foregroundStyle(.secondary)
TextField("L", text: $viewModel.labEntryL)
.textFieldStyle(.roundedBorder)
.frame(width: 56)
.accessibilityIdentifier("gamutLabEntryL")
TextField("a", text: $viewModel.labEntryA)
.textFieldStyle(.roundedBorder)
.frame(width: 56)
.accessibilityIdentifier("gamutLabEntryA")
TextField("b", text: $viewModel.labEntryB)
.textFieldStyle(.roundedBorder)
.frame(width: 56)
.accessibilityIdentifier("gamutLabEntryB")
Button("Inspect") { viewModel.inspectEnteredLab() }
.disabled(!viewModel.canInspectLab)
.accessibilityIdentifier("btnGamutInspectLab")
}
}
// MARK: - TIFF sample sheet
private var tiffPreviewSheet: some View {
VStack(spacing: 12) {
Text("Click a pixel to sample its colour.")
.font(.headline)
.foregroundStyle(Theme.text)
if let png = viewModel.tiffPreviewPNG {
TiffSampleImageView(pngData: png) { r, g, b in
viewModel.sampleTiffPixel(r: r, g: g, b: b)
}
.frame(minWidth: 320, minHeight: 240)
}
HStack {
Spacer()
Button("Cancel") { viewModel.showingTiffPreview = false }
.accessibilityIdentifier("btnCloseGamutTiffPreview")
}
}
.padding(16)
.background(Theme.background)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("gamutTiffPreview")
}
}
/// `NSViewRepresentable` wrapper around an `SCNView` rendering one node
/// per ``NamedGamut`` layer.
/// `NSViewRepresentable` wrapper around an `SCNView` that builds the scene from
/// one or two ``GamutMesh`` values.
///
/// 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.
/// Scene construction and camera reset are coordinated through a typed callback
/// binding owned by the view model.
private struct GamutSceneView: NSViewRepresentable {
var layers: [NamedGamut]
var visibleIDs: Set<String>
var profileMesh: GamutMesh?
var referenceMesh: GamutMesh?
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()
@@ -371,23 +147,13 @@ private struct GamutSceneView: NSViewRepresentable {
context.coordinator.scnView = scnView
context.coordinator.scene = scene
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() }
}
context.coordinator.buildScene(profile: profileMesh, reference: referenceMesh)
return scnView
}
func updateNSView(_ nsView: SCNView, context: Context) {
context.coordinator.onInspect = onInspect
context.coordinator.syncLayers(layers, visibleIDs: visibleIDs)
context.coordinator.buildScene(profile: profileMesh, reference: referenceMesh)
}
func makeCoordinator() -> Coordinator {
@@ -402,10 +168,6 @@ private struct GamutSceneView: NSViewRepresentable {
}
static func dismantleNSView(_ nsView: SCNView, coordinator: Coordinator) {
coordinator.removeKeyMonitor()
if let click = coordinator.clickGesture {
nsView.removeGestureRecognizer(click)
}
nsView.isPlaying = false
}
@@ -413,14 +175,10 @@ 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?
/// One `SCNNode` per loaded layer, keyed by `NamedGamut.id`.
private var layerNodes: [String: SCNNode] = [:]
private let profileNode = SCNNode()
private let referenceGroup = SCNNode()
private let axisNode = SCNNode()
private let layerGroup = SCNNode()
private let cameraNode: SCNNode = {
let node = SCNNode()
node.camera = SCNCamera()
@@ -428,97 +186,33 @@ private struct GamutSceneView: NSViewRepresentable {
return node
}()
/// 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 }
func buildScene(profile: GamutMesh?, reference: GamutMesh?) {
guard let scene 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(layerGroup)
scene.rootNode.addChildNode(profileNode)
scene.rootNode.addChildNode(referenceGroup)
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()
@@ -659,8 +353,7 @@ private struct GamutSceneView: NSViewRepresentable {
return SCNNode(geometry: geometry)
}
/// Profile A: solid vertex-coloured surface.
private func profileMeshNode(_ mesh: GamutMesh) -> SCNNode {
private func profileMeshNode(_ mesh: GamutMesh, name: String) -> SCNNode {
let (geometry, _) = scnGeometry(for: mesh)
let material = SCNMaterial()
@@ -670,26 +363,11 @@ private struct GamutSceneView: NSViewRepresentable {
material.isDoubleSided = true
geometry.materials = [material]
return SCNNode(geometry: geometry)
let node = SCNNode(geometry: geometry)
node.name = name
return node
}
/// Compare profile B: same vertex colours at ~30 % opacity so
/// overlaps with A and the sRGB reference stay readable.
private func compareMeshNode(_ mesh: GamutMesh) -> SCNNode {
let (geometry, _) = scnGeometry(for: mesh)
let material = SCNMaterial()
material.lightingModel = .lambert
material.diffuse.contents = NSColor.white
material.transparency = 0.30
material.isDoubleSided = true
material.writesToDepthBuffer = false
geometry.materials = [material]
return SCNNode(geometry: geometry)
}
/// Bundled sRGB reference: faint fill + structural edge lines.
private func referenceMeshNode(_ mesh: GamutMesh) -> SCNNode {
let (geometry, _) = scnGeometry(for: mesh)
@@ -753,31 +431,6 @@ private struct GamutSceneView: NSViewRepresentable {
scnView?.isPlaying = false
}
/// Local key-down monitor for the R camera-reset shortcut (the
/// SwiftUI key-press modifier is unavailable on macOS 12). Only
/// events aimed at this view's window are handled; everything
/// else passes through untouched.
func installKeyMonitor() {
guard keyMonitor == nil else { return }
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) {
[weak self] event in
guard let self,
let scnView = self.scnView,
event.window === scnView.window,
event.charactersIgnoringModifiers?.uppercased() == "R"
else { return event }
self.resetCamera()
return nil
}
}
func removeKeyMonitor() {
if let keyMonitor {
NSEvent.removeMonitor(keyMonitor)
self.keyMonitor = nil
}
}
func resetCamera() {
guard let scnView else { return }
@@ -812,81 +465,3 @@ 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)
}
}
+27 -276
View File
@@ -1,303 +1,54 @@
import Combine
import Foundation
import ICCeryCore
import simd
import Observation
/// View model for the native SceneKit gamut viewer (issues #28, #147).
/// View model for the native SceneKit gamut viewer.
///
/// 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).
/// Loads the bundled `sRGB.gam` reference immediately and, optionally, a
/// printer/profile `.gam` from the current working directory.
@MainActor
final class GamutViewModel: ObservableObject {
@Observable
final class GamutViewModel {
/// Stable layer ids also the `gamutLayer-<id>` a11y suffixes.
static let srgbLayerID = "sRGB"
static let profileLayerID = "profile"
static let compareLayerID = "compare"
/// Parsed reference sRGB gamut mesh.
var sRGBMesh: GamutMesh?
/// Loaded meshes: bundled sRGB plus up to two profiles.
@Published var layers: [NamedGamut] = []
/// Parsed printer/profile gamut mesh.
var profileMesh: GamutMesh?
/// 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
/// User-facing status line.
var status = "Loading gamut…"
/// Closure injected into the SceneKit view to request a camera reset.
@Published var resetCamera: () -> Void = {}
var resetCamera: () -> Void = {}
// MARK: - Inspect panel
/// 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
init(profileGamURL: URL? = nil) {
self.profileGamURL = profileGamURL
loadTask = Task { await load() }
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 = environment.runner.binaryResolver.referenceGamut("sRGB")
let referenceURL = BinaryResolver().referenceGamut("sRGB")
let reference = try await parse(url: referenceURL)
layers.append(NamedGamut(
id: Self.srgbLayerID,
displayName: "sRGB",
role: .reference,
mesh: reference,
sourceURL: referenceURL))
visibleIDs.insert(Self.srgbLayerID)
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)"
}
} 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)"
}
}
/// `.icc/.icm` sibling `.gam` or `iccgamut` compare slot.
/// Internal for tests.
func loadCompareProfile(url: URL) async {
let gamURL = url.deletingPathExtension().appendingPathExtension("gam")
do {
if !FileManager.default.fileExists(atPath: gamURL.path) {
_ = try await environment.runner.runIccgamut(
config: IccgamutConfig(profileURL: url))
}
await loadCompareGam(url: gamURL)
} catch {
noticeText = "Gamut extraction failed: \(error.localizedDescription)"
}
}
/// A third profile replaces B the compare slot never stacks and
/// sRGB is never touched.
private func installCompare(_ gamut: NamedGamut) {
if layers.contains(where: { $0.id == gamut.id }) {
noticeText = "Compare slot holds one profile. The previous compare mesh was replaced."
}
layers.removeAll { $0.id == gamut.id }
layers.append(gamut)
visibleIDs.insert(gamut.id)
refreshStatus()
}
// MARK: - Status line
/// `sRGB 448v / 892 faces · Profile 1024v / 2048 faces · vol 62% of sRGB`.
/// Unloaded layers are omitted; the volume clause appears only when
/// both volumes are finite and positive.
private func refreshStatus() {
var clauses = layers.map {
"\($0.displayName) \($0.mesh.vertices.count)v / \($0.mesh.faces.count) faces"
}
if let srgb = layer(id: Self.srgbLayerID),
let profile = layer(id: Self.profileLayerID) {
let srgbVolume = GamutGeometry.volume(of: srgb.mesh)
let profileVolume = GamutGeometry.volume(of: profile.mesh)
if srgbVolume.isFinite, srgbVolume > 0,
profileVolume.isFinite, profileVolume > 0 {
clauses.append("vol \(Int((profileVolume / srgbVolume * 100).rounded()))% of sRGB")
}
}
status = clauses.isEmpty ? "No gamut loaded" : clauses.joined(separator: " · ")
}
// MARK: - Inspect
/// Whether every manual Lab field parses as a number; the Inspect
/// button is disabled while this is false.
var canInspectLab: Bool {
[labEntryL, labEntryA, labEntryB].allSatisfy { Double($0) != nil }
}
/// Runs containment for a Lab point and publishes the inspect row.
func inspect(lab: LabColor, swatch: DisplayRGB?, isApproximate: Bool) {
inspectLab = lab
inspectSwatch = swatch ?? LabColorMath.labToSRGB(lab)
inspectIsApproximate = isApproximate
inspectResults = layers.map {
($0.id, $0.displayName, GamutGeometry.containment(of: lab, in: $0.mesh))
}
}
/// Click on the axis scaffold or empty background returns the panel
/// to idle.
func clearInspect() {
inspectLab = nil
inspectSwatch = nil
inspectIsApproximate = false
inspectResults = []
}
/// SceneKit hit callback: world `(x, y, z)` Lab `(xa*, yL*, zb*)`.
/// The swatch is the nearest vertex colour of the hit layer's mesh.
func inspectSceneHit(world: SIMD3<Float>, layerID: String) {
let lab = LabColor(l: Double(world.y), a: Double(world.x), b: Double(world.z))
var swatch: DisplayRGB?
var approximate = true
if let mesh = layer(id: layerID)?.mesh,
let nearest = mesh.vertices.min(by: {
simd_distance($0.position, world) < simd_distance($1.position, world)
}) {
swatch = nearest.rgb
approximate = false
}
inspect(lab: lab, swatch: swatch, isApproximate: approximate)
}
/// `btnGamutInspectLab` typed L*a*b* path. No clamping; out-of-axis
/// values still run containment and report `?` outside every hull.
func inspectEnteredLab() {
guard let l = Double(labEntryL),
let a = Double(labEntryA),
let b = Double(labEntryB) else { return }
inspect(lab: LabColor(l: l, a: a, b: b), swatch: nil, isApproximate: true)
}
// MARK: - TIFF sampling
/// `btnGamutSampleTiff` pick a target TIFF, decode a host-side PNG
/// preview (#58), and open the click-to-sample sheet.
func openTiffSample() {
let url = UITestHooks.isEnabled
? UITestHooks.gamutTiffURL
: fileDialogs.selectTiffFile()
guard let url else { return }
guard let png = TiffPreview.previewPNG(tiff: url) else {
noticeText = "Could not decode TIFF preview."
return
}
tiffPreviewPNG = png
showingTiffPreview = true
}
/// Pixel tap inside the preview sheet: sRGB8 approximate Lab D50.
func sampleTiffPixel(r: Int, g: Int, b: Int) {
inspect(
lab: ApproximateLab.srgb8ToLab(r: r, g: g, b: b),
swatch: DisplayRGB(
r: Double(r) / 255.0,
g: Double(g) / 255.0,
b: Double(b) / 255.0),
isApproximate: true)
showingTiffPreview = false
}
/// Parses a `.gam` file off the main actor so large meshes do not
/// stall the UI.
/// 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)
+10 -31
View File
@@ -5,11 +5,11 @@ import SwiftUI
@main
struct ICCeryApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@StateObject private var workflow: TargetWorkflowViewModel
@State private var workflow: TargetWorkflowViewModel
init() {
let environment = AppEnvironment.live()
_workflow = StateObject(wrappedValue: TargetWorkflowViewModel(environment: environment))
_workflow = State(initialValue: TargetWorkflowViewModel(environment: environment))
try? AppPaths.ensureDirectories()
// Log level is runtime state apply persisted settings at
// startup (#158); the Settings sheet re-applies on save.
@@ -17,22 +17,15 @@ struct ICCeryApp: App {
}
var body: some Scene {
// Single fixed window (docs/21 §Shell: 1280×800, min 1100×700);
// metrics are applied by AppDelegate once the window exists.
WindowGroup("ICCery") {
// Single fixed window (docs/21 §Shell: 1280×800, min 1100×700).
Window("ICCery", id: "main") {
RootView(workflow: workflow)
.frame(minWidth: 1100, minHeight: 700)
.preferredColorScheme(.dark)
}
.commands {
// 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)
}
}
.defaultSize(width: 1280, height: 800)
.windowResizability(.contentMinSize)
.defaultPosition(.center)
}
}
@@ -44,30 +37,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private var terminationRequested = false
func applicationDidFinishLaunching(_ notification: Notification) {
// SwiftUI scenes launched by XCTest stay `.runningBackground`
// unless the app takes regular activation and orders the window
// front (CI run 29804).
// SwiftUI `Window` scenes launched by XCTest stay
// `.runningBackground` unless the app takes regular activation
// and orders the window front (CI run 29804).
NSApp.setActivationPolicy(.regular)
for window in NSApp.windows {
configureMainWindow(window)
window.makeKeyAndOrderFront(nil)
}
NSApp.activate(ignoringOtherApps: true)
}
/// docs/21 §Shell: 1280×800 content, min 1100×700, centred.
private func configureMainWindow(_ window: NSWindow) {
window.setContentSize(NSSize(width: 1280, height: 800))
window.contentMinSize = NSSize(width: 1100, height: 700)
window.center()
}
/// Dock-click reopen: let the WindowGroup re-show or recreate the
/// main window when none are visible.
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
true
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
true
}
@@ -1,5 +1,5 @@
import Combine
import Foundation
import Observation
import SwiftUI
import ICCeryCore
@@ -32,7 +32,8 @@ enum XYStep: Equatable, Sendable {
/// Stage 3 workflow state and interaction (issues #18#22).
@MainActor
final class MeasurementWorkflowViewModel: ObservableObject {
@Observable
final class MeasurementWorkflowViewModel {
// MARK: - Authorities
@@ -41,37 +42,36 @@ final class MeasurementWorkflowViewModel: ObservableObject {
// MARK: - Settings-driven thresholds
@Published private(set) var goodMax: Double = 2.0
@Published private(set) var warningMax: Double = 5.0
@Published private(set) var enableLEDs: Bool = false
private(set) var goodMax: Double = 2.0
private(set) var warningMax: Double = 5.0
private(set) var enableLEDs: Bool = false
// MARK: - Instrument detection
@Published var instruments: [InstrumentDevice] = []
@Published var selectedInstrument: InstrumentSelection = .auto
@Published var isDetecting = false
@Published var detectionError: String?
var instruments: [InstrumentDevice] = []
var selectedInstrument: InstrumentSelection = .auto
var isDetecting = false
var detectionError: String?
// MARK: - Chartread session
@Published var isChartreadRunning = false
@Published var chartreadState: ChartreadState = .idle
@Published var currentPrompt: String?
@Published var requestedWarningKey: String?
@Published var chartreadLog: [String] = []
@Published var rows: [ChartreadRow] = []
@Published var swatchRows: [SwatchRow] = []
@Published var showRemoveSheetNotice = false
/// Stage-local chartread error notice (`#chartreadLastError`, #80).
@Published var chartreadNotice: Notice?
var isChartreadRunning = false
var chartreadState: ChartreadState = .idle
var currentPrompt: String?
var requestedWarningKey: String?
var chartreadLog: [String] = []
var rows: [ChartreadRow] = []
var swatchRows: [SwatchRow] = []
var showRemoveSheetNotice = false
var lastError: String?
private var chartreadTask: Task<Void, Never>?
// MARK: - Averaging
@Published var passSnapshots: [URL] = []
@Published var isFinishing = false
@Published var finishNotice: Notice?
@Published var resumedFromTi2 = false
var passSnapshots: [URL] = []
var isFinishing = false
var finishNotice: Notice?
var resumedFromTi2 = false
init(wizard: WizardViewModel, environment: AppEnvironment) {
self.wizard = wizard
@@ -185,7 +185,7 @@ final class MeasurementWorkflowViewModel: ObservableObject {
isChartreadRunning = true
chartreadState = .idle
currentPrompt = nil
chartreadNotice = nil
lastError = nil
chartreadLog.removeAll()
// Optional: reset rows when starting a fresh first pass.
@@ -227,10 +227,7 @@ final class MeasurementWorkflowViewModel: ObservableObject {
case .exit(let code):
if code != 0 {
chartreadNotice = Notice(
kind: .error,
text: "chartread exited with code \(code)"
)
lastError = "chartread exited with code \(code)"
}
case .completed(let canonicalURL):
@@ -238,7 +235,7 @@ final class MeasurementWorkflowViewModel: ObservableObject {
completePass(canonicalURL: canonicalURL)
case .failed(let error):
chartreadNotice = Notice(kind: .error, text: error.localizedDescription)
lastError = error.localizedDescription
chartreadState = .error
isChartreadRunning = false
}
@@ -384,10 +381,7 @@ final class MeasurementWorkflowViewModel: ObservableObject {
discoverPassSnapshots()
wizard.refreshGating()
} catch {
chartreadNotice = Notice(
kind: .error,
text: "Could not snapshot pass: \(error.localizedDescription)"
)
lastError = "Could not snapshot pass: \(error.localizedDescription)"
}
}
@@ -403,32 +397,30 @@ final class MeasurementWorkflowViewModel: ObservableObject {
func finishAndAverage() {
guard !isFinishing, let cwd = workingDirectory, !passSnapshots.isEmpty else { return }
isFinishing = true
finishNotice = nil
Task { @MainActor [weak self] in
guard let self else { return }
do {
// No log reset: prior chartread output must be preserved.
let canonical = try await ProcessRunSupport.runLogged(
setRunning: { self.isFinishing = $0 },
resetLog: {},
onLog: { self.chartreadLog.append(contentsOf: $0) }
) { onLog in
if self.passSnapshots.count == 1, let pass = self.passSnapshots.first {
return try MeasurementArtefacts.promotePass(
pass: pass,
basename: self.basename,
cwd: cwd
)
}
let canonical: URL
if self.passSnapshots.count == 1, let pass = self.passSnapshots.first {
canonical = try MeasurementArtefacts.promotePass(
pass: pass,
basename: self.basename,
cwd: cwd
)
} else {
let config = AverageConfig(
workingDirectory: cwd,
basename: self.basename,
passFiles: self.passSnapshots
)
return try await self.environment.runner.runAverage(
canonical = try await self.environment.runner.runAverage(
config: config,
onLogBatch: onLog
onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
self?.chartreadLog.append(contentsOf: batch)
}
)
}
self.discoverPassSnapshots()
@@ -473,6 +465,7 @@ final class MeasurementWorkflowViewModel: ObservableObject {
)
}
}
self.isFinishing = false
}
}
}
-255
View File
@@ -1,255 +0,0 @@
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.")
}
}
}
-432
View File
@@ -1,432 +0,0 @@
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()
}
}
}
-8
View File
@@ -21,14 +21,6 @@ struct Notice: Identifiable, Equatable {
case .error: return .red
}
}
var accessibilityValue: String {
switch self {
case .info: return "info"
case .warning: return "warning"
case .error: return "error"
}
}
}
let id = UUID()
+2 -2
View File
@@ -4,7 +4,7 @@ import ICCeryCore
/// `#savePresetDialog` save the live Stage 1/2 form as a custom
/// preset (issue #11). Names/descriptions render via `Text` only (#114).
struct SavePresetDialog: View {
@ObservedObject var workflow: TargetWorkflowViewModel
@Bindable var workflow: TargetWorkflowViewModel
var body: some View {
VStack(alignment: .leading, spacing: 14) {
@@ -35,7 +35,7 @@ struct SavePresetDialog: View {
/// `#managePresetsDialog` list, delete (custom only), import, export.
struct ManagePresetsDialog: View {
@ObservedObject var workflow: TargetWorkflowViewModel
@Bindable var workflow: TargetWorkflowViewModel
var body: some View {
VStack(alignment: .leading, spacing: 12) {
@@ -1,22 +1,23 @@
import Combine
import Foundation
import Observation
import ICCeryCore
/// CUPS queue selection, bound print panel, and `lp` spool (issues 1215, 17 / #85).
@MainActor
final class PrintSessionViewModel: ObservableObject {
@Observable
final class PrintSessionViewModel {
let wizard: WizardViewModel
let environment: AppEnvironment
@Published var printers: [Printer] = []
@Published var selectedPrinter = ""
@Published var printerCaps = PrinterCapabilities()
@Published var selectedTray: Int?
@Published var selectedMediaType: String?
@Published var printOrientation = "portrait"
@Published var capturedCupsOptions: [String: String] = [:]
@Published var printNotice: Notice?
@Published var isPrinting = false
var printers: [Printer] = []
var selectedPrinter = ""
var printerCaps = PrinterCapabilities()
var selectedTray: Int?
var selectedMediaType: String?
var printOrientation = "portrait"
var capturedCupsOptions: [String: String] = [:]
var printNotice: Notice?
var isPrinting = false
private var printTask: Task<Void, Never>?
init(wizard: WizardViewModel, environment: AppEnvironment) {
@@ -24,41 +25,24 @@ final class PrintSessionViewModel: ObservableObject {
self.environment = environment
}
private var printerEnumTask: Task<[Printer]?, Never>?
func refreshPrinters() {
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 }
let cups = environment.cupsService
Task { @MainActor in
do {
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
let list = try await cups.listPrinters()
printers = list
if !list.contains(where: { $0.name == selectedPrinter }) {
selectedPrinter = list.first { $0.isDefault }?.name
?? list.first?.name ?? ""
}
await self.reloadSelectedCapabilities()
return list
await reloadSelectedCapabilities()
} catch {
self.printNotice = Notice(
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 {
@@ -127,8 +111,7 @@ final class PrintSessionViewModel: ObservableObject {
isPrinting = true
let task = Task { @MainActor [weak self] in
guard let self else { return }
// `defer` cannot mutate isolated state under Swift 5.7
// (Xcode 14.2 / macOS 12 runner), so clear explicitly (#113).
defer { self.printTask = nil }
var printed = 0
for page in result.pages {
do {
@@ -141,7 +124,6 @@ final class PrintSessionViewModel: ObservableObject {
+ error.localizedDescription
)
isPrinting = false
self.printTask = nil
return
}
}
@@ -151,7 +133,6 @@ final class PrintSessionViewModel: ObservableObject {
autoHideAfter: nil
)
isPrinting = false
self.printTask = nil
}
printTask = task
}
@@ -161,6 +142,7 @@ final class PrintSessionViewModel: ObservableObject {
isPrinting = true
let task = Task { @MainActor [weak self] in
guard let self else { return }
defer { self.printTask = nil }
do {
try await spool(page, index: page.index, pageSize: pageSize)
printNotice = Notice(
@@ -175,7 +157,6 @@ final class PrintSessionViewModel: ObservableObject {
)
}
isPrinting = false
self.printTask = nil
}
printTask = task
}
+81 -83
View File
@@ -1,11 +1,12 @@
import Combine
import Foundation
import Observation
import SwiftUI
import ICCeryCore
/// Stage 4/5 workflow: build a profile, verify it, track drift, and install.
@MainActor
final class ProfileWorkflowViewModel: ObservableObject {
@Observable
final class ProfileWorkflowViewModel {
let wizard: WizardViewModel
let environment: AppEnvironment
@@ -13,50 +14,51 @@ final class ProfileWorkflowViewModel: ObservableObject {
// MARK: - Stage 4 form
@Published var algorithm: String = "l" // l | x | X | m
@Published var quality: String = "m" // l | m | h | u
@Published var intent: String = "" // usually empty at Stage 4
@Published var fwaSelection: ColprofFwaSelection = .none
@Published var fwaCustomPath: String = ""
@Published var illuminant: String = ""
@Published var observer: String = ""
@Published var inputViewingCond: String = ""
@Published var outputViewingCond: String = ""
@Published var profileDescription: String = ""
@Published var copyright: String = ""
var algorithm: String = "l" // l | x | X | m
var quality: String = "m" // l | m | h | u
var intent: String = "" // usually empty at Stage 4
var fwaSelection: ColprofFwaSelection = .none
var fwaCustomPath: String = ""
var illuminant: String = ""
var observer: String = ""
var inputViewingCond: String = ""
var outputViewingCond: String = ""
var profileDescription: String = ""
var copyright: String = ""
// MARK: - Run state
@Published var isColprofRunning = false
@Published var colprofLog: [String] = []
@Published var colprofProgress: String?
@Published var createdProfileURL: URL?
var isColprofRunning = false
var colprofLog: [String] = []
var colprofProgress: String?
var lastError: String?
var createdProfileURL: URL?
/// Path to the `.gam` gamut mesh extracted post-`colprof` (issue #28).
@Published var createdGamutURL: URL?
var createdGamutURL: URL?
// MARK: - Stage 4/5 calibration (issue #24)
@Published var applyCalibration = false
@Published var calibrationFile: String = ""
var applyCalibration = false
var calibrationFile: String = ""
// MARK: - Stage 5 verification (issue #25)
@Published var profcheckReport: ProfcheckReport?
@Published var profcheckWarning: String?
@Published var isProfcheckRunning = false
var profcheckReport: ProfcheckReport?
var profcheckWarning: String?
var isProfcheckRunning = false
// MARK: - History / drift (issue #26)
@Published var verificationHistory: [VerificationRecord] = []
@Published var driftPrinterFilter: String? = nil
@Published var driftAlert: String?
@Published var isHistoryStoreError: String?
var verificationHistory: [VerificationRecord] = []
var driftPrinterFilter: String? = nil
var driftAlert: String?
var isHistoryStoreError: String?
// MARK: - Install (issue #27)
@Published var installResult: InstallProfileResult?
@Published var showingInstallCollision = false
@Published var installCollisionMessage: String = ""
var installResult: InstallProfileResult?
var showingInstallCollision = false
var installCollisionMessage: String = ""
var pendingInstallOptions: InstallProfileOptions?
init(wizard: WizardViewModel, environment: AppEnvironment) {
@@ -163,59 +165,60 @@ final class ProfileWorkflowViewModel: ObservableObject {
guard canCreateProfile, let _ = wizard.effectiveWorkingDirectory else { return }
let config = buildColprofConfig()
isColprofRunning = true
colprofLog = []
colprofProgress = nil
lastError = nil
createdProfileURL = nil
createdGamutURL = nil
let runner = environment.runner
Task { @MainActor [weak self] in
guard let self else { return }
defer { self.isColprofRunning = false }
do {
let outcome = try await ProcessRunSupport.runLogged(
setRunning: { self.isColprofRunning = $0 },
resetLog: { self.colprofLog = [] },
onLog: { batch in
self.colprofLog.append(contentsOf: batch)
if let last = batch.last {
self.updateProgress(ColprofProgressClassifier.classify(line: last))
}
let url = try await runner.runColprof(config: config, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
guard let self else { return }
self.colprofLog.append(contentsOf: batch)
if let last = batch.last {
self.updateProgress(ColprofProgressClassifier.classify(line: last))
}
) { onLog in
let url = try await runner.runColprof(config: config, onLogBatch: onLog)
})
var finalProfileURL = url
var finalProfileURL = url
if self.applyCalibration, !self.calibrationFile.isEmpty {
let applyConfig = ApplycalConfig(
calibrationPath: self.calibrationFile,
inputProfileURL: url
)
assert(!applyConfig.unapply, "applycal unapply is not supported in v2.0")
finalProfileURL = try await runner.runApplycal(config: applyConfig)
self.colprofLog.append("Calibration embedded: \(self.calibrationFile)")
}
// Gamut extraction is best-effort for Stage 5 / M6 viewer.
var gamutURL: URL?
do {
let gamConfig = IccgamutConfig(profileURL: finalProfileURL)
let url = try await runner.runIccgamut(config: gamConfig, onLogBatch: onLog)
gamutURL = url
self.colprofLog.append("Gamut mesh extracted: \(url.lastPathComponent)")
} catch {
self.wizard.showNotice(
"Gamut extraction skipped: \(error.localizedDescription)",
kind: .info
)
}
return (profileURL: finalProfileURL, gamutURL: gamutURL)
if self.applyCalibration, !self.calibrationFile.isEmpty {
let applyConfig = ApplycalConfig(
calibrationPath: self.calibrationFile,
inputProfileURL: url
)
assert(!applyConfig.unapply, "applycal unapply is not supported in v2.0")
finalProfileURL = try await runner.runApplycal(config: applyConfig)
self.colprofLog.append("Calibration embedded: \(self.calibrationFile)")
}
self.createdProfileURL = outcome.profileURL
self.createdGamutURL = outcome.gamutURL
// Gamut extraction is best-effort for Stage 5 / M6 viewer.
do {
let gamConfig = IccgamutConfig(profileURL: finalProfileURL)
let gamURL = try await runner.runIccgamut(config: gamConfig, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
self?.colprofLog.append(contentsOf: batch)
})
self.createdGamutURL = gamURL
self.colprofLog.append("Gamut mesh extracted: \(gamURL.lastPathComponent)")
} catch {
self.wizard.showNotice(
"Gamut extraction skipped: \(error.localizedDescription)",
kind: .info
)
}
self.createdProfileURL = finalProfileURL
self.wizard.refreshGating()
self.wizard.showNotice("Profile created: \(outcome.profileURL.lastPathComponent)")
self.wizard.showNotice("Profile created: \(finalProfileURL.lastPathComponent)")
self.wizard.go(to: .verifyInstall)
} catch {
self.lastError = error.localizedDescription
self.wizard.showNotice(
"Profile creation failed: \(error.localizedDescription)",
kind: .error
@@ -282,28 +285,23 @@ final class ProfileWorkflowViewModel: ObservableObject {
let ti3URL = ArtefactProbe.artefact(wizard.basename, "ti3", cwd)
let config = ProfcheckConfig(ti3URL: ti3URL, iccURL: profileURL)
isProfcheckRunning = true
profcheckReport = nil
profcheckWarning = nil
let runner = environment.runner
Task { @MainActor [weak self] in
guard let self else { return }
defer { self.isProfcheckRunning = false }
do {
let outcome = try await ProcessRunSupport.runLogged(
setRunning: { self.isProfcheckRunning = $0 },
resetLog: {},
onLog: { self.colprofLog.append(contentsOf: $0) }
) { onLog in
let report = try await runner.runProfcheck(config: config, onLogBatch: onLog)
var history: [VerificationRecord]?
if let record = self.makeVerificationRecord(from: report) {
history = try await self.environment.historyStore.append(record)
}
return (report: report, history: history)
}
self.profcheckReport = outcome.report
if let history = outcome.history {
self.verificationHistory = history
let report = try await runner.runProfcheck(config: config, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
self?.colprofLog.append(contentsOf: batch)
})
self.profcheckReport = report
if let record = self.makeVerificationRecord(from: report) {
let updated = try await self.environment.historyStore.append(record)
self.verificationHistory = updated
self.driftAlert = DriftAlert.compute(from: self.filteredHistory)
}
} catch let error as ArgyllRunnerError where error == .profcheckUnparseable {
-689
View File
@@ -1,689 +0,0 @@
import AppKit
import Combine
import Foundation
import ICCeryCore
/// Project file session (issue #149): `.icceryproj` open/save/new/close,
/// recents, the dirty flag, and the window title.
///
/// The project is an **index** disk artefacts still own the stepper
/// (R18). Open writes `WizardState` through the normal setters and ends
/// in the same probe `windowDidBecomeKey` uses; it never auto-runs
/// `targen`/`colprof`/`chartread` and never unlocks a stage the disk
/// does not back.
@MainActor
final class ProjectSession: ObservableObject {
/// What to do once the dirty alert resolves.
enum PendingAction {
case new
case open(URL)
case close
}
/// A live session snapshot, compared against the bound project for
/// the dirty flag (title ``, `btnProjectSave`).
private struct Snapshot: Equatable {
var basename: String
var cwd: String
var printerID: String?
var presetID: String?
var mediaRecipeID: String?
var calibrationURL: String?
}
let workflow: TargetWorkflowViewModel
let environment: AppEnvironment
private let fileDialogs = FileDialogService.shared
private let recentsStore: RecentProjectsStore
private var cancellables = Set<AnyCancellable>()
/// Bound project file location; `nil` = no project.
@Published var projectURL: URL?
/// Last saved/opened project payload.
@Published var project: ICCeryProject?
/// Open Recent entries, newest first, missing files pruned.
@Published var recents: [RecentProjectEntry] = []
/// JSON claimed a finished profile but disk stops earlier
/// `projectChipStale` + info banner (R18).
@Published var diskBehindNotes = false
/// `ICCery` / `ICCery {name}` / `ICCery {name} `.
@Published var windowTitle = "ICCery"
/// Live session fields differ from the bound project computed
/// fresh so decision paths (`requestNew`/`requestClose`) never see
/// a stale willSet value.
var isDirty: Bool {
guard let project else { return false }
return liveSnapshot() != snapshot(of: project)
}
// Flow state for RootView.
@Published var showingNewAlert = false
@Published var showingDirtyAlert = false
@Published var showingRelocateSheet = false
private var pendingAction: PendingAction?
/// Loaded project whose `cwd` is missing relocate sheet payload.
@Published private(set) var pendingRelocate:
(project: ICCeryProject, url: URL)?
init(workflow: TargetWorkflowViewModel, environment: AppEnvironment) {
self.workflow = workflow
self.environment = environment
self.recentsStore = environment.recentProjectsStore
refreshRecents()
// Dirty / title derive from live fields observe each source;
// child VMs are not tracked through the parent's
// objectWillChange (R22-style weak sinks). `@Published` fires on
// willSet, so the recompute is deferred one main turn to read
// post-set values.
let wizard = workflow.wizard
let printSession = workflow.print
let media = workflow.media
let profile = workflow.profile
wizard.$basename.sink { [weak self] _ in self?.scheduleRecompute() }
.store(in: &cancellables)
wizard.$workingDirectory.sink { [weak self] _ in self?.scheduleRecompute() }
.store(in: &cancellables)
wizard.$printerName.sink { [weak self] _ in self?.scheduleRecompute() }
.store(in: &cancellables)
printSession?.$selectedPrinter.sink { [weak self] _ in self?.scheduleRecompute() }
.store(in: &cancellables)
workflow.$selectedPresetID.sink { [weak self] _ in self?.scheduleRecompute() }
.store(in: &cancellables)
media?.$selectedRecipeID.sink { [weak self] _ in self?.scheduleRecompute() }
.store(in: &cancellables)
profile.$calibrationFile.sink { [weak self] _ in self?.scheduleRecompute() }
.store(in: &cancellables)
recomputeDerived()
}
deinit { cancellables.removeAll() }
// MARK: - Derived
var isBound: Bool { project != nil }
/// A chartread / spotread / colprof child is live project changes
/// wait for the instrument run to finish.
var childSessionLive: Bool {
workflow.measurement.isChartreadRunning
|| workflow.spotRead.isRunning
|| workflow.profile.isColprofRunning
}
/// Basename eligible for persistence. A live `CAL_` stem resolves
/// only through the persisted original never by stripping, so a
/// bare `CAL_` (Force-Quit anomaly, empty persisted original) is
/// refused rather than trusted (R11).
var resolvedBasename: String? {
let live = workflow.wizard.basename
guard CalibrationIdentity.isCalibration(live) else {
return live.isEmpty ? nil : live
}
let original = workflow.wizard.calibrationOriginalBasename
return original.isEmpty ? nil : original
}
/// S / `btnProjectSave` enable rule. A `CAL_` live basename stays
/// enabled so the refusal can show its banner.
var canSave: Bool {
isBound && !workflow.wizard.basename.isEmpty
&& workflow.wizard.workingDirectory != nil
}
/// `menuProjectSaveAs` no binding required.
var canSaveAs: Bool {
!workflow.wizard.basename.isEmpty
&& workflow.wizard.workingDirectory != nil
}
/// `menuProjectReport` needs a real basename and cwd on disk.
var canReport: Bool {
!workflow.wizard.basename.isEmpty
&& workflow.wizard.workingDirectory != nil
}
private func liveSnapshot() -> Snapshot {
Snapshot(
basename: resolvedBasename ?? "",
cwd: workflow.wizard.workingDirectory?.path ?? "",
printerID: workflow.print.selectedPrinter.isEmpty
? nil : workflow.print.selectedPrinter,
presetID: workflow.selectedPresetID == "none"
? nil : workflow.selectedPresetID,
mediaRecipeID: workflow.media.selectedRecipeID == "none"
? nil : workflow.media.selectedRecipeID,
calibrationURL: workflow.profile.calibrationFile.isEmpty
? nil : workflow.profile.calibrationFile)
}
private func snapshot(of project: ICCeryProject) -> Snapshot {
Snapshot(
basename: project.basename,
cwd: project.cwd,
printerID: project.printerID,
presetID: project.presetID,
mediaRecipeID: project.mediaRecipeID,
calibrationURL: project.calibrationURL)
}
/// Recomputes the window title from live fields.
func recomputeDerived() {
if let project {
windowTitle = "ICCery — \(project.name)" + (isDirty ? "" : "")
} else {
windowTitle = "ICCery"
}
}
/// Defer the recompute one main turn the Combine sinks fire on
/// willSet, before the changed property holds its new value.
private func scheduleRecompute() {
Task { @MainActor [weak self] in self?.recomputeDerived() }
}
// MARK: - New
/// N / `menuProjectNew`. Dirty sessions detour through the dirty
/// alert first; a live child gets a banner instead of the alert.
func requestNew() {
guard !childSessionLive else {
workflow.wizard.showNotice(
"Finish the instrument session before starting a project.",
kind: .warning)
return
}
if isDirty {
pendingAction = .new
showingDirtyAlert = true
return
}
showingNewAlert = true
}
/// `btnProjectNewConfirm` unbinds and clears the basename. Empty
/// is the legal "no target yet" state (#60); artefacts and
/// `media_library.json` are never deleted (R18).
func confirmNew() {
showingNewAlert = false
projectURL = nil
project = nil
pendingRelocate = nil
diskBehindNotes = false
workflow.wizard.basename = ""
workflow.targetBasename = ""
workflow.media.selectedRecipeID = "none"
workflow.wizard.sessionMode = .profile
// Re-probe an empty basename locks stages 25.
workflow.wizard.windowDidBecomeKey()
recomputeDerived()
}
// MARK: - Open
/// O / `btnProjectOpen` `selectProjectFile` (`.icceryproj`
/// only). Cancel is a no-op.
func requestOpen() {
guard !childSessionLive else {
workflow.wizard.showNotice(
"Finish the instrument session before opening a project.",
kind: .warning)
return
}
let url = UITestHooks.isEnabled
? UITestHooks.projectOpenURL
: fileDialogs.selectProjectFile()
guard let url else { return }
if isDirty {
pendingAction = .open(url)
showingDirtyAlert = true
return
}
open(url)
}
/// Open from the recents submenu same path, no open panel. A
/// missing file is dropped with a banner; no relocate is offered.
func openRecent(_ entry: RecentProjectEntry) {
guard !childSessionLive else {
workflow.wizard.showNotice(
"Finish the instrument session before opening a project.",
kind: .warning)
return
}
let url = URL(fileURLWithPath: entry.path)
guard FileManager.default.fileExists(atPath: url.path) else {
Task { try? await recentsStore.remove(path: entry.path) }
refreshRecents()
workflow.wizard.showNotice("Project file is gone.", kind: .warning)
return
}
if isDirty {
pendingAction = .open(url)
showingDirtyAlert = true
return
}
open(url)
}
/// Shared open path validates, routes to relocate when `cwd` is
/// gone, then applies. Failures leave live state untouched.
func open(_ url: URL) {
guard let loaded = loadProject(url) else { return }
apply(loaded, from: url)
}
/// Awaitable open for tests identical to `open` but completes
/// after apply finishes.
func openAsync(_ url: URL) async {
guard let loaded = loadProject(url) else { return }
await applyAsync(loaded, from: url)
}
/// Decode + validate + cwd-existence check. Returns the project
/// ready to apply, or nil after presenting an error banner / the
/// relocate sheet.
private func loadProject(_ url: URL) -> ICCeryProject? {
let loaded: ICCeryProject
do {
loaded = try ICCeryProject.load(from: url)
} catch let error as ICCeryProject.ValidationError {
workflow.wizard.showNotice(
error.errorDescription ?? "Could not open project.",
kind: .error)
return nil
} catch {
workflow.wizard.showNotice(
"Could not open project: \(error.localizedDescription)",
kind: .error)
return nil
}
var isDir: ObjCBool = false
guard FileManager.default.fileExists(
atPath: loaded.cwd, isDirectory: &isDir), isDir.boolValue
else {
pendingRelocate = (loaded, url)
showingRelocateSheet = true
return nil
}
return loaded
}
/// Sync entry the apply half runs in a Task so `media.apply`
/// (async) can run inside it.
private func apply(_ project: ICCeryProject, from url: URL) {
Task { @MainActor [weak self] in
await self?.applyAsync(project, from: url)
}
}
/// Apply order (issue #149):
/// 1. cwd + basename on the wizard **and** Stage 1 fields.
/// 2. `mediaRecipeID` `MediaLibraryViewModel.apply`; else
/// `presetID` existing preset apply. Missing recipe is not
/// fatal open still succeeds.
/// 3. Re-probe via the existing window-focus path (#151).
/// 4. Never auto-run `targen` / `colprof` / `chartread`.
private func applyAsync(_ project: ICCeryProject, from url: URL) async {
let cwdURL = URL(fileURLWithPath: project.cwd, isDirectory: true)
workflow.wizard.setTarget(
basename: project.basename, workingDirectory: cwdURL)
workflow.targetBasename = project.basename
workflow.targetDirectory = cwdURL
workflow.wizard.printerName = project.printerDisplayName
?? project.printerID
workflow.wizard.profileBasename = project.profileBasename
workflow.wizard.sessionMode = .profile
if let queue = project.printerID,
workflow.print.printers.contains(where: { $0.name == queue }) {
workflow.print.selectedPrinter = queue
}
var appliedRecipe = false
if let recipeID = project.mediaRecipeID {
if workflow.media.recipes.isEmpty {
await workflow.media.reloadAsync()
}
if let recipe = workflow.media.recipes
.first(where: { $0.id == recipeID }) {
appliedRecipe = await workflow.media.apply(recipe)
}
}
if !appliedRecipe, let presetID = project.presetID,
let preset = workflow.presets
.first(where: { $0.id == presetID }) {
workflow.applyPreset(preset)
}
// Disk owns the stepper same probe window focus uses.
workflow.wizard.windowDidBecomeKey()
// JSON-vs-disk mismatch: notes say the profile is done but the
// artefacts stop earlier index drift, not gating truth.
let artefacts = workflow.wizard.artefacts
if project.lastVerification != nil && !artefacts.stage4Complete {
let already = diskBehindNotes && projectURL == url
diskBehindNotes = true
if !already {
let last = artefacts.stage3Complete ? ".ti3"
: artefacts.stage2Complete ? ".ti2"
: artefacts.stage1Complete ? ".ti1" : nil
let detail = last.map { "artefacts on disk stop at \($0)." }
?? "no artefacts found on disk."
workflow.wizard.showNotice(
"Project notes say profile done; \(detail)",
kind: .info)
}
} else {
diskBehindNotes = false
}
self.project = project
projectURL = url
pushRecent(url: url, name: project.name)
recomputeDerived()
}
// MARK: - Relocate
/// `btnProjectRelocate` pick a new cwd, rewrite the project file
/// atomically, then continue Apply.
func chooseRelocateFolder() {
guard let pending = pendingRelocate else { return }
let url = UITestHooks.isEnabled
? UITestHooks.projectRelocateURL
: fileDialogs.selectDirectory()
guard let url else { return }
var rewritten = pending.project
rewritten.cwd = url.path
rewritten.updated = Date()
do {
try rewritten.save(to: pending.url)
} catch {
workflow.wizard.showNotice(
"Could not update project: \(error.localizedDescription)",
kind: .error)
return
}
pendingRelocate = nil
showingRelocateSheet = false
apply(rewritten, from: pending.url)
}
/// `btnProjectRelocateCancel` aborts the open; live session
/// unchanged.
func cancelRelocate() {
pendingRelocate = nil
showingRelocateSheet = false
}
// MARK: - Save / Save As
/// S / `btnProjectSave` writes the live session into the bound
/// URL. A live `CAL_` stem refuses unless the persisted original
/// resolves; `CAL_` is never persisted (R11).
func saveProject() {
Task { @MainActor in _ = await saveProjectAsync() }
}
@discardableResult
func saveProjectAsync() async -> Bool {
guard let url = projectURL, project != nil else { return false }
guard let basename = resolvedBasename else {
refuseUnsavable()
return false
}
return await write(to: url, basename: basename)
}
/// S / `menuProjectSaveAs` always shows the save picker, then
/// binds the chosen URL and pushes recents.
func saveProjectAs() {
guard let basename = resolvedBasename else {
refuseUnsavable()
return
}
guard let cwd = workflow.wizard.workingDirectory else {
refuseUnsavable()
return
}
let url = UITestHooks.isEnabled
? UITestHooks.projectSaveURL
: fileDialogs.selectProjectSavePath(
basename: basename, startingAt: cwd)
guard let url else { return }
Task { @MainActor in _ = await write(to: url, basename: basename) }
}
private func refuseUnsavable() {
if CalibrationIdentity.isCalibration(workflow.wizard.basename) {
workflow.wizard.showNotice(
"Finish or exit calibration before saving a project.",
kind: .warning)
} else {
workflow.wizard.showNotice(
"Set a target basename and working folder before saving a project.",
kind: .warning)
}
}
/// Builds the project payload from live session fields and writes
/// it atomically. On success binds `url`, refreshes recents, and
/// clears the stale-chip flag. Failure error banner, bound URL
/// unchanged.
@discardableResult
private func write(to url: URL, basename: String) async -> Bool {
guard let cwd = workflow.wizard.workingDirectory,
!cwd.path.isEmpty else {
refuseUnsavable()
return false
}
let stem = workflow.wizard.profileBasename ?? basename
let snapshot = await lastVerificationSnapshot(stem: stem)
?? project?.lastVerification
let queue = workflow.print.selectedPrinter
let display = workflow.print.printers
.first { $0.name == queue }?.displayName
?? workflow.wizard.printerName
let existing = project
let name = (existing?.name.isEmpty == false)
? existing!.name : basename
let updated = ICCeryProject(
name: name,
notes: existing?.notes ?? "",
basename: basename,
cwd: cwd.path,
profileBasename: workflow.wizard.profileBasename,
printerID: queue.isEmpty ? nil : queue,
printerDisplayName: display,
mediaRecipeID: workflow.media.selectedRecipeID == "none"
? nil : workflow.media.selectedRecipeID,
presetID: workflow.selectedPresetID == "none"
? nil : workflow.selectedPresetID,
calibrationURL: workflow.profile.calibrationFile.isEmpty
? nil : workflow.profile.calibrationFile,
lastVerification: snapshot,
updated: Date())
do {
try updated.save(to: url)
} catch {
workflow.wizard.showNotice(
"Could not save project: \(error.localizedDescription)",
kind: .error)
return false
}
project = updated
projectURL = url
diskBehindNotes = false
pushRecent(url: url, name: updated.name)
workflow.wizard.showNotice("Project saved: \(url.lastPathComponent)")
recomputeDerived()
return true
}
/// Last `VerificationHistoryStore` record for this profile stem,
/// if any notes only.
private func lastVerificationSnapshot(
stem: String
) async -> VerificationSnapshot? {
guard let records = try? await environment.historyStore.load()
else { return nil }
guard let record = records.last(where: {
$0.profileName == stem || $0.profileName == workflow.wizard.basename
}) else { return nil }
return VerificationSnapshot(record: record)
}
// MARK: - Close
/// `menuProjectClose` unbinds; live basename/cwd/artefacts stay.
func requestClose() {
guard isBound else { return }
if isDirty {
pendingAction = .close
showingDirtyAlert = true
return
}
closeProject()
}
func closeProject() {
projectURL = nil
project = nil
diskBehindNotes = false
recomputeDerived()
}
// MARK: - Dirty alert
/// `btnProjectDirtySave` / `btnProjectDirtyDiscard`. Save proceeds
/// to the pending action only when the write succeeded.
func resolveDirty(save: Bool) {
showingDirtyAlert = false
guard let action = pendingAction else { return }
if save {
Task { @MainActor [weak self] in
guard let self else { return }
if await self.saveProjectAsync() {
self.pendingAction = nil
self.proceed(with: action)
}
}
} else {
pendingAction = nil
proceed(with: action)
}
}
/// `btnProjectDirtyCancel` abandons the pending action.
func cancelDirty() {
pendingAction = nil
showingDirtyAlert = false
}
private func proceed(with action: PendingAction) {
switch action {
case .new:
confirmNew()
case .open(let url):
open(url)
case .close:
closeProject()
}
}
// MARK: - Report
/// `menuProjectReport` writes `{cwd}/{basename}-report.md`
/// atomically (generated; overwrites). No picker.
func saveReport() {
guard canReport, let cwd = workflow.wizard.workingDirectory else {
return
}
let basename = workflow.wizard.basename
Task { @MainActor [weak self] in
guard let self else { return }
let stem = self.workflow.wizard.profileBasename ?? basename
let snapshot = await self.lastVerificationSnapshot(stem: stem)
?? self.project?.lastVerification
let queue = self.workflow.print.selectedPrinter
let display = self.workflow.print.printers
.first { $0.name == queue }?.displayName
?? self.workflow.wizard.printerName
let recipeName = self.workflow.media.recipes
.first { $0.id == self.workflow.media.selectedRecipeID }?.name
let payload = ICCeryProject(
name: self.project?.name.isEmpty == false
? self.project!.name : basename,
notes: self.project?.notes ?? "",
basename: basename,
cwd: cwd.path,
profileBasename: self.workflow.wizard.profileBasename,
printerID: queue.isEmpty ? nil : queue,
printerDisplayName: display,
mediaRecipeID: self.workflow.media.selectedRecipeID == "none"
? nil : self.workflow.media.selectedRecipeID,
presetID: self.workflow.selectedPresetID == "none"
? nil : self.workflow.selectedPresetID,
calibrationURL: self.workflow.profile.calibrationFile.isEmpty
? nil : self.workflow.profile.calibrationFile,
lastVerification: snapshot,
updated: Date())
do {
let written = try ProjectReport.write(
project: payload,
recipeName: recipeName,
artefacts: self.workflow.wizard.artefacts)
self.workflow.wizard.showNotice(
"Wrote \(written.lastPathComponent)")
} catch {
self.workflow.wizard.showNotice(
"Report failed: \(error.localizedDescription)",
kind: .error)
}
}
}
// MARK: - Recents / reveal
/// Rebuilds the recents list; entries whose file is gone are
/// dropped here (submenu build), not at launch. Corrupt `[]`,
/// file kept (R12).
func refreshRecents() {
Task { @MainActor [weak self] in
guard let self else { return }
do {
self.recents = try await self.recentsStore.pruneMissing()
} catch {
self.recents = []
}
}
}
private func pushRecent(url: URL, name: String) {
Task { @MainActor [weak self] in
guard let self else { return }
try? await self.recentsStore.add(url: url, name: name)
self.refreshRecents()
}
}
/// `menuProjectRecentsClear` wipes `recent_projects.json` only;
/// `.icceryproj` files are never deleted.
func clearRecents() {
Task { @MainActor [weak self] in
try? await self?.recentsStore.clear()
self?.recents = []
}
}
/// `btnProjectReveal` reveals the bound **project file** in
/// Finder, not the cwd.
func revealInFinder() {
guard let projectURL else { return }
NSWorkspace.shared.activateFileViewerSelecting([projectURL])
}
}
-151
View File
@@ -1,151 +0,0 @@
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")
}
}
+6 -67
View File
@@ -5,20 +5,12 @@ import ICCeryCore
/// Root layout: 270 pt sidebar + main stage area with the notification
/// banner pinned to the top (docs/21 §Shell).
struct RootView: View {
@ObservedObject var workflow: TargetWorkflowViewModel
/// Observed directly: nested ObservableObjects are not tracked
/// through the parent's `objectWillChange`.
@ObservedObject private var model: WizardViewModel
@ObservedObject private var project: ProjectSession
@Bindable var workflow: TargetWorkflowViewModel
@State private var showingSettings = false
@State private var showingAbout = false
@State private var showingAllHelp = false
init(workflow: TargetWorkflowViewModel) {
self.workflow = workflow
self._model = ObservedObject(wrappedValue: workflow.wizard)
self._project = ObservedObject(wrappedValue: workflow.project)
}
private var model: WizardViewModel { workflow.wizard }
var body: some View {
HStack(spacing: 0) {
@@ -58,25 +50,6 @@ 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 }
}
@@ -84,52 +57,18 @@ struct RootView: View {
get: { workflow.wizard.showingGamutViewer },
set: { workflow.wizard.showingGamutViewer = $0 }
)) {
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)
GamutView(profileGamURL: workflow.wizard.gamutProfileURL)
}
}
}
/// Content for the active wizard stage. Isolated into its own view so that
/// `WizardViewModel` is tracked via `@ObservedObject` instead of the parent's
/// `WizardViewModel` is tracked via `@Bindable` instead of the parent's
/// `TargetWorkflowViewModel`, which does not observe nested `wizard` mutations.
private struct WizardStageContent: View {
@ObservedObject var model: WizardViewModel
@ObservedObject var workflow: TargetWorkflowViewModel
@Bindable var model: WizardViewModel
var workflow: TargetWorkflowViewModel
var body: some View {
switch model.stage {
+3 -2
View File
@@ -4,7 +4,7 @@ import ICCeryCore
/// Settings sheet (issue #5, docs/21 §Settings). Dark-theme Form with
/// the full v1 field set; ΔE validation shows inline under the fields.
struct SettingsView: View {
@StateObject var model = SettingsViewModel()
@State var model = SettingsViewModel()
@Environment(\.dismiss) private var dismiss
private static let instruments: [(code: String, label: String)] = [
@@ -58,7 +58,7 @@ struct SettingsView: View {
Text($0.label).tag($0.code)
}
}
Text("Seeds Spot Read and Stage 3 when the instrument is plugged in. printtarg -i is still chosen on Stage 2.")
Text("Display-only — Stage 2's instrument select is used for actual runs.")
.font(.caption)
.foregroundStyle(.secondary)
@@ -143,6 +143,7 @@ struct SettingsView: View {
}
}
}
.formStyle(.grouped)
Divider()
+6 -6
View File
@@ -1,5 +1,4 @@
import AppKit
import Combine
import Foundation
import ICCeryCore
@@ -7,11 +6,12 @@ import ICCeryCore
/// validation; the log level is applied live via `LogSink` (#158) and a
/// `settingsDidChange` notification fans out to #20.
@MainActor
final class SettingsViewModel: ObservableObject {
@Observable
final class SettingsViewModel {
@Published var settings: AppSettings
@Published var validationErrors: [String] = []
@Published var savedFlash = false
var settings: AppSettings
var validationErrors: [String] = []
var savedFlash = false
private let store: SettingsStore
private let sink: LogSink
@@ -33,7 +33,7 @@ final class SettingsViewModel: ObservableObject {
sink.applySettings(settings)
savedFlash = true
Task {
try? await Task.sleep(nanoseconds: 1_500_000_000)
try? await Task.sleep(for: .seconds(1.5))
savedFlash = false
}
return true
+2 -105
View File
@@ -4,36 +4,12 @@ import ICCeryCore
/// 270 pt sidebar (docs/21 §Shell): logo, settings/about buttons, preset
/// select, Calibrate Printer + status chip, and the 15 stepper.
struct SidebarView: View {
@ObservedObject var workflow: TargetWorkflowViewModel
/// Observed directly: nested ObservableObjects are not tracked
/// 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
@Bindable var workflow: TargetWorkflowViewModel
var onOpenSettings: () -> Void
var onOpenAbout: () -> Void
@Binding var showingAllHelp: Bool
init(
workflow: TargetWorkflowViewModel,
onOpenSettings: @escaping () -> Void,
onOpenAbout: @escaping () -> Void,
showingAllHelp: Binding<Bool>
) {
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
}
private var model: WizardViewModel { workflow.wizard }
var body: some View {
VStack(alignment: .leading, spacing: 0) {
@@ -98,56 +74,6 @@ 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")
@@ -162,32 +88,9 @@ struct SidebarView: View {
.frame(maxWidth: .infinity)
}
.controlSize(.large)
.helpOverlay(
"View the profile gamut in 3D against sRGB.",
showing: $showingAllHelp)
.accessibilityIdentifier("btnViewGamut")
.padding(.horizontal, 12)
// 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)
@@ -207,12 +110,6 @@ 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)
-360
View File
@@ -1,360 +0,0 @@
import SwiftUI
import ICCeryCore
/// Spot Read sheet (issue #148) one patch Lab/XYZ from the live
/// instrument. A `RootView` sheet, not a wizard stage and not a Stage 3
/// tab; all identifiers are `spot*` Stage 3 `chartread` ids are never
/// reused here.
struct SpotReadView: View {
@ObservedObject var model: SpotReadViewModel
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(alignment: .leading, spacing: 12) {
header
if !model.sidecarAvailable {
missingSidecar
} else {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
instrumentCard
promptLine
transport
lastSampleCard
historySection
}
}
}
footer
}
.padding(16)
.frame(width: 560, height: 640)
.background(Theme.background)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("spotReadView")
.onAppear { model.sheetOpened() }
.onDisappear { model.sheetClosed() }
}
// MARK: - Header / missing sidecar
private var header: some View {
HStack(alignment: .firstTextBaseline) {
Text("Spot Read")
.font(.title3)
.foregroundStyle(Theme.text)
Spacer()
if model.isRunning {
ProgressView()
.scaleEffect(0.8)
}
}
}
private var missingSidecar: some View {
VStack(alignment: .leading, spacing: 12) {
Text("spotread sidecar missing — run fetch-argyll")
.foregroundStyle(Theme.text)
.accessibilityIdentifier("spotSidecarMissing")
Spacer()
}
}
// MARK: - Instrument card (clones Stage 3 look, own ids)
private var instrumentCard: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("Instrument")
.font(.headline)
.foregroundStyle(Theme.text)
Spacer()
Button(action: { model.detectInstruments() }) {
Image(systemName: "arrow.clockwise")
}
.disabled(!model.canDetect)
.accessibilityIdentifier("btnSpotDetectInstruments")
}
if let error = model.detectionError {
Text(error)
.font(.caption)
.foregroundStyle(.red)
.accessibilityIdentifier("spotDetectError")
}
Picker("Instrument", selection: Binding(
get: { instrumentTag },
set: { newTag in
if newTag.isEmpty {
model.selectedInstrument = .auto
} else if let device = model.instruments.first(where: { "\($0.port)" == newTag }) {
model.selectedInstrument = .device(device)
}
}
)) {
Text("Auto (first available port)").tag("")
ForEach(model.instruments) { device in
Text(device.displayName).tag("\(device.port)")
}
}
.pickerStyle(.menu)
.disabled(model.isRunning)
.accessibilityIdentifier("spotInstrumentSelect")
if model.defaultMissing {
Text("Saved default instrument not present")
.font(.caption)
.foregroundStyle(.orange)
.accessibilityIdentifier("spotDefaultMissing")
}
Toggle("Also set as default instrument", isOn: Binding(
get: { model.setAsDefault },
set: { model.applyDefaultToggle($0) }
))
.accessibilityIdentifier("spotSetDefault")
if model.selectedInstrument.isXY {
Text("XY tables use Stage 3. Spot Read is a handheld / reflective probe.")
.font(.caption)
.foregroundStyle(Theme.accent)
.accessibilityIdentifier("spotXYHint")
}
}
.padding(16)
.background(Theme.panel)
}
private var instrumentTag: String {
switch model.selectedInstrument {
case .auto:
return ""
case .device(let device):
return "\(device.port)"
}
}
// MARK: - Prompt line
private var promptLine: some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text("Status")
.font(.headline)
.foregroundStyle(Theme.text)
Spacer()
Text(model.prompt)
.font(.callout)
.foregroundStyle(Theme.text)
.accessibilityIdentifier("spotPrompt")
}
if let error = model.lastError {
Text(error)
.font(.caption)
.foregroundStyle(.red)
.accessibilityIdentifier("spotLastError")
.accessibilityValue(error)
}
if !model.log.isEmpty {
ProcessLogView(
lines: model.log,
minHeight: 60,
maxHeight: 100,
containerId: "spotLogContainer",
logId: "spotLog"
)
}
}
.padding(16)
.background(Theme.panel)
}
// MARK: - Transport
private var transport: some View {
HStack(spacing: 12) {
if !model.isRunning {
Button("Start") { model.start() }
.disabled(!model.canStart)
.accessibilityIdentifier("btnSpotStart")
} else {
switch model.state {
case .calibrating:
Button("Calibrate") { model.calibrate() }
.accessibilityIdentifier("btnSpotCalibrate")
case .awaitingStrip:
Button("Read") { model.trigger() }
.accessibilityIdentifier("btnSpotTrigger")
default:
EmptyView()
}
Button("Stop") { model.stopIfNeeded() }
.accessibilityIdentifier("btnSpotStop")
}
Spacer()
}
.padding(.horizontal, 4)
}
// MARK: - Last sample
@ViewBuilder
private var lastSampleCard: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Last sample")
.font(.headline)
.foregroundStyle(Theme.text)
if let sample = model.displayedSample {
HStack(spacing: 16) {
let rgb = LabColorMath.labToSRGB(sample.lab)
RoundedRectangle(cornerRadius: 4)
.fill(Color(red: rgb.r, green: rgb.g, blue: rgb.b))
.frame(width: 32, height: 32)
.overlay(RoundedRectangle(cornerRadius: 4).stroke(Theme.border))
.accessibilityIdentifier("spotSwatch")
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 12) {
Text(String(format: "L* %.1f", sample.lab.l))
.accessibilityIdentifier("spotLabL")
Text(String(format: "a* %.1f", sample.lab.a))
.accessibilityIdentifier("spotLabA")
Text(String(format: "b* %.1f", sample.lab.b))
.accessibilityIdentifier("spotLabB")
}
.font(.callout)
.foregroundStyle(Theme.text)
if let xyz = sample.xyz {
Text(String(format: "XYZ %.2f %.2f %.2f", xyz.x, xyz.y, xyz.z))
.font(.caption)
.foregroundStyle(.secondary)
.accessibilityIdentifier("spotXYZ")
}
HStack(spacing: 8) {
Text(sample.port.map { "\(sample.instrumentName) · port \($0)" }
?? sample.instrumentName)
.font(.caption)
.foregroundStyle(.secondary)
.accessibilityIdentifier("spotLastInstrument")
if let de = model.displayedDeltaE {
HStack(spacing: 6) {
Circle()
.fill(deltaEColor)
.frame(width: 8, height: 8)
Text(String(format: "ΔE %.2f", de))
.font(.caption)
.foregroundStyle(.secondary)
}
.accessibilityElement(children: .contain)
.accessibilityIdentifier("spotDeltaE")
}
}
if model.isDisplayedLabImplausible {
Text("Implausible L*")
.font(.caption)
.foregroundStyle(.orange)
.accessibilityIdentifier("spotLabImplausible")
}
}
Spacer()
}
.accessibilityElement(children: .contain)
.accessibilityIdentifier("spotLastSample")
} else {
Text("No readings yet.")
.font(.caption)
.foregroundStyle(.secondary)
.accessibilityIdentifier("spotLastEmpty")
}
}
.padding(16)
.background(Theme.panel)
}
private var deltaEColor: Color {
switch model.deltaEClassification {
case .good, nil: return .green
case .warning: return .orange
case .bad: return .red
}
}
// MARK: - History
@ViewBuilder
private var historySection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("History")
.font(.headline)
.foregroundStyle(Theme.text)
if model.samples.isEmpty {
Text("No history.")
.font(.caption)
.foregroundStyle(.secondary)
.accessibilityIdentifier("spotHistoryEmpty")
} else {
List {
ForEach(Array(model.samples.enumerated()), id: \.element.id) { index, sample in
historyRow(index: index, sample: sample)
}
}
.frame(minHeight: 120)
.accessibilityIdentifier("spotHistoryTable")
}
}
.padding(16)
.background(Theme.panel)
}
private func historyRow(index: Int, sample: SpotReadSample) -> some View {
let previous = index + 1 < model.samples.count ? model.samples[index + 1] : nil
let deltaE = previous.map { ColorDifference.deltaE00($0.lab, sample.lab) }
return Button(action: { model.selectFromHistory(sample) }) {
HStack(spacing: 10) {
Text(sample.timestamp, style: .time)
.frame(width: 70, alignment: .leading)
Text(String(format: "%.1f", sample.lab.l))
.frame(width: 44, alignment: .trailing)
Text(String(format: "%.1f", sample.lab.a))
.frame(width: 44, alignment: .trailing)
Text(String(format: "%.1f", sample.lab.b))
.frame(width: 44, alignment: .trailing)
Text(deltaE.map { String(format: "%.2f", $0) } ?? "")
.frame(width: 44, alignment: .trailing)
Text(sample.instrumentName)
.lineLimit(1)
Spacer()
}
.font(.caption)
.foregroundStyle(Theme.text)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityIdentifier("spotHistoryRow-\(sample.id.uuidString)")
}
// MARK: - Footer
private var footer: some View {
HStack(spacing: 12) {
Button("Copy Lab") { model.copyLab() }
.disabled(model.displayedSample == nil)
.accessibilityIdentifier("btnSpotCopyLab")
Button("Export CSV…") { model.exportCsv() }
.disabled(model.samples.isEmpty)
.accessibilityIdentifier("btnSpotExportCsv")
Spacer()
Button("Close") { dismiss() }
.keyboardShortcut(.cancelAction)
.accessibilityIdentifier("btnCloseSpotRead")
}
.padding(.top, 4)
}
}
-420
View File
@@ -1,420 +0,0 @@
import AppKit
import Combine
import Foundation
import ICCeryCore
/// Spot-read console state and interaction (issue #148).
///
/// Runs the bundled `spotread` sidecar under the single-lease process id
/// `spotread`; Stage 3 `chartread` is untouched. `defaultInstrument`
/// seeds the instrument picker on sheet open it is never written into
/// `printtarg -i` or `targen` argv (R15).
@MainActor
final class SpotReadViewModel: ObservableObject {
let workflow: TargetWorkflowViewModel
let environment: AppEnvironment
private let fileDialogs = FileDialogService.shared
// MARK: - Instrument card
@Published var instruments: [InstrumentDevice] = []
@Published var selectedInstrument: InstrumentSelection = .auto
@Published var isDetecting = false
@Published var detectionError: String?
/// `spotDefaultMissing` set when `defaultInstrument` is saved but
/// no detected device matches it.
@Published var defaultMissing = false
/// `spotSetDefault` toggle state.
@Published var setAsDefault = false
// MARK: - Session
@Published var isRunning = false
@Published var state: ChartreadState = .idle
@Published var prompt = "Press Start to open the instrument."
@Published var lastError: String?
@Published var log: [String] = []
// MARK: - Samples / history (in-memory, cap 50, newest first)
@Published private(set) var samples: [SpotReadSample] = []
@Published private(set) var displayedSample: SpotReadSample?
@Published private(set) var displayedDeltaE: Double?
private let historyLimit = 50
private var streamTask: Task<Void, Never>?
init(workflow: TargetWorkflowViewModel, environment: AppEnvironment) {
self.workflow = workflow
self.environment = environment
}
// MARK: - Derived state
/// Whether the bundled `spotread` sidecar resolves to an executable.
/// `BinaryResolver` only never `$PATH`, never `chartread`.
var sidecarAvailable: Bool {
let url = environment.runner.binaryResolver.resolve("spotread")
return environment.runner.binaryResolver.exists(url)
}
var isChartreadRunning: Bool { workflow.measurement.isChartreadRunning }
var canStart: Bool {
sidecarAvailable && !isDetecting && !isRunning && !isChartreadRunning
&& workflow.wizard.effectiveWorkingDirectory != nil
}
var canDetect: Bool { !isDetecting && !isRunning }
var deltaEClassification: SwatchClassification? {
guard let de = displayedDeltaE else { return nil }
let settings = environment.settingsStore.load()
return ColorDifference.classify(
deltaE: de,
goodMax: settings.deltaEGoodMax,
warningMax: settings.deltaEWarningMax)
}
/// `spotLabImplausible` L* outside 0100 still displays, unclamped.
var isDisplayedLabImplausible: Bool {
guard let l = displayedSample?.lab.l else { return false }
return l < 0 || l > 100
}
// MARK: - Sheet lifecycle
/// Called from `SpotReadView.onAppear`. Resets the in-memory session
/// and runs detection once; a missing sidecar gets a wizard notice.
func sheetOpened() {
samples = []
displayedSample = nil
displayedDeltaE = nil
log = []
lastError = nil
state = .idle
prompt = "Press Start to open the instrument."
guard sidecarAvailable else {
workflow.wizard.showNotice(
"spotread sidecar missing — run fetch-argyll", kind: .error)
return
}
detectInstruments()
}
/// Called from `onDisappear` *and* the sheet's `onDismiss` clearing
/// the flag alone is not enough; a live child must be quit and
/// killed (R14).
func sheetClosed() {
stopIfNeeded()
samples = []
displayedSample = nil
displayedDeltaE = nil
log = []
defaultMissing = false
}
// MARK: - Detection
func detectInstruments() {
guard canDetect else { return }
isDetecting = true
detectionError = nil
Task { @MainActor [weak self] in
guard let self else { return }
// `instlist` is an exclusive lease (#116) never spawn a
// second one; surface the busy state instead.
if await self.environment.runner.processManager.isRunning(ProcessID.instlist) {
self.detectionError = "Instrument detection is already running."
self.isDetecting = false
return
}
do {
let devices = try await self.environment.runner.detectInstruments()
self.instruments = devices
self.seedDefault(from: devices)
if case .device(let selected) = self.selectedInstrument,
!devices.contains(where: { $0.port == selected.port }) {
self.selectedInstrument = .auto
}
} catch {
self.detectionError = error.localizedDescription
}
self.isDetecting = false
}
}
/// Seed the picker from `AppSettings.defaultInstrument`; no match
/// `.auto` + `spotDefaultMissing`.
private func seedDefault(from devices: [InstrumentDevice]) {
guard let code = environment.settingsStore.load().defaultInstrument,
!code.isEmpty else {
defaultMissing = false
return
}
if let match = devices.first(where: { Self.matches(code: code, device: $0) }) {
selectedInstrument = .device(match)
defaultMissing = false
} else {
selectedInstrument = .auto
defaultMissing = true
}
}
/// Whether an `instlist` device corresponds to a `printtarg -i` /
/// settings instrument code (`i1`, `CM`, `p3`, `SS`, `20`/`22`/`41`/`51`).
static func matches(code: String, device: InstrumentDevice) -> Bool {
let haystack = "\(device.name) \(device.type)".lowercased()
switch code {
case "i1": return haystack.contains("i1pro") && !haystack.contains("i1pro 3") && !haystack.contains("i1pro3")
case "p3": return haystack.contains("i1pro 3") || haystack.contains("i1pro3")
case "CM": return haystack.contains("colormunki")
case "SS": return haystack.contains("specbos") || haystack.contains("spectraval") || haystack.contains("spectroscan") || haystack.contains("spectro scan")
case "20": return haystack.contains("display 2")
case "22": return haystack.contains("display")
case "41": return haystack.contains("spyder 4") || haystack.contains("spyder 5") || haystack.contains("spyder4") || haystack.contains("spyder5")
case "51": return haystack.contains("spyder x")
default: return false
}
}
/// Reverse of `matches` most specific codes first.
static func code(for device: InstrumentDevice) -> String? {
for code in ["p3", "51", "41", "22", "20", "CM", "SS", "i1"]
where matches(code: code, device: device) {
return code
}
return nil
}
/// `spotSetDefault` writes `AppSettings.defaultInstrument` only.
/// Never touches `printtarg -i` or `targen`.
func applyDefaultToggle(_ on: Bool) {
setAsDefault = on
var settings = environment.settingsStore.load()
if on, case .device(let device) = selectedInstrument {
settings.defaultInstrument = Self.code(for: device)
} else if !on {
settings.defaultInstrument = nil
}
try? environment.settingsStore.save(settings)
}
// MARK: - Session control
func start() {
guard sidecarAvailable else {
lastError = "spotread sidecar missing — run fetch-argyll"
return
}
guard !isChartreadRunning else {
lastError = "Stop the Stage 3 chart read first."
return
}
guard let cwd = workflow.wizard.effectiveWorkingDirectory else {
lastError = "Set a working folder in Stage 1 first."
return
}
Task { @MainActor [weak self] in
guard let self else { return }
// `spotread` is an exclusive lease a second Start while a
// child is live is an error, not a kill + respawn (#116).
if await self.environment.runner.processManager.isRunning(ProcessID.spotread) {
self.lastError = "A spotread session is already running."
return
}
self.begin(config: self.buildConfig(cwd: cwd))
}
}
private func buildConfig(cwd: URL) -> SpotReadConfig {
let port: Int?
let name: String
switch selectedInstrument {
case .auto:
port = nil
name = "Auto"
case .device(let device):
port = device.port
name = device.name
}
return SpotReadConfig(
workingDirectory: cwd,
selectedPort: selectedInstrument.chartreadPort,
enableLEDs: environment.settingsStore.load().enableI1Pro2Leds,
isXY: selectedInstrument.isXY,
instrumentName: name,
instrumentPort: port
)
}
private func begin(config: SpotReadConfig) {
isRunning = true
state = .idle
lastError = nil
prompt = "Waiting for a reading…"
let stream = environment.runner.runSpotread(config: config)
streamTask = Task { @MainActor [weak self] in
guard let self else { return }
for await event in stream {
self.handle(event: event)
}
self.isRunning = false
self.state = .idle
if self.lastError == nil {
self.prompt = "Press Start to open the instrument."
}
}
}
private func handle(event: SpotReadEvent) {
switch event {
case .prompt(let result):
state = result.state
prompt = promptText(for: result.state)
case .sample(let sample):
let previous = samples.first
samples.insert(sample, at: 0)
if samples.count > historyLimit {
samples.removeLast()
}
displayedSample = sample
displayedDeltaE = previous.map {
ColorDifference.deltaE00($0.lab, sample.lab)
}
case .log(let batch):
log.append(contentsOf: batch)
case .exit(let code):
if code != 0 {
lastError = "spotread exited with code \(code)"
}
case .failed(let error):
lastError = error.localizedDescription
}
}
private func promptText(for state: ChartreadState) -> String {
switch state {
case .calibrating:
return "Place the instrument on the calibration tile, then Calibrate."
case .awaitingStrip:
return "Place on the patch, then Read."
case .reading, .promptContinue:
return "Waiting for a reading…"
case .warning:
return "Instrument warning — stop and restart if it persists."
case .error:
return "Read error — Stop, then Start again."
default:
return "Waiting for a reading…"
}
}
// MARK: - Transport
/// `btnSpotCalibrate` same bytes Stage 3 sends for calibrate.
func calibrate() {
send(.trigger)
}
/// `btnSpotTrigger` the Read key (`" \n"`).
func trigger() {
send(.trigger)
}
private func send(_ input: ChartreadInput) {
Task { @MainActor [weak self] in
guard let self, self.isRunning else { return }
try? await self.environment.runner.sendSpotreadInput(input)
}
}
/// `btnSpotStop` / sheet dismiss: `q\n`, ~500 ms, then kill if the
/// child is still live.
func stopIfNeeded() {
guard isRunning else { return }
streamTask?.cancel()
streamTask = nil
let processManager = environment.runner.processManager
Task { @MainActor in
try? await processManager.sendStdin(
id: ProcessID.spotread, bytes: ChartreadInput.quit.bytes)
try? await Task.sleep(nanoseconds: 500_000_000)
await processManager.kill(id: ProcessID.spotread)
}
isRunning = false
state = .idle
prompt = "Press Start to open the instrument."
}
// MARK: - History / export
/// Click a history row: copies that sample into the last-sample card.
/// Never re-triggers the instrument.
func selectFromHistory(_ sample: SpotReadSample) {
displayedSample = sample
if let index = samples.firstIndex(of: sample), index + 1 < samples.count {
displayedDeltaE = ColorDifference.deltaE00(samples[index + 1].lab, sample.lab)
} else {
displayedDeltaE = nil
}
}
/// `btnSpotCopyLab` `L* a* b*` of the displayed sample as plain
/// text (`50.0 1.2 -3.4`).
func copyLab() {
guard let sample = displayedSample else { return }
let text = String(format: "%.1f %.1f %.1f", sample.lab.l, sample.lab.a, sample.lab.b)
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(text, forType: .string)
}
/// `btnSpotExportCsv` RFC-4180 via `selectCsvSavePath`. Cancel is
/// a no-op. Rows are newest-first, matching the history list.
func exportCsv() {
guard !samples.isEmpty else { return }
let url = UITestHooks.isEnabled
? UITestHooks.csvExportURL
: fileDialogs.selectCsvSavePath()
guard let url else { return }
var out = "timestamp,L,a,b,dE00,instrument,port\r\n"
for (index, sample) in samples.enumerated() {
let deltaE = index + 1 < samples.count
? String(format: "%.2f", ColorDifference.deltaE00(samples[index + 1].lab, sample.lab))
: ""
out += "\(csvField(iso8601(sample.timestamp))),\(f1(sample.lab.l)),\(f1(sample.lab.a)),\(f1(sample.lab.b)),\(deltaE),\(csvField(sample.instrumentName)),\(sample.port.map(String.init) ?? "")\r\n"
}
do {
try out.write(to: url, atomically: true, encoding: .utf8)
workflow.wizard.showNotice("Spot readings exported: \(url.lastPathComponent)")
} catch {
workflow.wizard.showNotice(
"Export failed: \(error.localizedDescription)", kind: .error)
}
}
private func f1(_ value: Double) -> String {
String(format: "%.1f", value)
}
private func iso8601(_ date: Date) -> String {
ISO8601DateFormatter().string(from: date)
}
private func csvField(_ text: String) -> String {
guard text.contains(",") || text.contains("\"") || text.contains("\n") else {
return text
}
return "\"\(text.replacingOccurrences(of: "\"", with: "\"\""))\""
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ import ICCeryCore
/// docs/08). All documented element ids are wired as accessibility
/// identifiers so the UI-test contract stays stable.
struct Stage1View: View {
@ObservedObject var workflow: TargetWorkflowViewModel
@Bindable var workflow: TargetWorkflowViewModel
var body: some View {
ScrollView {
+6 -37
View File
@@ -5,19 +5,7 @@ import ICCeryCore
/// issues #9/#10, docs/09). Print controls are visible but inert
/// real spooling lands in M3.
struct Stage2View: View {
@ObservedObject var workflow: TargetWorkflowViewModel
/// Observed directly: nested ObservableObjects are not tracked
/// through the parent's `objectWillChange`.
@ObservedObject private var printSession: PrintSessionViewModel
@ObservedObject private var wizard: WizardViewModel
@State private var printGenerationTask: Task<Void, Never>?
init(workflow: TargetWorkflowViewModel) {
self.workflow = workflow
self._printSession = ObservedObject(wrappedValue: workflow.print)
self._wizard = ObservedObject(wrappedValue: workflow.wizard)
}
@Bindable var workflow: TargetWorkflowViewModel
var body: some View {
ScrollView {
@@ -230,7 +218,6 @@ struct Stage2View: View {
.foregroundStyle(notice.kind == .error
? .red : .blue)
.accessibilityIdentifier("printNotificationIcon")
.accessibilityValue(notice.kind.accessibilityValue)
Text(notice.text)
.font(.caption)
.foregroundStyle(notice.kind == .error
@@ -252,7 +239,7 @@ struct Stage2View: View {
}
.frame(maxWidth: 320)
.accessibilityIdentifier("printerSelect")
.onChange(of: workflow.print.selectedPrinter) { _ in
.onChange(of: workflow.print.selectedPrinter) { _, _ in
workflow.print.selectedTray = nil
workflow.print.selectedMediaType = nil
Task { @MainActor in await workflow.print.reloadSelectedCapabilities() }
@@ -340,18 +327,9 @@ struct Stage2View: View {
.clipShape(RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium))
.accessibilityElement(children: .contain)
.accessibilityIdentifier("rawPrintPanel")
.onAppear { schedulePrinterRefresh() }
.onChange(of: workflow.printtargResult?.pages.count) { _ in
schedulePrinterRefresh()
}
}
/// Auto-enumerates printers once a manifest exists and whenever it
/// changes (e.g. resume from .ti2). The explicit task handle keeps
/// a superseded run from racing the next one.
private func schedulePrinterRefresh() {
printGenerationTask?.cancel()
printGenerationTask = Task { @MainActor in
.task(id: workflow.printtargResult?.pages.count) {
// Auto-enumerate once a manifest exists and whenever it
// changes (e.g. resume from .ti2).
if workflow.print.printers.isEmpty, workflow.printtargResult != nil {
workflow.print.refreshPrinters()
}
@@ -362,16 +340,7 @@ struct Stage2View: View {
/// One gallery cell: PNG preview + per-page Print button.
private struct GalleryPageView: View {
let page: GalleryPage
@ObservedObject var workflow: TargetWorkflowViewModel
/// Observed directly: `print` is a nested ObservableObject and its
/// `isPrinting`/`selectedPrinter` changes drive this cell's button.
@ObservedObject private var printSession: PrintSessionViewModel
init(page: GalleryPage, workflow: TargetWorkflowViewModel) {
self.page = page
self.workflow = workflow
self._printSession = ObservedObject(wrappedValue: workflow.print)
}
let workflow: TargetWorkflowViewModel
var body: some View {
VStack(spacing: 6) {
+16 -20
View File
@@ -4,15 +4,7 @@ import ICCeryCore
/// Stage 3 measurement, live swatches, and multi-pass averaging.
struct Stage3View: View {
@ObservedObject var model: MeasurementWorkflowViewModel
/// `model.basename`/`model.workingDirectory` delegate to `wizard`;
/// observe it directly so header updates propagate.
@ObservedObject private var wizard: WizardViewModel
init(model: MeasurementWorkflowViewModel) {
self.model = model
self._wizard = ObservedObject(wrappedValue: model.wizard)
}
@Bindable var model: MeasurementWorkflowViewModel
var body: some View {
VStack(spacing: 0) {
@@ -172,22 +164,28 @@ struct Stage3View: View {
.foregroundStyle(Theme.accent)
}
if let notice = model.chartreadNotice {
Text(notice.text)
if let lastError = model.lastError {
Text(lastError)
.font(.caption)
.foregroundStyle(notice.kind.tint)
.foregroundStyle(.red)
.accessibilityIdentifier("chartreadLastError")
.accessibilityValue(notice.text)
.accessibilityValue(lastError)
}
controlButtons
if !model.chartreadLog.isEmpty {
ProcessLogView(
lines: model.chartreadLog,
containerId: "chartreadLogContainer",
logId: "chartreadLog"
)
DisclosureGroup("Log") {
VStack(alignment: .leading) {
ForEach(model.chartreadLog, id: \.self) { line in
Text(line)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
}
}
}
.foregroundStyle(Theme.text)
.accessibilityIdentifier("chartreadLogContainer")
}
}
.padding(16)
@@ -376,8 +374,6 @@ struct Stage3View: View {
Text(notice.text)
.font(.caption)
.foregroundStyle(notice.kind == .error ? .red : .green)
.accessibilityIdentifier("chartreadFinishNotice")
.accessibilityValue(notice.kind.accessibilityValue)
}
}
.padding(16)
+28 -27
View File
@@ -3,15 +3,7 @@ import ICCeryCore
/// Stage 4 build an ICC/ICM profile from the canonical `.ti3`.
struct Stage4View: View {
@ObservedObject var model: ProfileWorkflowViewModel
/// Header reads `model.wizard.basename`; observe the nested
/// ObservableObject directly.
@ObservedObject private var wizard: WizardViewModel
init(model: ProfileWorkflowViewModel) {
self.model = model
self._wizard = ObservedObject(wrappedValue: model.wizard)
}
@Bindable var model: ProfileWorkflowViewModel
var body: some View {
VStack(spacing: 0) {
@@ -138,20 +130,16 @@ struct Stage4View: View {
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("colprofCopyright")
// Nested VStack keeps the parent at the Swift 5.7 ViewBuilder
// 10-child limit (Xcode 14.2 / macOS 12 CI runner, #111).
VStack(alignment: .leading, spacing: 12) {
Toggle("Apply calibration curve", isOn: $model.applyCalibration)
.accessibilityIdentifier("colprofApplyCalibration")
Toggle("Apply calibration curve", isOn: $model.applyCalibration)
.accessibilityIdentifier("colprofApplyCalibration")
if model.applyCalibration {
HStack {
TextField("Calibration .cal file", text: $model.calibrationFile)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("colprofCalibrationFile")
Button("Browse…") { model.browseForCalibrationFile() }
.accessibilityIdentifier("btnBrowseCalibrationFile")
}
if model.applyCalibration {
HStack {
TextField("Calibration .cal file", text: $model.calibrationFile)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("colprofCalibrationFile")
Button("Browse…") { model.browseForCalibrationFile() }
.accessibilityIdentifier("btnBrowseCalibrationFile")
}
}
}
@@ -178,14 +166,27 @@ struct Stage4View: View {
}
Spacer()
if let lastError = model.lastError {
Text(lastError)
.font(.caption)
.foregroundStyle(.red)
.accessibilityIdentifier("colprofLastError")
}
}
if !model.colprofLog.isEmpty {
ProcessLogView(
lines: model.colprofLog,
containerId: "colprofLogContainer",
logId: "colprofLog"
)
DisclosureGroup("Log") {
VStack(alignment: .leading) {
ForEach(model.colprofLog, id: \.self) { line in
Text(line)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
}
}
}
.foregroundStyle(Theme.text)
.accessibilityIdentifier("colprofLogContainer")
}
}
.padding(16)
+1 -9
View File
@@ -3,15 +3,7 @@ import ICCeryCore
/// Stage 5 verify the generated profile, track drift, and install.
struct Stage5View: View {
@ObservedObject var model: ProfileWorkflowViewModel
/// Header/buttons read `model.wizard.*`; observe the nested
/// ObservableObject directly.
@ObservedObject private var wizard: WizardViewModel
init(model: ProfileWorkflowViewModel) {
self.model = model
self._wizard = ObservedObject(wrappedValue: model.wizard)
}
@Bindable var model: ProfileWorkflowViewModel
var body: some View {
VStack(spacing: 0) {
+64 -92
View File
@@ -1,5 +1,5 @@
import Combine
import Foundation
import Observation
import ICCeryCore
/// Stage 1/2 form state, runner orchestration, resume flow, and preset
@@ -10,7 +10,8 @@ import ICCeryCore
/// All process work runs through `ArgyllRunner` off `@MainActor`; only
/// coalesced log batches and completion hop back.
@MainActor
final class TargetWorkflowViewModel: ObservableObject {
@Observable
final class TargetWorkflowViewModel {
let wizard: WizardViewModel
let environment: AppEnvironment
@@ -18,112 +19,95 @@ final class TargetWorkflowViewModel: ObservableObject {
// MARK: - Stage 1 form (targen)
@Published var colourSpace: ColourSpace = .rgb {
var colourSpace: ColourSpace = .rgb {
didSet {
guard colourSpace != oldValue else { return }
// CMYK black patches default to 0, RGB to 4 (docs/08).
blackPatches = colourSpace == .cmyk ? 0 : 4
}
}
@Published var patchPreset: PatchCountPreset = .standard800
var patchPreset: PatchCountPreset = .standard800
/// `#patchCountCustom` used when `patchPreset == .custom`.
@Published var customPatchCount = 2500
@Published var whitePatches = 4
@Published var blackPatches = 4
var customPatchCount = 2500
var whitePatches = 4
var blackPatches = 4
// Advanced each optional flag is enabled + value, so an untouched
// control emits nothing (#advanced fields are opt-in).
@Published var greyStepsEnabled = false
@Published var greySteps = 5
@Published var singleChannelEnabled = false
@Published var singleChannelSteps = 5
@Published var neutralStepsEnabled = false
@Published var neutralSteps = 3
@Published var neutralConcEnabled = false
@Published var neutralConcentration = 0.50
@Published var preconditioningProfile: String?
@Published var highQuality = false
@Published var adaptationEnabled = false
@Published var adaptation = 0.10
@Published var algorithm: FullSpreadAlgorithm = .ofps
@Published var inkLimitEnabled = false
@Published var totalInkLimit = 320
@Published var darkEmphasisEnabled = false
@Published var darkEmphasis = 1.0
@Published var devicePowerEnabled = false
@Published var devicePower = 1.0
var greyStepsEnabled = false
var greySteps = 5
var singleChannelEnabled = false
var singleChannelSteps = 5
var neutralStepsEnabled = false
var neutralSteps = 3
var neutralConcEnabled = false
var neutralConcentration = 0.50
var preconditioningProfile: String?
var highQuality = false
var adaptationEnabled = false
var adaptation = 0.10
var algorithm: FullSpreadAlgorithm = .ofps
var inkLimitEnabled = false
var totalInkLimit = 320
var darkEmphasisEnabled = false
var darkEmphasis = 1.0
var devicePowerEnabled = false
var devicePower = 1.0
/// `#targetBasename` no placeholder is ever invented (#60).
@Published var targetBasename = ""
var targetBasename = ""
/// `#selectedPathDisplay` / resolved cwd.
@Published var targetDirectory: URL?
var targetDirectory: URL?
// MARK: - Stage 2 form (printtarg)
@Published var instrument: PrintInstrument = .i1
@Published var pageSize: PageSize = .a4
@Published var customPageW = 210.0
@Published var customPageH = 297.0
@Published var bitDepth: TiffBitDepth = .eight
var instrument: PrintInstrument = .i1
var pageSize: PageSize = .a4
var customPageW = 210.0
var customPageH = 297.0
var bitDepth: TiffBitDepth = .eight
/// `#tiffDpi` two-way bound; presets can change it (150-DPI draft
/// regression must be visible here).
@Published var tiffDpi = 300
@Published var layoutOrder: LayoutOrder = .deterministic
@Published var customSeed = 1
@Published var labelIsCustom = false
@Published var customLabel = ""
@Published var metaPrinter = ""
@Published var metaInkSet = ""
@Published var metaDriverPaper = ""
@Published var metaActualPaper = ""
var tiffDpi = 300
var layoutOrder: LayoutOrder = .deterministic
var customSeed = 1
var labelIsCustom = false
var customLabel = ""
var metaPrinter = ""
var metaInkSet = ""
var metaDriverPaper = ""
var metaActualPaper = ""
// MARK: - Run state
@Published var targenRunning = false
@Published var targenLog: [String] = []
@Published var printtargRunning = false
@Published var printtargLog: [String] = []
@Published var printtargResult: PrinttargResult?
var targenRunning = false
var targenLog: [String] = []
var printtargRunning = false
var printtargLog: [String] = []
var printtargResult: PrinttargResult?
/// Sticky until the target changes: `.ti2` resume landed us on
/// Stage 3 (`#stage3LoadedTargetBanner` data).
@Published var resumedFromTi2 = false
var resumedFromTi2 = false
// MARK: - Presets
@Published var presets: [ProfilingPreset] = []
@Published var selectedPresetID = "none"
@Published var showingSavePreset = false
@Published var showingManagePresets = false
@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
var presets: [ProfilingPreset] = []
var selectedPresetID = "none"
var showingSavePreset = false
var showingManagePresets = false
var savePresetName = ""
var savePresetDesc = ""
/// Stage 3 measurement workflow, owned at the app level so it persists
/// across stage switches and can observe settings changes.
@Published var measurement: MeasurementWorkflowViewModel
var measurement: MeasurementWorkflowViewModel
/// Stage 4/5 profile workflow, owned at the app level so it persists
/// across stage switches and can observe preset values.
@Published var profile: ProfileWorkflowViewModel
var profile: ProfileWorkflowViewModel
/// Stage 0 calibration workflow.
@Published var calibration: CalibrationViewModel!
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!
var print: PrintSessionViewModel!
init(environment: AppEnvironment = .live()) {
self.environment = environment
@@ -143,18 +127,6 @@ 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()
}
@@ -234,6 +206,8 @@ final class TargetWorkflowViewModel: ObservableObject {
func generateTarget() {
guard canGenerate, !targenRunning else { return }
let config = buildTargenConfig()
targenRunning = true
targenLog = []
resumedFromTi2 = false
let runner = environment.runner
Task { @MainActor in
@@ -254,6 +228,7 @@ final class TargetWorkflowViewModel: ObservableObject {
} catch {
wizard.showNotice(
"targen failed: \(error.localizedDescription)", kind: .error)
targenRunning = false
}
}
}
@@ -267,12 +242,7 @@ final class TargetWorkflowViewModel: ObservableObject {
? UITestHooks.datasetImportURL
: fileDialogs.selectDatasetFile()
guard let url else { return }
importMeasurementDataset(from: url)
}
/// Test seam (issue #80): unit tests pass missing or malformed URLs
/// directly instead of mutating the global environment.
func importMeasurementDataset(from url: URL) {
do {
let dataset = try CGATSParser.parse(url: url)
guard let directory = targetDirectory ?? wizard.effectiveWorkingDirectory else {
@@ -369,6 +339,8 @@ final class TargetWorkflowViewModel: ObservableObject {
func createLayout() {
guard wizard.isUnlocked(.layOutPrint), !printtargRunning else { return }
let config = buildPrinttargConfig()
printtargRunning = true
printtargLog = []
printtargResult = nil
let runner = environment.runner
Task { @MainActor in
+15 -14
View File
@@ -1,5 +1,5 @@
import Combine
import Foundation
import Observation
import ICCeryCore
/// Wizard state machine + artefact gating (issue #4, docs/06).
@@ -10,46 +10,47 @@ import ICCeryCore
/// `wizard_state.json`; unlocks come from `ArtefactProbe.verify`
/// navigation is disk, not buttons.
@MainActor
final class WizardViewModel: ObservableObject {
@Observable
final class WizardViewModel {
// MARK: - wizardState fields (persisted)
@Published var stage: WizardStage {
var stage: WizardStage {
didSet { if stage != oldValue { persist() } }
}
/// `wizardState.basename` empty until a real artefact names it (#60).
@Published var basename: String {
var basename: String {
didSet { if basename != oldValue { refreshGating(); persist() } }
}
/// `wizardState.cwd` resolved via `resolveSafeCwd` (#59).
@Published var workingDirectory: URL? {
var workingDirectory: URL? {
didSet { if workingDirectory != oldValue { refreshGating(); persist() } }
}
@Published var printerName: String? {
var printerName: String? {
didSet { if printerName != oldValue { persist() } }
}
@Published var sessionMode: SessionMode {
var sessionMode: SessionMode {
didSet { if sessionMode != oldValue { persist() } }
}
/// `profileBasename` may differ after a `.ti3` import (#94).
@Published var profileBasename: String? {
var profileBasename: String? {
didSet { if profileBasename != oldValue { persist() } }
}
/// Pre-`CAL_` basename, persisted so relaunch/Force Quit can restore it (#29).
@Published var calibrationOriginalBasename: String {
var calibrationOriginalBasename: String {
didSet { if calibrationOriginalBasename != oldValue { persist() } }
}
// MARK: - Ephemeral
/// Banner notice currently displayed (`#wizardNotification`).
@Published var notice: Notice?
var notice: Notice?
/// Current artefact probe result; recomputed on `refreshGating()`.
@Published private(set) var artefacts = StageArtefacts()
private(set) var artefacts = StageArtefacts()
/// Whether the 3D gamut viewer sheet is open (issue #28).
@Published var showingGamutViewer = false
var showingGamutViewer = false
/// Optional `.gam` URL to show alongside the sRGB reference.
@Published var gamutProfileURL: URL?
var gamutProfileURL: URL?
private let stateStore: WizardStateStore
private var noticeDismissTask: Task<Void, Never>?
@@ -200,7 +201,7 @@ final class WizardViewModel: ObservableObject {
self.notice = notice
if let delay = notice.autoHideAfter {
noticeDismissTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
try? await Task.sleep(for: .seconds(delay))
guard !Task.isCancelled else { return }
if self?.notice?.id == notice.id {
self?.notice = nil
+15 -13
View File
@@ -1,25 +1,27 @@
import XCTest
import Testing
import Foundation
@testable import ICCeryCore
final class AppPathsTests: XCTestCase {
func testAppDataDirUsesBundleID() {
XCTAssertTrue(AppPaths.appDataDir.path.contains("Library/Application Support/com.gronod.iccery2"))
@Suite("AppPaths")
struct AppPathsTests {
@Test func appDataDirUsesBundleID() {
#expect(AppPaths.appDataDir.path.contains("Library/Application Support/com.gronod.iccery2"))
}
func testLogFileIsUnderLibraryLogs() {
XCTAssertEqual(AppPaths.logFile.lastPathComponent, "iccery.log")
XCTAssertTrue(AppPaths.logFile.path.contains("Library/Logs/com.gronod.iccery2"))
@Test func logFileIsUnderLibraryLogs() {
#expect(AppPaths.logFile.lastPathComponent == "iccery.log")
#expect(AppPaths.logFile.path.contains("Library/Logs/com.gronod.iccery2"))
}
func testBundledArgyllDirIsInsideResources() {
XCTAssertEqual(AppPaths.bundledArgyllDir.lastPathComponent, "Argyll")
@Test func bundledArgyllDirIsInsideResources() {
#expect(AppPaths.bundledArgyllDir.lastPathComponent == "Argyll")
}
}
final class WizardStageTests: XCTestCase {
func testStepperOrderIsOneThroughFive() {
XCTAssertEqual(WizardStage.stepperStages.map(\.stepperIndex), [1, 2, 3, 4, 5])
XCTAssertNil(WizardStage.calibrate.stepperIndex)
@Suite("WizardStage")
struct WizardStageTests {
@Test func stepperOrderIsOneThroughFive() {
#expect(WizardStage.stepperStages.map(\.stepperIndex) == [1, 2, 3, 4, 5])
#expect(WizardStage.calibrate.stepperIndex == nil)
}
}
@@ -1,19 +1,22 @@
import Foundation
import XCTest
import Testing
@testable import ICCeryCore
final class ApplycalArgsTests: XCTestCase {
@Suite("ApplycalArgs")
struct ApplycalArgsTests {
func testApplyArgv() throws {
@Test("Apply argv")
func applyArgv() throws {
let config = ApplycalConfig(
calibrationPath: "/tmp/cal.cal",
inputProfileURL: URL(fileURLWithPath: "/tmp/profile.icc")
)
let args = try ApplycalArgs.build(config: config)
XCTAssertEqual(args, ["-v", "-a", "/tmp/cal.cal", "/tmp/profile.icc"])
#expect(args == ["-v", "-a", "/tmp/cal.cal", "/tmp/profile.icc"])
}
func testUnapplyEmittedWhenConfigSet() throws {
@Test("Unapply is emitted when the caller explicitly sets it")
func unapplyEmittedWhenConfigSet() throws {
let config = ApplycalConfig(
calibrationPath: "/tmp/cal.cal",
inputProfileURL: URL(fileURLWithPath: "/tmp/profile.icc"),
@@ -22,6 +25,6 @@ final class ApplycalArgsTests: XCTestCase {
let args = try ApplycalArgs.build(config: config)
// Builder emits -u only when the caller explicitly sets unapply.
// The UI layer never passes unapply: true in v2.0.
XCTAssertEqual(args, ["-v", "-u", "/tmp/cal.cal", "/tmp/profile.icc"])
#expect(args == ["-v", "-u", "/tmp/cal.cal", "/tmp/profile.icc"])
}
}
@@ -1,39 +0,0 @@
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)
}
}
@@ -1,80 +0,0 @@
import XCTest
import Foundation
@testable import ICCeryCore
final class ArgsBuilderTests: XCTestCase {
// MARK: - option
func testOptionNil() {
XCTAssertEqual(ArgsBuilder.option("-f", nil), [])
}
func testOptionPresent() {
XCTAssertEqual(ArgsBuilder.option("-f", "abc"), ["-f", "abc"])
XCTAssertEqual(ArgsBuilder.option("-f", ""), ["-f", ""])
XCTAssertEqual(ArgsBuilder.option("-f", " padded "), ["-f", " padded "])
}
// MARK: - optionIfNonEmpty
func testOptionIfNonEmptyNilEmpty() {
XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", nil), [])
XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", ""), [])
}
func testOptionIfNonEmptyWhitespace() {
XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", " "), [])
XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", " \t\n "), [])
}
func testOptionIfNonEmptyTrims() {
XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", " label "), ["-d", "label"])
XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", "\tcal.cal\n"), ["-d", "cal.cal"])
}
// MARK: - optionUnlessApprox
func testOptionUnlessApproxNil() {
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", nil, skip: 0.50), [])
}
func testOptionUnlessApproxExactSkip() {
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", 0.50, skip: 0.50), [])
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-V", 1.0, skip: 1.0), [])
}
func testOptionUnlessApproxWithinEpsilon() {
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", 0.5005, skip: 0.50), [])
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-V", 0.9995, skip: 1.0), [])
}
func testOptionUnlessApproxOutsideEpsilon() {
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", 0.75, skip: 0.50), ["-N", "0.75"])
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-V", 1.50, skip: 1.0), ["-V", "1.50"])
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", 0.498, skip: 0.50), ["-N", "0.50"])
}
func testOptionUnlessApproxPOSIX() {
// 1234.5 must never produce a grouping separator or comma decimal.
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-p", 1234.5, skip: 1.0), ["-p", "1234.50"])
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-p", 2.0, skip: 1.0), ["-p", "2.00"])
}
func testOptionUnlessApproxCustom() {
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-x", 1.005, skip: 1.0, epsilon: 0.01), [])
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-x", 1.5, skip: 1.0, format: "%.1f"), ["-x", "1.5"])
}
// MARK: - flag
func testFlagTrue() {
XCTAssertEqual(ArgsBuilder.flag("-G", when: true), ["-G"])
XCTAssertEqual(ArgsBuilder.flag("-r", when: true), ["-r"])
}
func testFlagFalse() {
XCTAssertEqual(ArgsBuilder.flag("-G", when: false), [])
XCTAssertEqual(ArgsBuilder.flag("-r", when: false), [])
}
}
@@ -1,16 +1,17 @@
import Foundation
import XCTest
import Testing
@testable import ICCeryCore
final class ArgyllRunnerCalibrationTests: XCTestCase {
@Suite("ArgyllRunner Calibration")
struct ArgyllRunnerCalibrationTests {
private func makeRunner(processManager: ProcessManager = ProcessManager()) -> ArgyllRunner {
private func makeRunner() -> ArgyllRunner {
let binDir = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("ICCeryUITests/Fixtures/bin")
return ArgyllRunner(
processManager: processManager,
processManager: .shared,
binaryResolver: BinaryResolver(overrideDir: binDir)
)
}
@@ -22,7 +23,8 @@ final class ArgyllRunnerCalibrationTests: XCTestCase {
return root
}
func testCalibrationTargenProducesTi1() async throws {
@Test("Calibration targen produces CAL_*.ti1")
func calibrationTargenProducesTi1() async throws {
let testRoot = try makeTestDir()
let runner = makeRunner()
let config = CalibrationTargenConfig(
@@ -34,16 +36,16 @@ final class ArgyllRunnerCalibrationTests: XCTestCase {
let url = try await runner.runCalibrationTargen(config: config)
XCTAssertEqual(url.lastPathComponent, "CAL_demo.ti1")
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
#expect(url.lastPathComponent == "CAL_demo.ti1")
#expect(FileManager.default.fileExists(atPath: url.path))
try? FileManager.default.removeItem(at: testRoot)
}
func testCalibrationTargenProcessId() async throws {
@Test("Calibration targen from foo runs as process id targen_CAL_foo")
func calibrationTargenProcessId() async throws {
let testRoot = try makeTestDir()
let pm = ProcessManager()
let runner = makeRunner(processManager: pm)
let events = pm.events()
let runner = makeRunner()
let events = ProcessManager.shared.events()
// Subscribed before spawn; the exit event is emitted before
// runCalibrationTargen returns, so this always terminates.
let sawExit = Task {
@@ -62,13 +64,13 @@ final class ArgyllRunnerCalibrationTests: XCTestCase {
let url = try await runner.runCalibrationTargen(config: config)
XCTAssertEqual(url.lastPathComponent, "CAL_foo.ti1")
let sawExitEvent = await sawExit.value
XCTAssertTrue(sawExitEvent)
#expect(url.lastPathComponent == "CAL_foo.ti1")
#expect(await sawExit.value)
try? FileManager.default.removeItem(at: testRoot)
}
func testPrintcalProducesCal() async throws {
@Test("printcal captured run creates .cal")
func printcalProducesCal() async throws {
let testRoot = try makeTestDir()
let runner = makeRunner()
let output = testRoot.appendingPathComponent("CAL_demo.cal")
@@ -80,32 +82,15 @@ final class ArgyllRunnerCalibrationTests: XCTestCase {
let url = try await runner.runPrintcal(config: config)
XCTAssertEqual(url.lastPathComponent, "CAL_demo.cal")
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
#expect(url.lastPathComponent == "CAL_demo.cal")
#expect(FileManager.default.fileExists(atPath: url.path))
try? FileManager.default.removeItem(at: testRoot)
}
func testPrintcalFailureThrows() async throws {
@Test("printcal failure throws printcalFailed")
func printcalFailureThrows() async throws {
let testRoot = try makeTestDir()
defer { try? FileManager.default.removeItem(at: testRoot) }
// Per-test mock printcal that always fails no global
// environment mutation, no shared fixture changes.
let binDir = try makeTestDir()
defer { try? FileManager.default.removeItem(at: binDir) }
let mockURL = binDir.appendingPathComponent("printcal")
try """
#!/bin/sh
echo "printcal mock failure" >&2
exit 1
""".write(to: mockURL, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes(
[.posixPermissions: 0o755], ofItemAtPath: mockURL.path)
let runner = ArgyllRunner(
processManager: ProcessManager(),
binaryResolver: BinaryResolver(bundledRoot: binDir, overrideDir: binDir)
)
let runner = makeRunner()
let output = testRoot.appendingPathComponent("CAL_demo.cal")
let config = PrintcalConfig(
ti3Basename: "CAL_demo",
@@ -113,11 +98,12 @@ final class ArgyllRunnerCalibrationTests: XCTestCase {
outputURL: output
)
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
setenv("ICCERY_MOCK_PRINTCAL_EXIT", "1", 1)
defer { unsetenv("ICCERY_MOCK_PRINTCAL_EXIT") }
await #expect(throws: (any Error).self) {
_ = try await runner.runPrintcal(config: config)
} errorHandler: { error in
XCTAssertEqual(error, .toolFailed(
tool: "printcal", code: 1, logs: ["printcal mock failure\n"]))
}
try? FileManager.default.removeItem(at: testRoot)
}
}
@@ -1,5 +1,5 @@
import Foundation
import XCTest
import Testing
@testable import ICCeryCore
final class LogHolder: @unchecked Sendable {
@@ -19,9 +19,11 @@ final class LogHolder: @unchecked Sendable {
}
}
final class ArgyllRunnerColprofTests: XCTestCase {
@Suite("ArgyllRunner colprof")
struct ArgyllRunnerColprofTests {
func testColprofProducesIcc() async throws {
@Test("Mock colprof produces .icc")
func colprofProducesIcc() async throws {
let binDir = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
@@ -31,7 +33,7 @@ final class ArgyllRunnerColprofTests: XCTestCase {
try FileManager.default.createDirectory(at: testRoot, withIntermediateDirectories: true)
let runner = ArgyllRunner(
processManager: ProcessManager(),
processManager: .shared,
binaryResolver: BinaryResolver(overrideDir: binDir)
)
@@ -41,39 +43,10 @@ final class ArgyllRunnerColprofTests: XCTestCase {
holder.append(batch)
}
XCTAssertEqual(url.lastPathComponent, "testrun.icc")
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
XCTAssertTrue(holder.lines.contains { $0.contains("Gamut mapping") })
#expect(url.lastPathComponent == "testrun.icc")
#expect(FileManager.default.fileExists(atPath: url.path))
#expect(holder.lines.contains { $0.contains("Gamut mapping") })
try? FileManager.default.removeItem(at: testRoot)
}
func testColprofFailureThrowsToolFailed() async throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("colprof-fail-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
let mockURL = dir.appendingPathComponent("colprof")
try """
#!/bin/sh
echo "colprof broke" >&2
exit 4
""".write(to: mockURL, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes(
[.posixPermissions: 0o755], ofItemAtPath: mockURL.path)
let runner = ArgyllRunner(
processManager: ProcessManager(),
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir)
)
let config = ColprofConfig(basename: "failrun", workingDirectory: dir)
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
try await runner.runColprof(config: config)
} errorHandler: { error in
XCTAssertEqual(error, .toolFailed(
tool: "colprof", code: 4, logs: ["colprof broke"]))
}
}
}
@@ -1,158 +0,0 @@
import Foundation
import XCTest
@testable import ICCeryCore
/// Focused contracts for the shared `runStreamingTool` loop (#79).
///
/// Every test uses a per-test temporary directory, unique basenames,
/// and a fresh `ProcessManager` no shared UI fixture scripts and no
/// process-environment mutation.
final class ArgyllRunnerStreamingLoopTests: XCTestCase {
private func makeTempDir() throws -> URL {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("runner-loop-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}
private func writeMock(_ name: String, _ body: String, in dir: URL) throws {
let url = dir.appendingPathComponent(name)
try body.write(to: url, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes(
[.posixPermissions: 0o755], ofItemAtPath: url.path)
}
private func makeRunner(binDir: URL) -> ArgyllRunner {
ArgyllRunner(
processManager: ProcessManager(),
binaryResolver: BinaryResolver(bundledRoot: binDir, overrideDir: binDir))
}
func testNonZeroExitThrowsToolFailed() async throws {
let dir = try makeTempDir()
defer { try? FileManager.default.removeItem(at: dir) }
try writeMock("targen", """
#!/bin/sh
echo "Generating patches..."
echo "targen: too few patches" >&2
exit 3
""", in: dir)
let runner = makeRunner(binDir: dir)
let config = TargenConfig(
colourSpace: .rgb, patchCount: 800, whitePatches: 4,
blackPatches: 4, basename: "fail", workingDirectory: dir)
do {
_ = try await runner.runTargen(config: config)
XCTFail("Expected toolFailed")
} catch let error as ArgyllRunnerError {
guard case .toolFailed(let tool, let code, let logs) = error else {
XCTFail("Expected toolFailed, got \(error)")
return
}
XCTAssertEqual(tool, "targen")
XCTAssertEqual(code, 3)
XCTAssertTrue(logs.contains("Generating patches..."))
XCTAssertTrue(logs.contains("targen: too few patches"))
}
}
func testZeroExitMissingArtefact() async throws {
let dir = try makeTempDir()
defer { try? FileManager.default.removeItem(at: dir) }
try writeMock("targen", """
#!/bin/sh
echo "done but wrote nothing"
exit 0
""", in: dir)
let runner = makeRunner(binDir: dir)
let expectedPath = dir.appendingPathComponent("gone.ti1").path
let config = TargenConfig(
colourSpace: .rgb, patchCount: 800, whitePatches: 4,
blackPatches: 4, basename: "gone", workingDirectory: dir)
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
try await runner.runTargen(config: config)
} errorHandler: { error in
XCTAssertEqual(error, .missingArtefact(expectedPath))
}
}
func testImmediateExitDeliversLine() async throws {
let dir = try makeTempDir()
defer { try? FileManager.default.removeItem(at: dir) }
try writeMock("targen", """
#!/bin/sh
last=""
for arg in "$@"; do last="$arg"; done
echo "only line"
touch "$last.ti1"
exit 0
""", in: dir)
let runner = makeRunner(binDir: dir)
let config = TargenConfig(
colourSpace: .rgb, patchCount: 800, whitePatches: 4,
blackPatches: 4, basename: "quick", workingDirectory: dir)
let holder = LogHolder()
let url = try await runner.runTargen(config: config) { batch in
holder.append(batch)
}
XCTAssertEqual(url.lastPathComponent, "quick.ti1")
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
XCTAssertTrue(holder.lines.contains("only line"))
}
func testColprofPartialLineFlush() async throws {
let dir = try makeTempDir()
defer { try? FileManager.default.removeItem(at: dir) }
// The fragment is printed without a newline, then the mock sleeps
// past the 500 ms partial-line flush interval before writing the
// artefact and exiting so the tail is delivered mid-run.
try writeMock("colprof", """
#!/bin/sh
last=""
for arg in "$@"; do last="$arg"; done
printf 'Doing gamut mapping'
sleep 2
touch "$last.icc"
exit 0
""", in: dir)
let runner = makeRunner(binDir: dir)
let config = ColprofConfig(basename: "frag", workingDirectory: dir)
let holder = LogHolder()
let url = try await runner.runColprof(config: config) { batch in
holder.append(batch)
}
XCTAssertEqual(url.lastPathComponent, "frag.icc")
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
XCTAssertTrue(holder.lines.contains("Doing gamut mapping"))
}
func testToolDescriptions() {
let cases: [(tool: String, expected: String)] = [
(tool: "chartread", expected: "Chartread failed: boom"),
(tool: "average", expected: "Averaging failed: boom"),
(tool: "colprof", expected: "Profile creation failed: boom"),
(tool: "printcal", expected: "Calibration curve computation failed: boom"),
(tool: "applycal", expected: "Apply calibration failed: boom"),
(tool: "iccgamut", expected: "Gamut extraction failed: boom"),
(tool: "profcheck", expected: "Profile verification failed: boom"),
]
for (tool, expected) in cases {
let error = ArgyllRunnerError.toolFailed(tool: tool, code: 1, logs: ["boom"])
XCTAssertEqual(error.errorDescription, expected)
}
}
func testGenericFallbacks() {
let unknown = ArgyllRunnerError.toolFailed(tool: "targen", code: 7, logs: ["boom"])
XCTAssertEqual(unknown.errorDescription, "Process exited with code 7")
let emptyLogs = ArgyllRunnerError.toolFailed(tool: "colprof", code: 2, logs: [])
XCTAssertEqual(emptyLogs.errorDescription,
"Profile creation failed: exited with code 2")
}
}
+60 -46
View File
@@ -1,4 +1,4 @@
import XCTest
import Testing
import Foundation
import ImageIO
import UniformTypeIdentifiers
@@ -10,8 +10,9 @@ private func tempURL(_ name: String) -> URL {
.appendingPathComponent(name)
}
final class Ti2HeaderTests: XCTestCase {
func testParsesKeywordsAndSibling() throws {
@Suite("Ti2Header")
struct Ti2HeaderTests {
@Test func parsesKeywordsAndSibling() throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-ti2-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
@@ -30,18 +31,18 @@ final class Ti2HeaderTests: XCTestCase {
)
let h = Ti2Header.parse(dir.appendingPathComponent("job.ti2"))
XCTAssertEqual(h.instrument, "i1iO")
XCTAssertEqual(h.patchCount, 800)
XCTAssertEqual(h.pageCount, 3)
XCTAssertTrue(h.hasSiblingTi1)
#expect(h.instrument == "i1iO")
#expect(h.patchCount == 800)
#expect(h.pageCount == 3)
#expect(h.hasSiblingTi1)
}
func testMissingFileYieldsEmptyHeader() {
@Test func missingFileYieldsEmptyHeader() {
let h = Ti2Header.parse(URL(fileURLWithPath: "/nonexistent/x.ti2"))
XCTAssertTrue(h.instrument == nil && h.patchCount == nil && !h.hasSiblingTi1)
#expect(h.instrument == nil && h.patchCount == nil && !h.hasSiblingTi1)
}
func testNumberOfFieldsIsNotPatchCount() throws {
@Test func numberOfFieldsIsNotPatchCount() throws {
let url = tempURL("t.ti2")
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
@@ -49,11 +50,12 @@ final class Ti2HeaderTests: XCTestCase {
try "NUMBER_OF_FIELDS 9\nNUMBER_OF_SETS 52\nBEGIN_DATA\n".write(
to: url, atomically: true, encoding: .utf8
)
XCTAssertEqual(Ti2Header.parse(url).patchCount, 52)
#expect(Ti2Header.parse(url).patchCount == 52)
}
}
final class TiffPreviewTests: XCTestCase {
@Suite("TiffPreview")
struct TiffPreviewTests {
/// Builds a real 2000×1000 TIFF in a temp dir via ImageIO.
private func makeTiff(width: Int = 2000, height: Int = 1000) throws -> URL {
let url = tempURL("big.tif")
@@ -79,48 +81,50 @@ final class TiffPreviewTests: XCTestCase {
return url
}
func testProducesCappedPNG() throws {
@Test func producesCappedPNG() throws {
let tiff = try makeTiff()
let png = TiffPreview.previewPNG(tiff: tiff)
XCTAssertNotNil(png)
#expect(png != nil)
// PNG magic
XCTAssertEqual(png!.prefix(8), Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]))
#expect(png!.prefix(8) == Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]))
// Verify the cap by decoding the thumbnail header.
let src = CGImageSourceCreateWithData(png! as CFData, nil)!
let img = CGImageSourceCreateImageAtIndex(src, 0, nil)!
XCTAssertTrue(max(img.width, img.height) <= TiffPreview.maxEdge)
XCTAssertEqual(img.width, 1200)
#expect(max(img.width, img.height) <= TiffPreview.maxEdge)
#expect(img.width == 1200)
}
func testNonTiffReturnsNil() throws {
@Test func nonTiffReturnsNil() throws {
let url = tempURL("not-tiff.txt")
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
)
try "hello".write(to: url, atomically: true, encoding: .utf8)
XCTAssertNil(TiffPreview.previewPNG(tiff: url))
#expect(TiffPreview.previewPNG(tiff: url) == nil)
}
}
final class ArtefactFilesTests: XCTestCase {
func testBase64RoundTrip() throws {
@Suite("ArtefactFiles")
struct ArtefactFilesTests {
@Test func base64RoundTrip() throws {
let url = tempURL("a.txt")
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
)
try "hello".write(to: url, atomically: true, encoding: .utf8)
let b64 = try ArtefactFiles.readBase64(url)
XCTAssertEqual(Data(base64Encoded: b64), Data("hello".utf8))
#expect(Data(base64Encoded: b64) == Data("hello".utf8))
}
func testDefaultWorkingDirExists() {
XCTAssertTrue(FileManager.default.fileExists(
@Test func defaultWorkingDirExists() {
#expect(FileManager.default.fileExists(
atPath: ArtefactFiles.defaultWorkingDirectory().path
))
}
}
final class ArtefactProbeProfileTests: XCTestCase {
@Suite("ArtefactProbe profile resolve")
struct ArtefactProbeProfileTests {
private func makeDir() throws -> URL {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("probe-\(UUID().uuidString)")
@@ -130,83 +134,93 @@ final class ArtefactProbeProfileTests: XCTestCase {
// MARK: Basename probe matrix (#69)
func testOnlyIcc() throws {
@Test("basename probe: only .icc exists")
func onlyIcc() throws {
let dir = try makeDir()
let icc = dir.appendingPathComponent("job.icc")
try Data("icc".utf8).write(to: icc)
XCTAssertEqual(ArtefactProbe.resolveProfile(basename: "job", cwd: dir)?.path, icc.path)
#expect(ArtefactProbe.resolveProfile(basename: "job", cwd: dir)?.path == icc.path)
}
func testOnlyIcm() throws {
@Test("basename probe: only .icm exists")
func onlyIcm() throws {
let dir = try makeDir()
let icm = dir.appendingPathComponent("job.icm")
try Data("icm".utf8).write(to: icm)
XCTAssertEqual(ArtefactProbe.resolveProfile(basename: "job", cwd: dir)?.path, icm.path)
#expect(ArtefactProbe.resolveProfile(basename: "job", cwd: dir)?.path == icm.path)
}
func testIcmWins() throws {
@Test("basename probe prefers .icm")
func icmWins() throws {
let dir = try makeDir()
try Data("icc".utf8).write(to: dir.appendingPathComponent("job.icc"))
let icm = dir.appendingPathComponent("job.icm")
try Data("icm".utf8).write(to: icm)
let url = ArtefactProbe.resolveProfile(basename: "job", cwd: dir)
XCTAssertEqual(url?.path, icm.path)
#expect(url?.path == icm.path)
}
func testNeitherExists() throws {
@Test("basename probe: neither exists returns nil")
func neitherExists() throws {
let dir = try makeDir()
XCTAssertNil(ArtefactProbe.resolveProfile(basename: "job", cwd: dir))
#expect(ArtefactProbe.resolveProfile(basename: "job", cwd: dir) == nil)
}
// MARK: Explicit URL matrix (#69 / #83)
func testExplicitIccWins() throws {
@Test("explicit existing .icc wins even when .icm exists")
func explicitIccWins() throws {
let dir = try makeDir()
let icc = dir.appendingPathComponent("job.icc")
try Data("icc".utf8).write(to: icc)
try Data("icm".utf8).write(to: dir.appendingPathComponent("job.icm"))
XCTAssertEqual(ArtefactProbe.resolveProfile(icc).path, icc.path)
#expect(ArtefactProbe.resolveProfile(icc).path == icc.path)
}
func testExplicitIcmWins() throws {
@Test("explicit existing .icm wins even when .icc exists")
func explicitIcmWins() throws {
let dir = try makeDir()
try Data("icc".utf8).write(to: dir.appendingPathComponent("job.icc"))
let icm = dir.appendingPathComponent("job.icm")
try Data("icm".utf8).write(to: icm)
XCTAssertEqual(ArtefactProbe.resolveProfile(icm).path, icm.path)
#expect(ArtefactProbe.resolveProfile(icm).path == icm.path)
}
func testFlipExtension() throws {
@Test("explicit missing .icc flips to sibling .icm")
func flipExtension() throws {
let dir = try makeDir()
let icc = dir.appendingPathComponent("job.icc")
let icm = dir.appendingPathComponent("job.icm")
try Data("icm".utf8).write(to: icm)
let resolved = ArtefactProbe.resolveProfile(icc)
XCTAssertEqual(resolved.path, icm.path)
#expect(resolved.path == icm.path)
}
func testFlipToIcc() throws {
@Test("explicit missing .icm flips to sibling .icc")
func flipToIcc() throws {
let dir = try makeDir()
let icc = dir.appendingPathComponent("job.icc")
let icm = dir.appendingPathComponent("job.icm")
try Data("icc".utf8).write(to: icc)
XCTAssertEqual(ArtefactProbe.resolveProfile(icm).path, icc.path)
#expect(ArtefactProbe.resolveProfile(icm).path == icc.path)
}
func testMissingBoth() throws {
@Test("explicit missing both returns the original URL")
func missingBoth() throws {
let dir = try makeDir()
let icc = dir.appendingPathComponent("job.icc")
XCTAssertEqual(ArtefactProbe.resolveProfile(icc).path, icc.path)
#expect(ArtefactProbe.resolveProfile(icc).path == icc.path)
}
func testUnrelatedExtension() throws {
@Test("unrelated extension is never rewritten")
func unrelatedExtension() throws {
let dir = try makeDir()
let mpp = dir.appendingPathComponent("job.mpp")
let icc = dir.appendingPathComponent("job.icc")
try Data("icc".utf8).write(to: icc)
// Even though a sibling .icc exists, a missing .mpp stays .mpp.
XCTAssertEqual(ArtefactProbe.resolveProfile(mpp).path, mpp.path)
#expect(ArtefactProbe.resolveProfile(mpp).path == mpp.path)
let txt = dir.appendingPathComponent("job.txt")
XCTAssertEqual(ArtefactProbe.resolveProfile(txt).path, txt.path)
#expect(ArtefactProbe.resolveProfile(txt).path == txt.path)
}
}
+17 -16
View File
@@ -1,8 +1,9 @@
import XCTest
import Testing
import Foundation
@testable import ICCeryCore
final class BinaryResolverTests: XCTestCase {
@Suite("BinaryResolver")
struct BinaryResolverTests {
private func makeTree(_ body: (URL) throws -> Void) throws -> URL {
let root = FileManager.default.temporaryDirectory
@@ -21,16 +22,16 @@ final class BinaryResolverTests: XCTestCase {
}
}
func testOverrideDirWinsWhenFileExists() throws {
@Test func overrideDirWinsWhenFileExists() throws {
let override = try makeTree { root in
try touch(root.appendingPathComponent("targen"))
}
let bundled = try makeTree { _ in }
let r = BinaryResolver(bundledRoot: bundled, overrideDir: override)
XCTAssertEqual(r.resolve("targen"), override.appendingPathComponent("targen"))
#expect(r.resolve("targen") == override.appendingPathComponent("targen"))
}
func testOverrideFallsThroughWhenMissing() throws {
@Test func overrideFallsThroughWhenMissing() throws {
let override = try makeTree { _ in }
let bundled = try makeTree { root in
let dir = root.appendingPathComponent("macos-universal")
@@ -38,10 +39,10 @@ final class BinaryResolverTests: XCTestCase {
try touch(dir.appendingPathComponent("instlist"))
}
let r = BinaryResolver(bundledRoot: bundled, overrideDir: override)
XCTAssertTrue(r.resolve("targen").path.contains("macos-universal/targen"))
#expect(r.resolve("targen").path.contains("macos-universal/targen"))
}
func testUniversalPreferredWhenMarkerPresent() throws {
@Test func universalPreferredWhenMarkerPresent() throws {
let bundled = try makeTree { root in
for dir in ["macos-universal", "macos-x86_64"] {
let d = root.appendingPathComponent(dir)
@@ -50,10 +51,10 @@ final class BinaryResolverTests: XCTestCase {
}
}
let r = BinaryResolver(bundledRoot: bundled)
XCTAssertEqual(r.platformDir(), "macos-universal")
#expect(r.platformDir() == "macos-universal")
}
func testFallsBackToArchDir() throws {
@Test func fallsBackToArchDir() throws {
let bundled = try makeTree { root in
let d = root.appendingPathComponent("macos-x86_64")
try FileManager.default.createDirectory(at: d, withIntermediateDirectories: true)
@@ -63,20 +64,20 @@ final class BinaryResolverTests: XCTestCase {
bundledRoot: bundled,
archDirs: ["macos-universal", "macos-x86_64"]
)
XCTAssertEqual(r.platformDir(), "macos-x86_64")
#expect(r.platformDir() == "macos-x86_64")
}
func testMissingEverythingReturnsConstructedPath() throws {
@Test func missingEverythingReturnsConstructedPath() throws {
let bundled = try makeTree { _ in }
let r = BinaryResolver(bundledRoot: bundled)
// v1 semantic: path is returned; spawn surfaces the error.
XCTAssertTrue(r.resolve("targen").path.hasSuffix("macos-universal/targen"))
XCTAssertFalse(r.exists(r.resolve("targen")))
#expect(r.resolve("targen").path.hasSuffix("macos-universal/targen"))
#expect(!r.exists(r.resolve("targen")))
}
func testMockAndGamutPaths() throws {
@Test func mockAndGamutPaths() throws {
let r = BinaryResolver(bundledRoot: URL(fileURLWithPath: "/x"))
XCTAssertEqual(r.mock("chartread").path, "/x/mocks/chartread.mock")
XCTAssertEqual(r.referenceGamut("sRGB.gam").path, "/x/reference_gamuts/sRGB.gam")
#expect(r.mock("chartread").path == "/x/mocks/chartread.mock")
#expect(r.referenceGamut("sRGB.gam").path == "/x/reference_gamuts/sRGB.gam")
}
}
+50 -37
View File
@@ -1,8 +1,9 @@
import Foundation
import XCTest
import Testing
@testable import ICCeryCore
final class CGATSParserTests: XCTestCase {
@Suite("CGATS Parser & Writer")
struct CGATSParserTests {
private static let canonicalCTI3 = """
CTI3
@@ -20,40 +21,44 @@ final class CGATSParserTests: XCTestCase {
END_DATA
"""
func testParseCTI3() throws {
@Test("Parses CTI3 with canonical field names")
func parseCTI3() throws {
let dataset = try CGATSParser.parse(Self.canonicalCTI3)
XCTAssertEqual(dataset.format, .cti3)
XCTAssertEqual(dataset.samples.count, 2)
XCTAssertEqual(dataset.colorRep, "RGB")
XCTAssertEqual(dataset.deviceClass, "DISPLAY")
XCTAssertEqual(dataset.samples[0].id, "1")
XCTAssertEqual(dataset.samples[0].loc, "A1")
XCTAssertEqual(dataset.samples[1].values["RGB_G"], "50.0000")
#expect(dataset.format == .cti3)
#expect(dataset.samples.count == 2)
#expect(dataset.colorRep == "RGB")
#expect(dataset.deviceClass == "DISPLAY")
#expect(dataset.samples[0].id == "1")
#expect(dataset.samples[0].loc == "A1")
#expect(dataset.samples[1].values["RGB_G"] == "50.0000")
}
func testRoundTrip() throws {
@Test("Round-trips parse, write, reparse")
func roundTrip() throws {
let first = try CGATSParser.parse(Self.canonicalCTI3)
let text = try CGATSWriter.write(first)
let second = try CGATSParser.parse(text)
XCTAssertEqual(second.format, first.format)
XCTAssertEqual(second.samples.count, first.samples.count)
XCTAssertEqual(second.colorRep, first.colorRep)
XCTAssertEqual(second.deviceClass, first.deviceClass)
#expect(second.format == first.format)
#expect(second.samples.count == first.samples.count)
#expect(second.colorRep == first.colorRep)
#expect(second.deviceClass == first.deviceClass)
}
func testParseCSV() throws {
@Test("Parses CSV with comma delimiters")
func parseCSV() throws {
let csv = """
SAMPLE_ID,SAMPLE_LOC,RGB_R,RGB_G,RGB_B,XYZ_X,XYZ_Y,XYZ_Z,LAB_L,LAB_A,LAB_B
1,A1,50,0,0,20,10,5,50,60,30
2,A2,0,50,0,10,30,5,60,-50,40
"""
let dataset = try CGATSParser.parse(csv, sourceURL: URL(fileURLWithPath: "/tmp/sample.csv"))
XCTAssertEqual(dataset.format, .csv)
XCTAssertEqual(dataset.samples.count, 2)
XCTAssertEqual(dataset.samples[0].values["RGB_R"], "50.0000")
#expect(dataset.format == .csv)
#expect(dataset.samples.count == 2)
#expect(dataset.samples[0].values["RGB_R"] == "50.0000")
}
func testConverts255To100() throws {
@Test("Converts 0-255 device values to 0-100")
func converts255To100() throws {
let rgb = """
CTI3
COLOR_REP RGB
@@ -67,11 +72,12 @@ final class CGATSParserTests: XCTestCase {
END_DATA
"""
let dataset = try CGATSParser.parse(rgb)
XCTAssertEqual(dataset.samples[0].values["RGB_R"], "100.0000")
XCTAssertEqual(dataset.samples[0].values["RGB_G"], "50.1961")
#expect(dataset.samples[0].values["RGB_R"] == "100.0000")
#expect(dataset.samples[0].values["RGB_G"] == "50.1961")
}
func testSynthesizesMetadata() throws {
@Test("Synthesizes COLOR_REP and DEVICE_CLASS when missing")
func synthesizesMetadata() throws {
let cmyk = """
CTI3
NUMBER_OF_FIELDS 6
@@ -84,15 +90,19 @@ final class CGATSParserTests: XCTestCase {
END_DATA
"""
let dataset = try CGATSParser.parse(cmyk)
XCTAssertEqual(dataset.colorRep, "CMYK")
XCTAssertEqual(dataset.deviceClass, "PRINTER")
#expect(dataset.colorRep == "CMYK")
#expect(dataset.deviceClass == "PRINTER")
}
func testRejectsEmpty() {
XCTAssertThrowsError(try CGATSParser.parse(""))
@Test("Rejects empty file")
func rejectsEmpty() {
#expect(throws: (any Error).self) {
_ = try CGATSParser.parse("")
}
}
func testRejectsArity() {
@Test("Rejects malformed arity")
func rejectsArity() {
let bad = """
CTI3
NUMBER_OF_FIELDS 2
@@ -104,18 +114,21 @@ final class CGATSParserTests: XCTestCase {
1
END_DATA
"""
XCTAssertThrowsError(try CGATSParser.parse(bad))
#expect(throws: (any Error).self) {
_ = try CGATSParser.parse(bad)
}
}
func testWriterFormat() throws {
@Test("Writer emits valid .ti3 with tabs and required keywords")
func writerFormat() throws {
let dataset = try CGATSParser.parse(Self.canonicalCTI3)
let text = try CGATSWriter.write(dataset)
XCTAssertTrue(text.contains("CTI3"))
XCTAssertTrue(text.contains("BEGIN_DATA_FORMAT"))
XCTAssertTrue(text.contains("BEGIN_DATA"))
XCTAssertTrue(text.contains("END_DATA"))
XCTAssertTrue(text.contains("COLOR_REP"))
XCTAssertTrue(text.contains("DEVICE_CLASS"))
XCTAssertTrue(text.contains("\t"))
#expect(text.contains("CTI3"))
#expect(text.contains("BEGIN_DATA_FORMAT"))
#expect(text.contains("BEGIN_DATA"))
#expect(text.contains("END_DATA"))
#expect(text.contains("COLOR_REP"))
#expect(text.contains("DEVICE_CLASS"))
#expect(text.contains("\t"))
}
}
@@ -1,67 +1,78 @@
import Foundation
import XCTest
import Testing
@testable import ICCeryCore
/// Issue #83 canonical `CAL_` / original-stem pairing.
final class CalibrationIdentityTests: XCTestCase {
func testLivePlain() {
@Suite("CalibrationIdentity")
struct CalibrationIdentityTests {
@Test("live foo, no persisted")
func livePlain() {
let id = CalibrationIdentity.parse(liveBasename: "foo", persistedOriginal: "")
XCTAssertEqual(id.originalBasename, "foo")
XCTAssertEqual(id.calibrationBasename, "CAL_foo")
#expect(id.originalBasename == "foo")
#expect(id.calibrationBasename == "CAL_foo")
}
func testLivePlainIgnoresPersisted() {
@Test("live foo ignores stale persisted")
func livePlainIgnoresPersisted() {
let id = CalibrationIdentity.parse(liveBasename: "foo", persistedOriginal: "bar")
XCTAssertEqual(id.originalBasename, "foo")
XCTAssertEqual(id.calibrationBasename, "CAL_foo")
#expect(id.originalBasename == "foo")
#expect(id.calibrationBasename == "CAL_foo")
}
func testLiveCalPersisted() {
@Test("live CAL_foo, persisted foo")
func liveCalPersisted() {
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "foo")
XCTAssertEqual(id.originalBasename, "foo")
XCTAssertEqual(id.calibrationBasename, "CAL_foo")
#expect(id.originalBasename == "foo")
#expect(id.calibrationBasename == "CAL_foo")
}
func testLiveCalNoPersist() {
@Test("live CAL_foo, empty persisted strips prefix")
func liveCalNoPersist() {
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "")
XCTAssertEqual(id.originalBasename, "foo")
XCTAssertEqual(id.calibrationBasename, "CAL_foo")
#expect(id.originalBasename == "foo")
#expect(id.calibrationBasename == "CAL_foo")
}
func testPersistedWins() {
@Test("persisted original wins over CAL_ live")
func persistedWins() {
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "bar")
XCTAssertEqual(id.originalBasename, "bar")
XCTAssertEqual(id.calibrationBasename, "CAL_bar")
#expect(id.originalBasename == "bar")
#expect(id.calibrationBasename == "CAL_bar")
}
func testEmptyLiveWithPersisted() {
@Test("empty live yields empty identity even with persisted original")
func emptyLiveWithPersisted() {
let id = CalibrationIdentity.parse(liveBasename: "", persistedOriginal: "foo")
XCTAssertTrue(id.originalBasename.isEmpty)
XCTAssertTrue(id.calibrationBasename.isEmpty)
#expect(id.originalBasename.isEmpty)
#expect(id.calibrationBasename.isEmpty)
}
func testEmptyLive() {
@Test("empty live, empty persisted")
func emptyLive() {
let id = CalibrationIdentity.parse(liveBasename: "", persistedOriginal: "")
XCTAssertTrue(id.originalBasename.isEmpty)
XCTAssertTrue(id.calibrationBasename.isEmpty)
#expect(id.originalBasename.isEmpty)
#expect(id.calibrationBasename.isEmpty)
}
func testAlreadyPrefixed() {
XCTAssertEqual(CalibrationIdentity.prefix("CAL_foo"), "CAL_foo")
XCTAssertEqual(CalibrationIdentity.prefix("foo"), "CAL_foo")
@Test("prefix is idempotent on already-prefixed input")
func alreadyPrefixed() {
#expect(CalibrationIdentity.prefix("CAL_foo") == "CAL_foo")
#expect(CalibrationIdentity.prefix("foo") == "CAL_foo")
let id = CalibrationIdentity.parse(liveBasename: "CAL_CAL_foo", persistedOriginal: "")
XCTAssertEqual(id.originalBasename, "CAL_foo")
XCTAssertEqual(id.calibrationBasename, "CAL_foo")
#expect(id.originalBasename == "CAL_foo")
#expect(id.calibrationBasename == "CAL_foo")
}
func testPrefixEmpty() {
XCTAssertTrue(CalibrationIdentity.prefix("").isEmpty)
XCTAssertEqual(CalibrationIdentity.strip("foo"), "foo")
XCTAssertEqual(CalibrationIdentity.strip("CAL_foo"), "foo")
@Test("prefix never invents a name from empty input")
func prefixEmpty() {
#expect(CalibrationIdentity.prefix("").isEmpty)
#expect(CalibrationIdentity.strip("foo") == "foo")
#expect(CalibrationIdentity.strip("CAL_foo") == "foo")
}
func testProcessIdMatches() {
@Test("runner process id for a calibration targen is targen_CAL_*")
func processIdMatches() {
let cal = CalibrationIdentity.prefix("foo")
XCTAssertEqual(ProcessID.targen(cal), "targen_CAL_foo")
#expect(ProcessID.targen(cal) == "targen_CAL_foo")
}
}
@@ -1,8 +1,9 @@
import Foundation
import XCTest
import Testing
@testable import ICCeryCore
final class CalibrationStoreTests: XCTestCase {
@Suite("CalibrationStore")
struct CalibrationStoreTests {
private static let sampleCal = """
CTI3
@@ -22,7 +23,8 @@ final class CalibrationStoreTests: XCTestCase {
END_DATA
"""
func testParseCal() async throws {
@Test("Loads metadata and curves from .cal")
func parseCal() async throws {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("test_\(UUID().uuidString).cal")
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
@@ -31,16 +33,17 @@ final class CalibrationStoreTests: XCTestCase {
try await store.load(url: url)
let data = await store.data
XCTAssertEqual(data?.colorRep, "RGB")
XCTAssertEqual(data?.descriptor, "Test printer")
XCTAssertEqual(data?.maxTac, 300)
XCTAssertEqual(data?.curves.count, 3)
#expect(data?.colorRep == "RGB")
#expect(data?.descriptor == "Test printer")
#expect(data?.maxTac == 300)
#expect(data?.curves.count == 3)
let r = data?.curves.first { $0.channel == "R" }
XCTAssertEqual(r?.output, [0, 64, 255])
#expect(r?.output == [0, 64, 255])
}
func testStaleCalibration() async throws {
@Test("Staleness is true for a very old calibration")
func staleCalibration() async throws {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("stale_\(UUID().uuidString).cal")
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
@@ -48,10 +51,11 @@ final class CalibrationStoreTests: XCTestCase {
let store = CalibrationStore(staleDays: 0)
try await store.load(url: url)
let stale = await store.isStale(comparedTo: "Other")
XCTAssertEqual(stale, true)
#expect(stale == true)
}
func testPrinterMismatch() async throws {
@Test("Printer mismatch is flagged as stale")
func printerMismatch() async throws {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("mismatch_\(UUID().uuidString).cal")
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
@@ -60,6 +64,6 @@ final class CalibrationStoreTests: XCTestCase {
try await store.load(url: url)
await store.setPrinterName("Printer A")
let stale = await store.isStale(comparedTo: "Printer B")
XCTAssertEqual(stale, true)
#expect(stale == true)
}
}
@@ -1,10 +1,12 @@
import Foundation
import XCTest
import Testing
@testable import ICCeryCore
final class CalibrationTargenArgsTests: XCTestCase {
@Suite("CalibrationTargenArgs")
struct CalibrationTargenArgsTests {
func testRgbBaseline() throws {
@Test("RGB baseline")
func rgbBaseline() throws {
let config = CalibrationTargenConfig(
colourSpace: .rgb,
steps: 21,
@@ -13,10 +15,11 @@ final class CalibrationTargenArgsTests: XCTestCase {
workingDirectory: URL(fileURLWithPath: "/tmp")
)
let args = try CalibrationTargenArgs.build(config: config)
XCTAssertEqual(args, ["-v", "-d", "2", "-s", "21", "-g", "21", "-e", "4", "-f", "0", "CAL_demo"])
#expect(args == ["-v", "-d", "2", "-s", "21", "-g", "21", "-e", "4", "-f", "0", "CAL_demo"])
}
func testCmykWithOptions() throws {
@Test("CMYK baseline with ink limit and neutral emphasis")
func cmykWithOptions() throws {
let config = CalibrationTargenConfig(
colourSpace: .cmyk,
steps: 25,
@@ -27,26 +30,33 @@ final class CalibrationTargenArgsTests: XCTestCase {
workingDirectory: URL(fileURLWithPath: "/tmp")
)
let args = try CalibrationTargenArgs.build(config: config)
XCTAssertEqual(args, ["-v", "-d", "4", "-s", "25", "-g", "25", "-e", "4", "-f", "0", "-n", "25", "-l", "320", "CAL_printer"])
#expect(args == ["-v", "-d", "4", "-s", "25", "-g", "25", "-e", "4", "-f", "0", "-n", "25", "-l", "320", "CAL_printer"])
}
func testRejectsBadSteps() {
@Test("Rejects out-of-range steps")
func rejectsBadSteps() {
let config = CalibrationTargenConfig(steps: 5, basename: "demo")
XCTAssertThrowsError(try CalibrationTargenArgs.build(config: config))
#expect(throws: (any Error).self) {
_ = try CalibrationTargenArgs.build(config: config)
}
}
func testRejectsBadInkLimit() {
@Test("Rejects bad CMYK ink limit")
func rejectsBadInkLimit() {
let config = CalibrationTargenConfig(
colourSpace: .cmyk,
inkLimit: 500,
basename: "demo"
)
XCTAssertThrowsError(try CalibrationTargenArgs.build(config: config))
#expect(throws: (any Error).self) {
_ = try CalibrationTargenArgs.build(config: config)
}
}
func testNoDoublePrefix() throws {
@Test("Does not double-prefix an existing CAL_ basename")
func noDoublePrefix() throws {
let config = CalibrationTargenConfig(basename: "CAL_test")
let args = try CalibrationTargenArgs.build(config: config)
XCTAssertEqual(args.last, "CAL_test")
#expect(args.last == "CAL_test")
}
}
+32 -23
View File
@@ -1,63 +1,72 @@
import Foundation
import XCTest
import Testing
@testable import ICCeryCore
final class ColprofArgsTests: XCTestCase {
@Suite("ColprofArgs")
struct ColprofArgsTests {
func testDefaults() throws {
@Test("Default algorithm and quality")
func defaults() throws {
let config = ColprofConfig(basename: "target")
let args = try ColprofArgs.build(config: config)
XCTAssertEqual(args, ["-v", "-a", "l", "-q", "m", "target"])
#expect(args == ["-v", "-a", "l", "-q", "m", "target"])
}
func testFwaBareFlag() throws {
@Test("FWA bare -f when empty string")
func fwaBareFlag() throws {
let config = ColprofConfig(fwa: "", basename: "target")
let args = try ColprofArgs.build(config: config)
XCTAssertEqual(args, ["-v", "-a", "l", "-q", "m", "-f", "target"])
#expect(args == ["-v", "-a", "l", "-q", "m", "-f", "target"])
}
func testFwaD50() throws {
@Test("FWA D50 and D65 emit -f value")
func fwaD50() throws {
let config = ColprofConfig(fwa: "D50", basename: "target")
let args = try ColprofArgs.build(config: config)
XCTAssertTrue(args.contains("-f"))
XCTAssertTrue(args.contains("D50"))
XCTAssertEqual(args.last, "target")
#expect(args.contains("-f"))
#expect(args.contains("D50"))
#expect(args.last == "target")
}
func testFwaNoneOmitted() throws {
@Test("FWA none is omitted")
func fwaNoneOmitted() throws {
let config = ColprofConfig(fwa: "none", basename: "target")
let args = try ColprofArgs.build(config: config)
XCTAssertFalse(args.contains("-f"))
#expect(!args.contains("-f"))
}
func testViewingCondNoneSkipped() throws {
@Test("Viewing conditions skip none")
func viewingCondNoneSkipped() throws {
let config = ColprofConfig(
inputViewingCond: "none",
outputViewingCond: "mt",
basename: "target"
)
let args = try ColprofArgs.build(config: config)
XCTAssertFalse(args.contains("-c"))
XCTAssertTrue(args.contains("-d"))
XCTAssertTrue(args.contains("mt"))
#expect(!args.contains("-c"))
#expect(args.contains("-d"))
#expect(args.contains("mt"))
}
func testDescriptionFallback() throws {
@Test("Description falls back to basename when empty")
func descriptionFallback() throws {
let config = ColprofConfig(description: "", basename: "target")
let args = try ColprofArgs.build(config: config)
XCTAssertFalse(args.contains("-D"))
#expect(!args.contains("-D"))
}
func testCopyright() throws {
@Test("Copyright only when non-empty")
func copyright() throws {
let config = ColprofConfig(copyright: "Gronod 2026", basename: "target")
let args = try ColprofArgs.build(config: config)
XCTAssertTrue(args.contains("-C"))
XCTAssertTrue(args.contains("Gronod 2026"))
#expect(args.contains("-C"))
#expect(args.contains("Gronod 2026"))
}
func testNoProgressJsonFlag() throws {
@Test("No -u passed")
func noProgressJsonFlag() throws {
let config = ColprofConfig(basename: "target")
let args = try ColprofArgs.build(config: config)
XCTAssertFalse(args.contains("-u"))
#expect(!args.contains("-u"))
}
}
@@ -1,20 +1,24 @@
import Foundation
import XCTest
import Testing
@testable import ICCeryCore
final class ColprofProgressTests: XCTestCase {
@Suite("ColprofProgress")
struct ColprofProgressTests {
func testGamutMapping() {
XCTAssertEqual(ColprofProgressClassifier.classify(line: "Gamut mapping calculation in progress"), .gamutMapping)
@Test("Classifies gamut mapping")
func gamutMapping() {
#expect(ColprofProgressClassifier.classify(line: "Gamut mapping calculation in progress") == .gamutMapping)
}
func testFitting() {
XCTAssertEqual(ColprofProgressClassifier.classify(line: "Fitting cLUT grid points"), .fittingClut)
XCTAssertEqual(ColprofProgressClassifier.classify(line: "clut table"), .fittingClut)
@Test("Classifies fitting or clut")
func fitting() {
#expect(ColprofProgressClassifier.classify(line: "Fitting cLUT grid points") == .fittingClut)
#expect(ColprofProgressClassifier.classify(line: "clut table") == .fittingClut)
}
func testWriting() {
XCTAssertEqual(ColprofProgressClassifier.classify(line: "Writing ICC profile header"), .writingIcc)
XCTAssertEqual(ColprofProgressClassifier.classify(line: "icc profile written"), .writingIcc)
@Test("Classifies writing")
func writing() {
#expect(ColprofProgressClassifier.classify(line: "Writing ICC profile header") == .writingIcc)
#expect(ColprofProgressClassifier.classify(line: "icc profile written") == .writingIcc)
}
}
@@ -1,4 +1,4 @@
import XCTest
import Testing
import Foundation
@testable import ICCeryCore
@testable import ICCery
@@ -6,42 +6,48 @@ import AppKit
import ApplicationServices
/// Issue 14 PMPrintSettingsToOptions capture filter (docs/11 layer ).
final class CupsOptionsFilterTests: XCTestCase {
@Suite("CupsOptionsFilter")
struct CupsOptionsFilterTests {
func testDropsReserved() {
@Test("Drops com.apple.*, collate, copies, job-sheets, AP_* keys")
func dropsReserved() {
let raw = "AP_ColorMatchingMode=AP_ApplicationColorMatching "
+ "AP.ColorMatchingMode=AP_ApplicationColorMatching "
+ "com.apple.print.JobTicket.PMTotalSidesImaged=0 "
+ "collate=true copies=1 job-sheets=none,none "
+ "pserrorhandler-requested=standard "
+ "MediaType=PhotographicGlossy"
XCTAssertEqual(CupsOptionsFilter.filter(raw), "MediaType=PhotographicGlossy")
#expect(CupsOptionsFilter.filter(raw) == "MediaType=PhotographicGlossy")
}
func testKeepsRelevant() {
@Test("Keeps relevant driver keys, order preserved")
func keepsRelevant() {
let raw = "InputSlot=Rear PageSize=A4 CNIJIntent2=4 "
+ "Resolution=600x600dpi Duplex=None"
XCTAssertEqual(CupsOptionsFilter.filter(raw), raw)
#expect(CupsOptionsFilter.filter(raw) == raw)
}
func testKeepsUnknown() {
@Test("Permissive: unknown non-com.* keys survive")
func keepsUnknown() {
let raw = "VendorFooBar=baz MediaType=Plain"
XCTAssertEqual(CupsOptionsFilter.filter(raw), raw)
#expect(CupsOptionsFilter.filter(raw) == raw)
}
func testDropsEmpty() {
@Test("Drops empty keys and values")
func dropsEmpty() {
let raw = "=noval MediaType= InputSlot=Rear"
// "MediaType=" has an empty value dropped; "=noval" empty key.
XCTAssertEqual(CupsOptionsFilter.filter(raw), "InputSlot=Rear")
#expect(CupsOptionsFilter.filter(raw) == "InputSlot=Rear")
}
func testExtractMedia() {
XCTAssertEqual(CupsParsers.extractMediaType(
fromOptionsString: "MediaType=Photo EPIJ_Medi=1"), "Photo")
XCTAssertEqual(CupsParsers.extractMediaType(
fromOptionsString: "EPIJ_Medi=7"), "7")
XCTAssertNil(CupsParsers.extractMediaType(
fromOptionsString: "PageSize=A4"))
@Test("extractMediaType prefers MediaType then EPIJ_Medi")
func extractMedia() {
#expect(CupsParsers.extractMediaType(
fromOptionsString: "MediaType=Photo EPIJ_Medi=1") == "Photo")
#expect(CupsParsers.extractMediaType(
fromOptionsString: "EPIJ_Medi=7") == "7")
#expect(CupsParsers.extractMediaType(
fromOptionsString: "PageSize=A4") == nil)
}
}
@@ -49,8 +55,9 @@ final class CupsOptionsFilterTests: XCTestCase {
/// `@convention(c)` closures can't capture, so recording goes through
/// a file-scope recorder keyed by global state; no private symbols are
/// touched.
@Suite("ColorSyncSuppressor")
@MainActor
final class ColorSyncSuppressorTests: XCTestCase {
struct ColorSyncSuppressorTests {
/// Fake PMPrintSession the injected resolver never dereferences it.
private var fakeSession: PMPrintSession {
@@ -71,14 +78,10 @@ final class ColorSyncSuppressorTests: XCTestCase {
s.modeResolver = { name in
if Self.missing.contains(name) { return nil }
Self.currentSymbol = name
// `Self` inside a @convention(c) closure is a dynamic-Self
// capture spell the (final) class name instead.
return { _, modeArg in
ColorSyncSuppressorTests.recorded.append(
(ColorSyncSuppressorTests.currentSymbol, modeArg as String))
if let ok = ColorSyncSuppressorTests.succeeding,
ColorSyncSuppressorTests.currentSymbol == ok.0,
(modeArg as String) == ok.1 {
Self.recorded.append((Self.currentSymbol, modeArg as String))
if let ok = Self.succeeding,
Self.currentSymbol == ok.0, (modeArg as String) == ok.1 {
return 0
}
return 1
@@ -87,50 +90,55 @@ final class ColorSyncSuppressorTests: XCTestCase {
return s
}
func testAttemptOrder() {
@Test("Attempt order: Lock → Mode → NoLock, AP_ prefix first")
func attemptOrder() {
Self.recorded = []
Self.succeeding = nil
Self.missing = ["PMSessionSetColorMatchingModeLock"]
let s = makeSuppressor()
XCTAssertEqual(s.applySPIMode(to: fakeSession), false)
#expect(s.applySPIMode(to: fakeSession) == false)
// Lock is unresolvable skipped; the rest plays out in order.
XCTAssertEqual(Self.recorded.map { "\($0.0)|\($0.1)" }, ColorMatchingAttempts.attempts
#expect(Self.recorded.map { "\($0.0)|\($0.1)" }
== ColorMatchingAttempts.attempts
.filter { $0.symbol != "PMSessionSetColorMatchingModeLock" }
.map { "\($0.symbol)|\($0.mode)" })
}
func testFirstZeroWins() {
@Test("First zero wins — later symbols/modes not called")
func firstZeroWins() {
Self.recorded = []
Self.succeeding = ("PMSessionSetColorMatchingModeLock",
"AP_ApplicationColorMatching")
Self.missing = []
let s = makeSuppressor()
XCTAssertTrue(s.applySPIMode(to: fakeSession))
XCTAssertEqual(Self.recorded.map { "\($0.0)|\($0.1)" }, [
#expect(s.applySPIMode(to: fakeSession))
#expect(Self.recorded.map { "\($0.0)|\($0.1)" } == [
"PMSessionSetColorMatchingModeLock|AP_ApplicationColorMatching",
])
}
func testModeFallback() {
@Test("Mode fallback: AP_ rejected → ApplicationColorMatching tried")
func modeFallback() {
Self.recorded = []
Self.succeeding = ("PMSessionSetColorMatchingModeLock",
"ApplicationColorMatching")
Self.missing = []
let s = makeSuppressor()
XCTAssertTrue(s.applySPIMode(to: fakeSession))
XCTAssertEqual(Self.recorded[0].0, "PMSessionSetColorMatchingModeLock")
XCTAssertEqual(Self.recorded[0].1, "AP_ApplicationColorMatching")
XCTAssertEqual(Self.recorded[1].0, "PMSessionSetColorMatchingModeLock")
XCTAssertEqual(Self.recorded[1].1, "ApplicationColorMatching")
XCTAssertEqual(Self.recorded.count, 2)
#expect(s.applySPIMode(to: fakeSession))
#expect(Self.recorded[0].0 == "PMSessionSetColorMatchingModeLock")
#expect(Self.recorded[0].1 == "AP_ApplicationColorMatching")
#expect(Self.recorded[1].0 == "PMSessionSetColorMatchingModeLock")
#expect(Self.recorded[1].1 == "ApplicationColorMatching")
#expect(Self.recorded.count == 2)
}
func testAllMissing() {
@Test("All symbols missing → false, no calls")
func allMissing() {
Self.recorded = []
Self.succeeding = nil
Self.missing = Set(ColorMatchingAttempts.symbols)
let s = makeSuppressor()
XCTAssertEqual(s.applySPIMode(to: fakeSession), false)
XCTAssertTrue(Self.recorded.isEmpty)
#expect(s.applySPIMode(to: fakeSession) == false)
#expect(Self.recorded.isEmpty)
}
}
+62 -51
View File
@@ -1,10 +1,11 @@
import XCTest
import Testing
import Foundation
@testable import ICCeryCore
/// Issue 12 CUPS enumeration parsers on recorded fixtures
/// (docs/1011). No live `lpstat`/`lpoptions` is spawned here.
final class CupsParsersTests: XCTestCase {
@Suite("CupsParsers")
struct CupsParsersTests {
// Recorded on an Epson XP-55 + Canon Pro9500 host.
private let lpstatE = """
@@ -33,102 +34,112 @@ final class CupsParsersTests: XCTestCase {
cupsPrintQuality/cupsPrintQuality: Draft *Normal High
"""
func testDestinations() {
XCTAssertEqual(CupsParsers.lpstatDestinations(lpstatE), [
@Test("lpstat -e: one destination per line; empty = success")
func destinations() {
#expect(CupsParsers.lpstatDestinations(lpstatE) == [
"Canon_Pro9500_II_series_XPS",
"Epson_XP_55_LPD",
"EPSON_XP_55_Series",
])
XCTAssertEqual(CupsParsers.lpstatDestinations(""), [])
#expect(CupsParsers.lpstatDestinations("") == [])
}
func testStatuses() {
@Test("lpstat -p: idle / now-printing / disabled statuses")
func statuses() {
let s = CupsParsers.lpstatStatuses(lpstatP)
XCTAssertEqual(s["Canon_Pro9500_II_series_XPS"], .idle)
XCTAssertEqual(s["Epson_XP_55_LPD"], .printing)
XCTAssertEqual(s["EPSON_XP_55_Series"], .stopped)
#expect(s["Canon_Pro9500_II_series_XPS"] == .idle)
#expect(s["Epson_XP_55_LPD"] == .printing)
#expect(s["EPSON_XP_55_Series"] == .stopped)
}
func testDefaultDestination() {
XCTAssertEqual(CupsParsers.lpstatDefault(
"system default destination: Canon_Pro9500_II_series_XPS\n"), "Canon_Pro9500_II_series_XPS")
XCTAssertNil(CupsParsers.lpstatDefault("no system default destination\n"))
@Test("lpstat -d: default destination or none")
func defaultDestination() {
#expect(CupsParsers.lpstatDefault(
"system default destination: Canon_Pro9500_II_series_XPS\n")
== "Canon_Pro9500_II_series_XPS")
#expect(CupsParsers.lpstatDefault("no system default destination\n") == nil)
}
func testDisplayName() {
XCTAssertEqual(CupsParsers.lpoptionsDisplayName(lpoptionsP), "EPSON XP-55 Series")
XCTAssertNil(CupsParsers.lpoptionsDisplayName("printer-type=42\n"))
@Test("lpoptions -p: quoted printer-info, bare flags ignored")
func displayName() {
#expect(CupsParsers.lpoptionsDisplayName(lpoptionsP) == "EPSON XP-55 Series")
#expect(CupsParsers.lpoptionsDisplayName("printer-type=42\n") == nil)
}
func testOptionListings() {
@Test("lpoptions -l: key/label split, * marks the default")
func optionListings() {
let listings = CupsParsers.lpoptionsList(lpoptionsL)
XCTAssertEqual(listings.count, 6)
#expect(listings.count == 6)
let page = listings[0]
XCTAssertEqual(page.key, "PageSize")
XCTAssertEqual(page.label, "Media Size")
XCTAssertEqual(page.defaultChoice, "A4")
XCTAssertTrue(page.choices.contains("Custom.WIDTHxHEIGHT"))
XCTAssertFalse(page.choices.contains("*A4"))
#expect(page.key == "PageSize")
#expect(page.label == "Media Size")
#expect(page.defaultChoice == "A4")
#expect(page.choices.contains("Custom.WIDTHxHEIGHT"))
#expect(!page.choices.contains("*A4"))
let slot = listings[1]
XCTAssertEqual(slot.key, "InputSlot")
XCTAssertEqual(slot.choices, ["Auto", "Main", "Photo", "Rear"])
XCTAssertEqual(slot.defaultChoice, "Main")
#expect(slot.key == "InputSlot")
#expect(slot.choices == ["Auto", "Main", "Photo", "Rear"])
#expect(slot.defaultChoice == "Main")
}
func testCapabilities() {
@Test("capabilities: trays/sizes index 1-based, media uses detected key")
func capabilities() {
let service = CupsService()
let listings = CupsParsers.lpoptionsList(lpoptionsL)
let caps = service.capabilities(from: listings, ppd: nil)
XCTAssertEqual(caps.trays, [
#expect(caps.trays == [
PrinterTray(id: 1, name: "Auto"),
PrinterTray(id: 2, name: "Main"),
PrinterTray(id: 3, name: "Photo"),
PrinterTray(id: 4, name: "Rear"),
])
XCTAssertEqual(caps.paperSizes.first, PrinterPaperSize(id: 1, name: "3.5x5"))
XCTAssertEqual(caps.paperSizes.count, 10)
XCTAssertEqual(caps.mediaTypes.map(\.id), [
#expect(caps.paperSizes.first == PrinterPaperSize(id: 1, name: "3.5x5"))
#expect(caps.paperSizes.count == 10)
#expect(caps.mediaTypes.map(\.id) == [
"Stationery", "PhotographicHighGloss", "Photographic",
"PhotographicMatte", "Envelope",
])
XCTAssertTrue(caps.supportsOrientation)
#expect(caps.supportsOrientation)
}
func testPpdLabels() {
@Test("PPD enrichment maps id → human label")
func ppdLabels() {
let ppd = """
*CNIJMediaType 42/Photo Paper Plus Semi-gloss: "<</MediaType(42)>>"
*CNIJMediaType 0/Plain Paper: ""
*en_US.CNIJMediaType 13/Envelope: ""
"""
let labels = CupsParsers.ppdChoiceLabels(ppd, key: "CNIJMediaType")
XCTAssertEqual(labels["42"], "Photo Paper Plus Semi-gloss")
XCTAssertEqual(labels["0"], "Plain Paper")
XCTAssertEqual(labels["13"], "Envelope")
#expect(labels["42"] == "Photo Paper Plus Semi-gloss")
#expect(labels["0"] == "Plain Paper")
#expect(labels["13"] == "Envelope")
}
func testMediaTypeKey() {
XCTAssertEqual(CupsParsers.detectMediaTypeKey(
optionKeys: ["MediaType", "CNIJMediaType"]), "CNIJMediaType")
XCTAssertEqual(CupsParsers.detectMediaTypeKey(
optionKeys: ["PageSize", "MediaType"]), "MediaType")
XCTAssertNil(CupsParsers.detectMediaTypeKey(optionKeys: ["PageSize"]))
@Test("detectMediaTypeKey prefers vendor keys in order")
func mediaTypeKey() {
#expect(CupsParsers.detectMediaTypeKey(
optionKeys: ["MediaType", "CNIJMediaType"]) == "CNIJMediaType")
#expect(CupsParsers.detectMediaTypeKey(
optionKeys: ["PageSize", "MediaType"]) == "MediaType")
#expect(CupsParsers.detectMediaTypeKey(optionKeys: ["PageSize"]) == nil)
}
func testDriverBypass() {
@Test("Driver bypass: Canon Intent2 > Intent; Epson CCor > CMat")
func driverBypass() {
func pair(_ keys: Set<String>) -> String? {
CupsParsers.detectDriverColorBypass(optionKeys: keys)
.map { "\($0.key)=\($0.value)" }
}
XCTAssertEqual(pair(["CNIJIntent2", "CNIJIntent"]), "CNIJIntent2=4")
XCTAssertEqual(pair(["CNIJIntent"]), "CNIJIntent=4")
XCTAssertEqual(pair(["EPIJ_CCor", "EPIJ_CMat"]), "EPIJ_CCor=0")
XCTAssertEqual(pair(["EPIJ_CMat"]), "EPIJ_CMat=3")
XCTAssertEqual(pair(["StpColorCorrection"]), "StpColorCorrection=Uncorrected")
XCTAssertEqual(pair(["ColorCorrection"]), "ColorCorrection=Uncorrected")
XCTAssertEqual(pair(["EpsonColorMode"]), "EpsonColorMode=Off")
XCTAssertNil(pair(["PageSize"]))
#expect(pair(["CNIJIntent2", "CNIJIntent"]) == "CNIJIntent2=4")
#expect(pair(["CNIJIntent"]) == "CNIJIntent=4")
#expect(pair(["EPIJ_CCor", "EPIJ_CMat"]) == "EPIJ_CCor=0")
#expect(pair(["EPIJ_CMat"]) == "EPIJ_CMat=3")
#expect(pair(["StpColorCorrection"]) == "StpColorCorrection=Uncorrected")
#expect(pair(["ColorCorrection"]) == "ColorCorrection=Uncorrected")
#expect(pair(["EpsonColorMode"]) == "EpsonColorMode=Off")
#expect(pair(["PageSize"]) == nil)
}
}
+30 -20
View File
@@ -1,57 +1,65 @@
import Foundation
import XCTest
import Testing
@testable import ICCeryCore
final class DriftAlertTests: XCTestCase {
@Suite("DriftAlert")
struct DriftAlertTests {
func testNotEnough() {
@Test("No alert with fewer than two poor results")
func notEnough() {
let records = [
record(avg: 4.0, at: 1000)
]
XCTAssertNil(DriftAlert.compute(from: records))
#expect(DriftAlert.compute(from: records) == nil)
}
func testOneHourApart() {
@Test("Alert on two poor results one hour apart")
func oneHourApart() {
let records = [
record(avg: 4.0, at: 1000),
record(avg: 5.0, at: 4600)
]
XCTAssertNotNil(DriftAlert.compute(from: records))
#expect(DriftAlert.compute(from: records) != nil)
}
func testSameDayUnderHour() {
@Test("No alert if same day and under one hour")
func sameDayUnderHour() {
let records = [
record(avg: 4.0, at: 1000),
record(avg: 5.0, at: 2000)
]
XCTAssertNil(DriftAlert.compute(from: records))
#expect(DriftAlert.compute(from: records) == nil)
}
func testDistinctDays() {
@Test("Alert on distinct days")
func distinctDays() {
let day1 = record(avg: 4.0, at: 0)
let day2 = record(avg: 5.0, at: 86400 + 1000)
XCTAssertNotNil(DriftAlert.compute(from: [day1, day2]))
#expect(DriftAlert.compute(from: [day1, day2]) != nil)
}
func testNonPoor() {
@Test("Non-poor records do not trigger")
func nonPoor() {
let records = [
record(avg: 1.0, at: 0),
record(avg: 1.5, at: 86400)
]
XCTAssertNil(DriftAlert.compute(from: records))
#expect(DriftAlert.compute(from: records) == nil)
}
func testNonPoorBreaksRun() {
@Test("Non-poor records break the consecutive poor run")
func nonPoorBreaksRun() {
let records = [
record(avg: 4.0, at: 0), // poor
record(avg: 4.5, at: 86400), // poor, far apart
record(avg: 1.0, at: 90000), // good breaks the run
record(avg: 4.0, at: 92000) // poor, recent but close to previous poor
]
XCTAssertNil(DriftAlert.compute(from: records))
#expect(DriftAlert.compute(from: records) == nil)
}
func testOnlySuffixRun() {
@Test("Only the final consecutive poor run is considered")
func onlySuffixRun() {
let records = [
record(avg: 4.0, at: 0), // poor
record(avg: 4.5, at: 18000), // poor, > 1h from first
@@ -59,24 +67,26 @@ final class DriftAlertTests: XCTestCase {
record(avg: 4.0, at: 25000), // poor
record(avg: 4.5, at: 26000) // poor, < 1h and same day
]
XCTAssertNil(DriftAlert.compute(from: records))
#expect(DriftAlert.compute(from: records) == nil)
}
func testSuffixRunAlerts() {
@Test("Final consecutive poor run alerts when far apart")
func suffixRunAlerts() {
let records = [
record(avg: 1.0, at: 0), // good
record(avg: 4.0, at: 1000), // poor
record(avg: 4.5, at: 4600) // poor, 1h after previous
]
XCTAssertNotNil(DriftAlert.compute(from: records))
#expect(DriftAlert.compute(from: records) != nil)
}
func testSingleFinalPoor() {
@Test("A single final poor record after good records does not alert")
func singleFinalPoor() {
let records = [
record(avg: 1.0, at: 0),
record(avg: 4.0, at: 86400)
]
XCTAssertNil(DriftAlert.compute(from: records))
#expect(DriftAlert.compute(from: records) == nil)
}
private func record(avg: Double, at offset: TimeInterval) -> VerificationRecord {
+38 -35
View File
@@ -1,4 +1,4 @@
import XCTest
import Testing
import Foundation
@testable import ICCeryCore
@@ -13,88 +13,91 @@ private func touch(_ url: URL, _ contents: String = "x") throws {
try contents.write(to: url, atomically: true, encoding: .utf8)
}
final class PathSecurityTests: XCTestCase {
func testRejectsTraversalAndSeparators() {
@Suite("PathSecurity")
struct PathSecurityTests {
@Test func rejectsTraversalAndSeparators() {
for bad in ["a/b", "a\\b", "..", "a/../b", "", "..x"] {
XCTAssertFalse(PathSecurity.isValidBasename(bad))
XCTAssertThrowsError(try PathSecurity.sanitizeBasename(bad)) { error in
XCTAssertTrue(error is PathSecurity.Error)
#expect(!PathSecurity.isValidBasename(bad))
#expect(throws: PathSecurity.Error.self) {
try PathSecurity.sanitizeBasename(bad)
}
}
}
func testAcceptsNormalNames() {
@Test func acceptsNormalNames() {
for good in ["target", "My Target 01", "écheneau-ümläut", "a.b"] {
XCTAssertTrue(PathSecurity.isValidBasename(good))
#expect(PathSecurity.isValidBasename(good))
}
}
func testResolveSafeCwdPrefersExplicit() throws {
@Test func resolveSafeCwdPrefersExplicit() throws {
let dir = try tempDir()
XCTAssertEqual(PathSecurity.resolveSafeCwd(dir), dir)
#expect(PathSecurity.resolveSafeCwd(dir) == dir)
}
func testResolveSafeCwdNeverReturnsNil() {
@Test func resolveSafeCwdNeverReturnsNil() {
let missing = URL(fileURLWithPath: "/nonexistent-\(UUID().uuidString)")
let resolved = PathSecurity.resolveSafeCwd(missing)
XCTAssertTrue(FileManager.default.fileExists(atPath: resolved.path))
#expect(FileManager.default.fileExists(atPath: resolved.path))
}
}
final class AtomicFileWriterTests: XCTestCase {
func testWritesAndLeavesNoTmp() throws {
@Suite("AtomicFileWriter")
struct AtomicFileWriterTests {
@Test func writesAndLeavesNoTmp() throws {
let dir = try tempDir()
let url = dir.appendingPathComponent("state.json")
try AtomicFileWriter.write(Data("{\"a\":1}".utf8), to: url)
XCTAssertEqual(try String(contentsOf: url, encoding: .utf8), "{\"a\":1}")
XCTAssertFalse(FileManager.default.fileExists(atPath: url.appendingPathExtension("tmp").path))
#expect(try String(contentsOf: url, encoding: .utf8) == "{\"a\":1}")
#expect(!FileManager.default.fileExists(atPath: url.appendingPathExtension("tmp").path))
}
func testOverwritesExistingAtomically() throws {
@Test func overwritesExistingAtomically() throws {
let dir = try tempDir()
let url = dir.appendingPathComponent("f.txt")
try AtomicFileWriter.write("one", to: url)
try AtomicFileWriter.write("two-longer", to: url)
XCTAssertEqual(try String(contentsOf: url, encoding: .utf8), "two-longer")
#expect(try String(contentsOf: url, encoding: .utf8) == "two-longer")
}
func testCreatesParentDirs() throws {
@Test func createsParentDirs() throws {
let dir = try tempDir()
let url = dir.appendingPathComponent("a/b/c/deep.json")
try AtomicFileWriter.write("{}", to: url)
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
#expect(FileManager.default.fileExists(atPath: url.path))
}
}
final class ArtefactProbeTests: XCTestCase {
func testVerifyProgression() throws {
@Suite("ArtefactProbe")
struct ArtefactProbeTests {
@Test func verifyProgression() throws {
let dir = try tempDir()
var v = ArtefactProbe.verify(basename: "t", cwd: dir)
XCTAssertEqual(v, StageArtefacts())
#expect(v == StageArtefacts())
try touch(dir.appendingPathComponent("t.ti1"))
v = ArtefactProbe.verify(basename: "t", cwd: dir)
XCTAssertTrue(v.stage1Complete && !v.stage2Complete && !v.stage3Complete)
#expect(v.stage1Complete && !v.stage2Complete && !v.stage3Complete)
try touch(dir.appendingPathComponent("t.ti2"))
try touch(dir.appendingPathComponent("t.ti3"))
v = ArtefactProbe.verify(basename: "t", cwd: dir)
XCTAssertTrue(v.stage2Complete && v.stage3Complete && !v.stage4Complete)
#expect(v.stage2Complete && v.stage3Complete && !v.stage4Complete)
try touch(dir.appendingPathComponent("t.icc"))
v = ArtefactProbe.verify(basename: "t", cwd: dir)
XCTAssertTrue(v.stage4Complete && v.profilePath?.pathExtension == "icc")
#expect(v.stage4Complete && v.profilePath?.pathExtension == "icc")
}
func testIcmWinsOverIcc() throws {
@Test func icmWinsOverIcc() throws {
let dir = try tempDir()
try touch(dir.appendingPathComponent("p.icc"))
try touch(dir.appendingPathComponent("p.icm"))
let profile = ArtefactProbe.resolveProfile(basename: "p", cwd: dir)
XCTAssertEqual(profile?.pathExtension, "icm")
#expect(profile?.pathExtension == "icm")
}
func testEnumeratesPassesPagesAndCAL() throws {
@Test func enumeratesPassesPagesAndCAL() throws {
let dir = try tempDir()
for name in [
"t.ti1", "t.ti2", "t.tif", "t.2.tif", "t_03.tif",
@@ -112,15 +115,15 @@ final class ArtefactProbeTests: XCTestCase {
"t.ti3", "t_pass1.ti3", "t_pass2.ti3",
"t.icc", "t.gam", "CAL_t.ti1", "CAL_t.cal",
] {
XCTAssertTrue(names.contains(expected), "missing \(expected)")
#expect(names.contains(expected), "missing \(expected)")
}
XCTAssertFalse(names.contains("other.ti1"))
XCTAssertFalse(names.contains("t.txt"))
XCTAssertFalse(names.contains("CAL_other.ti1"))
#expect(!names.contains("other.ti1"))
#expect(!names.contains("t.txt"))
#expect(!names.contains("CAL_other.ti1"))
}
func testEmptyDirReturnsEmpty() throws {
@Test func emptyDirReturnsEmpty() throws {
let dir = try tempDir()
XCTAssertTrue(ArtefactProbe.existingArtefacts(basename: "x", cwd: dir).isEmpty)
#expect(ArtefactProbe.existingArtefacts(basename: "x", cwd: dir).isEmpty)
}
}
@@ -1,85 +0,0 @@
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)
}
}
@@ -1,13 +1,15 @@
import XCTest
import Testing
import SceneKit
import ICCeryCore
@testable import ICCery
/// ``GamutSceneGeometryBuilder`` edge-case tests.
@Suite("Gamut scene geometry builder")
@MainActor
final class GamutGeometryBuilderTests: XCTestCase {
struct GamutGeometryBuilderTests {
func testDropsOutOfBoundsFaces() {
@Test("Drops out-of-bounds faces from the element without crashing")
func dropsOutOfBoundsFaces() {
let white = GamutVertex(
lab: LabColor(l: 100, a: 0, b: 0),
rgb: DisplayRGB(r: 1, g: 1, b: 1)
@@ -26,6 +28,6 @@ final class GamutGeometryBuilderTests: XCTestCase {
let (_, element) = GamutSceneGeometryBuilder.geometry(for: mesh)
XCTAssertEqual(element.primitiveCount, 1, "Only the in-bounds face should be in the index buffer")
#expect(element.primitiveCount == 1, "Only the in-bounds face should be in the index buffer")
}
}
@@ -1,9 +1,10 @@
import Foundation
import XCTest
import Testing
@testable import ICCeryCore
/// ``GamutMeshParser`` acceptance + edge-case tests.
final class GamutMeshParserTests: XCTestCase {
@Suite("Gamut mesh parser")
struct GamutMeshParserTests {
/// Returns the bundled real `sRGB.gam` in `Resources/Argyll/reference_gamuts`.
private var bundledSRGBGamURL: URL {
@@ -12,14 +13,16 @@ final class GamutMeshParserTests: XCTestCase {
return resource.appendingPathComponent("Argyll/reference_gamuts/sRGB.gam")
}
func testParsesBundledSRGB() throws {
@Test("Parses bundled sRGB.gam")
func parsesBundledSRGB() throws {
let mesh = try GamutMeshParser.parse(url: bundledSRGBGamURL)
XCTAssertEqual(mesh.vertices.count, 448, "sRGB.gam has 448 vertices")
XCTAssertEqual(mesh.faces.count, 892, "sRGB.gam has 892 faces")
#expect(mesh.vertices.count == 448, "sRGB.gam has 448 vertices")
#expect(mesh.faces.count == 892, "sRGB.gam has 892 faces")
}
func testDiscardsVertexNo() throws {
@Test("Discards VERTEX_NO and uses push-order indices")
func discardsVertexNo() throws {
let text = """
GAMUT
NUMBER_OF_FIELDS 4
@@ -46,13 +49,14 @@ final class GamutMeshParserTests: XCTestCase {
let mesh = try GamutMeshParser.parse(text: text)
XCTAssertEqual(mesh.vertices.count, 4)
XCTAssertEqual(mesh.faces.count, 2)
XCTAssertEqual(mesh.vertices[0].lab, LabColor(l: 10, a: 20, b: 30))
XCTAssertEqual(mesh.vertices[3].lab, LabColor(l: 40, a: 50, b: 60))
#expect(mesh.vertices.count == 4)
#expect(mesh.faces.count == 2)
#expect(mesh.vertices[0].lab == LabColor(l: 10, a: 20, b: 30))
#expect(mesh.vertices[3].lab == LabColor(l: 40, a: 50, b: 60))
}
func testIgnoresComments() throws {
@Test("Ignores comments and blank lines")
func ignoresComments() throws {
let text = """
# Header comment
NUMBER_OF_FIELDS 4
@@ -77,11 +81,12 @@ final class GamutMeshParserTests: XCTestCase {
"""
let mesh = try GamutMeshParser.parse(text: text)
XCTAssertEqual(mesh.vertices.count, 2)
XCTAssertEqual(mesh.faces.count, 1)
#expect(mesh.vertices.count == 2)
#expect(mesh.faces.count == 1)
}
func testRemapsCoordinates() throws {
@Test("Remaps coordinates to x=a*, y=L*, z=b*")
func remapsCoordinates() throws {
let text = """
NUMBER_OF_FIELDS 4
BEGIN_DATA_FORMAT
@@ -94,10 +99,11 @@ final class GamutMeshParserTests: XCTestCase {
"""
let mesh = try GamutMeshParser.parse(text: text)
XCTAssertEqual(mesh.vertices.first?.position, SIMD3<Float>(-20, 50, 80))
#expect(mesh.vertices.first?.position == SIMD3<Float>(-20, 50, 80))
}
func testComputesVertexColor() throws {
@Test("Computes per-vertex sRGB colour")
func computesVertexColor() throws {
let text = """
NUMBER_OF_FIELDS 4
BEGIN_DATA_FORMAT
@@ -110,13 +116,14 @@ final class GamutMeshParserTests: XCTestCase {
"""
let mesh = try GamutMeshParser.parse(text: text)
let white = try XCTUnwrap(mesh.vertices.first).rgb
XCTAssertTrue(white.r > 0.95)
XCTAssertTrue(white.g > 0.95)
XCTAssertTrue(white.b > 0.95)
let white = try #require(mesh.vertices.first).rgb
#expect(white.r > 0.95)
#expect(white.g > 0.95)
#expect(white.b > 0.95)
}
func testDropsOutOfBoundsFaces() throws {
@Test("Drops out-of-bounds face indices")
func dropsOutOfBoundsFaces() throws {
let text = """
NUMBER_OF_FIELDS 4
BEGIN_DATA_FORMAT
@@ -139,23 +146,21 @@ final class GamutMeshParserTests: XCTestCase {
"""
let mesh = try GamutMeshParser.parse(text: text)
XCTAssertEqual(mesh.faces.count, 1)
#expect(mesh.faces.count == 1)
}
func testThrowsOnEmptyFile() {
XCTAssertThrowsError(try GamutMeshParser.parse(text: "")) { error in
guard case GamutMeshParseError.noDataBlock = error else {
return XCTFail("Expected GamutMeshParseError.noDataBlock, got \(error)")
}
@Test("Throws on empty file")
func throwsOnEmptyFile() {
#expect(throws: GamutMeshParseError.noDataBlock) {
_ = try GamutMeshParser.parse(text: "")
}
}
func testThrowsWhenMissing() {
@Test("Throws when file is missing")
func throwsWhenMissing() {
let url = URL(fileURLWithPath: "/nonexistent/path/to/mesh.gam")
XCTAssertThrowsError(try GamutMeshParser.parse(url: url)) { error in
guard case GamutMeshParseError.missingFile = error else {
return XCTFail("Expected GamutMeshParseError.missingFile, got \(error)")
}
#expect(throws: GamutMeshParseError.missingFile) {
_ = try GamutMeshParser.parse(url: url)
}
}
}
@@ -1,127 +0,0 @@
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)
}
}
@@ -1,203 +0,0 @@
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))
}
}
@@ -1,14 +1,16 @@
import Foundation
import XCTest
import Testing
@testable import ICCeryCore
final class IccgamutArgsTests: XCTestCase {
@Suite("IccgamutArgs")
struct IccgamutArgsTests {
func testDensityNotDirectory() throws {
@Test("Density is 10 and not a directory")
func densityNotDirectory() throws {
let config = IccgamutConfig(
profileURL: URL(fileURLWithPath: "/tmp/MyProfile.icc")
)
let args = try IccgamutArgs.build(config: config)
XCTAssertEqual(args, ["-v", "-d", "10", "/tmp/MyProfile.icc"])
#expect(args == ["-v", "-d", "10", "/tmp/MyProfile.icc"])
}
}
+21 -14
View File
@@ -1,23 +1,26 @@
import Foundation
import XCTest
import Testing
@testable import ICCeryCore
final class JSONFileStoreTests: XCTestCase {
@Suite("JSONFileStore")
struct JSONFileStoreTests {
private func tempURL() -> URL {
FileManager.default.temporaryDirectory
.appendingPathComponent("json-store-\(UUID().uuidString).json")
}
func testMissingFileDefaults() throws {
@Test("Missing file returns default")
func missingFileDefaults() throws {
let store = JSONFileStore<AppSettings>(
fileURL: tempURL(),
corrupt: .throwCorrupt,
defaultValue: { .default }
)
XCTAssertEqual(try store.load(), .default)
#expect(try store.load() == .default)
}
func testCorruptDefaults() throws {
@Test("Corrupt file with replaceWithDefault returns default and leaves bytes")
func corruptDefaults() throws {
let url = tempURL()
try "{ not json".write(to: url, atomically: true, encoding: .utf8)
let store = JSONFileStore<AppSettings>(
@@ -25,12 +28,13 @@ final class JSONFileStoreTests: XCTestCase {
corrupt: .replaceWithDefault,
defaultValue: { .default }
)
XCTAssertEqual(try store.load(), .default)
#expect(try store.load() == .default)
let kept = try String(contentsOf: url, encoding: .utf8)
XCTAssertEqual(kept, "{ not json")
#expect(kept == "{ not json")
}
func testCorruptThrows() throws {
@Test("Corrupt file with throwCorrupt throws and leaves bytes")
func corruptThrows() throws {
let url = tempURL()
try "not json".write(to: url, atomically: true, encoding: .utf8)
let store = JSONFileStore<[Int]>(
@@ -38,12 +42,15 @@ final class JSONFileStoreTests: XCTestCase {
corrupt: .throwCorrupt,
defaultValue: { [] }
)
XCTAssertThrowsError(try store.load()) { error in XCTAssertTrue(error is DecodingError) }
#expect(throws: DecodingError.self) {
_ = try store.load()
}
let kept = try String(contentsOf: url, encoding: .utf8)
XCTAssertEqual(kept, "not json")
#expect(kept == "not json")
}
func testPrettySorted() throws {
@Test("Pretty sorted keys")
func prettySorted() throws {
let url = tempURL()
let store = JSONFileStore<AppSettings>(
fileURL: url,
@@ -52,8 +59,8 @@ final class JSONFileStoreTests: XCTestCase {
)
try store.save(.default)
let text = try String(contentsOf: url, encoding: .utf8)
XCTAssertTrue(text.contains("\n"))
XCTAssertTrue(text.contains("\"delta_e_good_max\""))
#expect(text.contains("\n"))
#expect(text.contains("\"delta_e_good_max\""))
// Lexical key sorting: ascending order of top-level keys.
let keys = [
"ask_before_overwrite_profile",
@@ -68,7 +75,7 @@ final class JSONFileStoreTests: XCTestCase {
var lastIndex = text.startIndex
for key in keys {
guard let range = text.range(of: "\"\(key)\"", range: lastIndex..<text.endIndex) else {
XCTFail("missing or out-of-order key \(key)")
Issue.record("missing or out-of-order key \(key)")
return
}
lastIndex = range.upperBound
+57 -44
View File
@@ -1,11 +1,12 @@
import XCTest
import Testing
import Foundation
@testable import ICCeryCore
/// Issue 15 `lp` argv goldens (docs/11 `build_lp_args`).
/// `-d`/`options`/`-t` handling is in `CupsService`; these tests cover
/// flag order, captured-option precedence, and sanitisation.
final class LpArgsTests: XCTestCase {
@Suite("LpArgs")
struct LpArgsTests {
private let tiff = "/tmp/work/target_001.tif"
private let queue = "EPSON_XP_55_Series"
@@ -19,100 +20,112 @@ final class LpArgsTests: XCTestCase {
options: options, optionKeys: optionKeys)
}
func testHeader() throws {
@Test("Header: -d queue -t title, both AP_* first, TIFF last")
func header() throws {
let argv = try build()
XCTAssertEqual(Array(argv[0...1]), ["-d", queue])
XCTAssertEqual(Array(argv[2...3]), ["-t", "ICCery Target - target_001.tif"])
XCTAssertEqual(Array(argv[4...5]), ["-o", "AP_ColorMatchingMode=AP_ApplicationColorMatching"])
XCTAssertEqual(Array(argv[6...7]), ["-o", "AP.ColorMatchingMode=AP_ApplicationColorMatching"])
XCTAssertEqual(argv.last, tiff)
XCTAssertFalse(argv.contains { $0 == "raw" || $0 == "-o raw" })
#expect(Array(argv[0...1]) == ["-d", queue])
#expect(Array(argv[2...3]) == ["-t", "ICCery Target - target_001.tif"])
#expect(Array(argv[4...5])
== ["-o", "AP_ColorMatchingMode=AP_ApplicationColorMatching"])
#expect(Array(argv[6...7])
== ["-o", "AP.ColorMatchingMode=AP_ApplicationColorMatching"])
#expect(argv.last == tiff)
#expect(!argv.contains { $0 == "raw" || $0 == "-o raw" })
}
func testNeverRaw() throws {
@Test("Never emits -o raw; captured raw= is dropped")
func neverRaw() throws {
let argv = try build(options: PrintOptions(
cupsOptions: "raw=true MediaType=Photo"))
for (i, arg) in argv.enumerated() where arg == "-o" {
XCTAssertNotEqual(argv[i + 1], "raw")
XCTAssertNotEqual(argv[i + 1], "raw=true")
#expect(argv[i + 1] != "raw")
#expect(argv[i + 1] != "raw=true")
}
XCTAssertFalse(argv.contains { $0.hasPrefix("raw=") })
XCTAssertTrue(argv.contains("MediaType=Photo"))
#expect(!argv.contains { $0.hasPrefix("raw=") })
#expect(argv.contains("MediaType=Photo"))
}
func testCapturedReplay() throws {
@Test("Captured options replayed after AP_* headers")
func capturedReplay() throws {
let argv = try build(options: PrintOptions(
cupsOptions: "InputSlot=Rear MediaType=Photo"))
let rear = argv.firstIndex(of: "InputSlot=Rear")!
let apFirst = argv.firstIndex(of:
"AP_ColorMatchingMode=AP_ApplicationColorMatching")!
XCTAssertTrue(rear > apFirst)
#expect(rear > apFirst)
}
func testCapturedWinsMedia() throws {
@Test("Captured wins: media key present → derived media skipped")
func capturedWinsMedia() throws {
let argv = try build(
options: PrintOptions(
mediaType: "Plain",
cupsOptions: "MediaType=Glossy"),
optionKeys: ["MediaType"])
XCTAssertTrue(argv.contains("MediaType=Glossy"))
XCTAssertFalse(argv.contains("MediaType=Plain"))
#expect(argv.contains("MediaType=Glossy"))
#expect(!argv.contains("MediaType=Plain"))
}
func testMediaDerived() throws {
@Test("Media emitted via detected key when not captured")
func mediaDerived() throws {
let argv = try build(
options: PrintOptions(mediaType: "SemiGloss"),
optionKeys: ["CNIJMediaType", "MediaType"])
// CNIJMediaType wins over MediaType in detection order.
XCTAssertTrue(argv.contains("CNIJMediaType=SemiGloss"))
XCTAssertFalse(argv.contains("MediaType=SemiGloss"))
#expect(argv.contains("CNIJMediaType=SemiGloss"))
#expect(!argv.contains("MediaType=SemiGloss"))
}
func testBypassRules() throws {
@Test("Driver bypass emitted when absent, skipped when captured")
func bypassRules() throws {
let withBypass = try build(
optionKeys: ["EPIJ_CMat"])
XCTAssertTrue(withBypass.contains("EPIJ_CMat=3"))
#expect(withBypass.contains("EPIJ_CMat=3"))
let captured = try build(
options: PrintOptions(cupsOptions: "EPIJ_CMat=1"),
optionKeys: ["EPIJ_CMat"])
// Captured value kept, detection not re-applied.
XCTAssertEqual(captured.filter { $0.hasPrefix("EPIJ_CMat") }, ["EPIJ_CMat=1"])
#expect(captured.filter { $0.hasPrefix("EPIJ_CMat") }
== ["EPIJ_CMat=1"])
}
func testOrientation() throws {
XCTAssertTrue(try build(options: PrintOptions(orientation: "portrait"))
@Test("Orientation: portrait=3 landscape=4; captured wins")
func orientation() throws {
#expect(try build(options: PrintOptions(orientation: "portrait"))
.contains("orientation-requested=3"))
XCTAssertTrue(try build(options: PrintOptions(orientation: "landscape"))
#expect(try build(options: PrintOptions(orientation: "landscape"))
.contains("orientation-requested=4"))
let capturedOrients = try build(options: PrintOptions(
orientation: "landscape",
cupsOptions: "orientation-requested=5"))
XCTAssertFalse(capturedOrients.contains("orientation-requested=4"))
XCTAssertTrue(capturedOrients.contains("orientation-requested=5"))
#expect(!capturedOrients.contains("orientation-requested=4"))
#expect(capturedOrients.contains("orientation-requested=5"))
}
func testPageSize() throws {
XCTAssertTrue(try build(options: PrintOptions(paperSize: "A4"))
@Test("PageSize emitted unless captured")
func pageSize() throws {
#expect(try build(options: PrintOptions(paperSize: "A4"))
.contains("PageSize=A4"))
let capturedSize = try build(options: PrintOptions(
paperSize: "A4", cupsOptions: "PageSize=Letter"))
XCTAssertFalse(capturedSize.contains("PageSize=A4"))
XCTAssertTrue(capturedSize.contains("PageSize=Letter"))
#expect(!capturedSize.contains("PageSize=A4"))
#expect(capturedSize.contains("PageSize=Letter"))
}
func testSanitise() throws {
XCTAssertThrowsError(try build(options: PrintOptions(
cupsOptions: "InputSlot=Rear;rm -rf /"))) { error in
XCTAssertTrue(error is LpArgsError)
@Test("Sanitise rejects `;`, newline, and shell metachars")
func sanitise() throws {
#expect(throws: LpArgsError.self) {
_ = try build(options: PrintOptions(
cupsOptions: "InputSlot=Rear;rm -rf /"))
}
XCTAssertThrowsError(try build(options: PrintOptions(
cupsOptions: "InputSlot=Rear\nMediaType=Photo"))) { error in
XCTAssertTrue(error is LpArgsError)
#expect(throws: LpArgsError.self) {
_ = try build(options: PrintOptions(
cupsOptions: "InputSlot=Rear\nMediaType=Photo"))
}
XCTAssertThrowsError(try build(options: PrintOptions(
cupsOptions: "InputSlot=$(whoami)"))) { error in
XCTAssertTrue(error is LpArgsError)
#expect(throws: LpArgsError.self) {
_ = try build(options: PrintOptions(
cupsOptions: "InputSlot=$(whoami)"))
}
}
}
+113 -123
View File
@@ -1,10 +1,12 @@
import Foundation
import XCTest
import Testing
@testable import ICCeryCore
final class InstrumentParserTests: XCTestCase {
@Suite("InstrumentParser")
struct InstrumentParserTests {
func testJson() throws {
@Test("Parses pretty-printed instlist JSON")
func json() throws {
let json = """
{
"event": "instruments",
@@ -16,118 +18,134 @@ final class InstrumentParserTests: XCTestCase {
}
"""
let devices = try InstrumentParser.parse(json)
XCTAssertEqual(devices.count, 3)
XCTAssertEqual(devices[0].port, 1)
XCTAssertEqual(devices[0].name, "X-Rite i1Pro")
XCTAssertEqual(devices[2].port, 3)
#expect(devices.count == 3)
#expect(devices[0].port == 1)
#expect(devices[0].name == "X-Rite i1Pro")
#expect(devices[2].port == 3)
}
func testRegexFallback() throws {
@Test("Falls back to regex for legacy instlist text")
func regexFallback() throws {
let text = """
1: 'X-Rite i1Pro' on usb
2: 'ColorMunki Smile'
""" + "\n"
let devices = try InstrumentParser.parse(text)
XCTAssertEqual(devices.count, 2)
XCTAssertEqual(devices[0].port, 1)
XCTAssertEqual(devices[1].name, "ColorMunki Smile")
#expect(devices.count == 2)
#expect(devices[0].port == 1)
#expect(devices[1].name == "ColorMunki Smile")
}
func testEmpty() throws {
XCTAssertTrue(try InstrumentParser.parse("").isEmpty)
@Test("Empty output returns no devices")
func empty() throws {
#expect(try InstrumentParser.parse("").isEmpty)
}
}
final class ChartreadArgsTests: XCTestCase {
@Suite("ChartreadArgs")
struct ChartreadArgsTests {
func testBaseline() throws {
@Test("Baseline argv and port 1 omits -c")
func baseline() throws {
let config = ChartreadConfig(basename: "target", selectedPort: 1)
let args = try ChartreadArgs.build(config: config)
XCTAssertEqual(args, ["-v", "-u", "target"])
#expect(args == ["-v", "-u", "target"])
}
func testPortArgument() throws {
@Test("Port > 1 emits -c")
func portArgument() throws {
let config = ChartreadConfig(basename: "target", selectedPort: 3)
let args = try ChartreadArgs.build(config: config)
XCTAssertEqual(args, ["-v", "-u", "-c", "3", "target"])
#expect(args == ["-v", "-u", "-c", "3", "target"])
}
func testLeds() throws {
@Test("LEDs emit -Y l")
func leds() throws {
let config = ChartreadConfig(
basename: "target",
selectedPort: 2,
enableLEDs: true
)
let args = try ChartreadArgs.build(config: config)
XCTAssertTrue(args.contains("-Y"))
XCTAssertTrue(args.contains("l"))
#expect(args.contains("-Y"))
#expect(args.contains("l"))
}
func testAutoPort() throws {
@Test("Auto omits -c")
func autoPort() throws {
let config = ChartreadConfig(basename: "target")
let args = try ChartreadArgs.build(config: config)
XCTAssertFalse(args.contains("-c"))
#expect(!args.contains("-c"))
}
}
final class ChartreadClassifierTests: XCTestCase {
@Suite("ChartreadClassifier")
struct ChartreadClassifierTests {
func testCalibration() {
@Test("Calibration prompt")
func calibration() {
let r = ChartreadClassifier.classify(
line: "Place instrument on calibration tile and hit [Space] to calibrate.",
previousState: .idle
)
XCTAssertEqual(r.state, .calibrating)
#expect(r.state == .calibrating)
}
func testAwaitingStrip() {
@Test("Strip awaiting")
func awaitingStrip() {
let r = ChartreadClassifier.classify(
line: "Hit [Space] to read strip A",
previousState: .calibrating
)
XCTAssertEqual(r.state, .awaitingStrip)
#expect(r.state == .awaitingStrip)
}
func testDone() {
@Test("Done prompt")
func done() {
let r = ChartreadClassifier.classify(
line: "'d' if/when done",
previousState: .awaitingStrip
)
XCTAssertEqual(r.state, .allStripsRead)
#expect(r.state == .allStripsRead)
}
func testPlaceSheet() {
@Test("XY place sheet")
func placeSheet() {
let r = ChartreadClassifier.classify(
line: "Please place sheet 1 of 2 on the table",
previousState: .idle
)
XCTAssertEqual(r.state, .tablePlaceSheet)
XCTAssertEqual(r.sheetNumber, 1)
XCTAssertEqual(r.sheetTotal, 2)
#expect(r.state == .tablePlaceSheet)
#expect(r.sheetNumber == 1)
#expect(r.sheetTotal == 2)
}
func testLocatePatch() {
@Test("XY locate patch")
func locatePatch() {
let r = ChartreadClassifier.classify(
line: "locate patch A1 with the sight,",
previousState: .tablePlaceSheet
)
XCTAssertEqual(r.state, .tableAlign)
XCTAssertEqual(r.alignmentPatch, "A1")
#expect(r.state == .tableAlign)
#expect(r.alignmentPatch == "A1")
}
func testRemoveNotice() {
@Test("Remove sheet notice preserves state")
func removeNotice() {
let r = ChartreadClassifier.classify(
line: "Please remove last sheet from table",
previousState: .tablePlaceSheet
)
XCTAssertEqual(r.state, .tablePlaceSheet)
XCTAssertEqual(r.isRemoveSheetNotice, true)
#expect(r.state == .tablePlaceSheet)
#expect(r.isRemoveSheetNotice == true)
}
}
final class ChartreadRowTests: XCTestCase {
@Suite("ChartreadRow")
struct ChartreadRowTests {
func testDecode() throws {
@Test("Decodes row JSON")
func decode() throws {
let json = """
{"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2,
"patch_count": 1, "patches": [
@@ -137,93 +155,59 @@ final class ChartreadRowTests: XCTestCase {
]}
"""
let row = try JSONDecoder().decode(ChartreadRow.self, from: Data(json.utf8))
XCTAssertEqual(row.rowId, "A")
XCTAssertEqual(row.patchCount, 1)
XCTAssertEqual(row.patches[0].measured.lab?.l, 51)
}
func testDecodeXYZAndLab() throws {
let json = """
{"event": "row_complete", "row_id": "B", "row_index": 1, "total_rows": 2,
"patch_count": 1, "patches": [
{"id": "7", "loc": "B7", "is_pad": false, "device": [10, 20, 30, 40],
"measured": {"XYZ": [30.5, 32.1, 25.9], "Lab": [63.4, 2.5, -8.2]}}
]}
"""
let row = try JSONDecoder().decode(ChartreadRow.self, from: Data(json.utf8))
let measured = row.patches[0].measured
XCTAssertEqual(measured.xyz, CIEXYZ(x: 30.5, y: 32.1, z: 25.9))
XCTAssertEqual(measured.lab, CIELab(l: 63.4, a: 2.5, b: -8.2))
}
func testXyzWireEncoding() throws {
for color in [XYZColor(x: 1.5, y: 2.5, z: 3.5), CIEXYZ(x: 1.5, y: 2.5, z: 3.5)] {
let value = try JSONSerialization.jsonObject(
with: JSONEncoder().encode(color))
XCTAssertEqual(value as? [Double], [1.5, 2.5, 3.5])
}
}
func testLabWireEncoding() throws {
for color in [LabColor(l: 50, a: -1, b: 2), CIELab(l: 50, a: -1, b: 2)] {
let value = try JSONSerialization.jsonObject(
with: JSONEncoder().encode(color))
XCTAssertEqual(value as? [Double], [50, -1, 2])
}
}
func testPatchColorKeys() throws {
let color = PatchColor(
xyz: CIEXYZ(x: 10, y: 20, z: 30),
lab: CIELab(l: 55, a: 1, b: -2))
let object = try JSONSerialization.jsonObject(
with: JSONEncoder().encode(color)) as? [String: Any]
XCTAssertEqual(object?["XYZ"] as? [Double], [10, 20, 30])
XCTAssertEqual(object?["Lab"] as? [Double], [55, 1, -2])
XCTAssertNil(object?["spectral"])
#expect(row.rowId == "A")
#expect(row.patchCount == 1)
#expect(row.patches[0].measured.lab?.l == 51)
}
}
final class ColourMathTests: XCTestCase {
@Suite("ColourMath")
struct ColourMathTests {
func testWhiteLab() {
@Test("White XYZ to Lab")
func whiteLab() {
let white = XYZColor(x: 96.4212, y: 100.0, z: 82.5188)
let lab = LabColorMath.xyzToLab(white)
XCTAssertTrue(abs(lab.l - 100) < 0.5)
XCTAssertTrue(abs(lab.a) < 0.5)
XCTAssertTrue(abs(lab.b) < 0.5)
#expect(abs(lab.l - 100) < 0.5)
#expect(abs(lab.a) < 0.5)
#expect(abs(lab.b) < 0.5)
}
func testLabToSRGB() {
@Test("Lab to sRGB roundtrip is clamped")
func labToSRGB() {
let red = LabColor(l: 55, a: 80, b: 70)
let rgb = LabColorMath.labToSRGB(red)
XCTAssertTrue(rgb.r > 0.8)
XCTAssertTrue(rgb.g < 0.2)
XCTAssertTrue(rgb.b < 0.2)
#expect(rgb.r > 0.8)
#expect(rgb.g < 0.2)
#expect(rgb.b < 0.2)
}
func testPadWhite() {
@Test("Pad white returns DisplayRGB")
func padWhite() {
let white = LabColor(l: 95, a: 0, b: 0)
let rgb = LabColorMath.labToSRGB(white)
XCTAssertTrue(rgb.r > 0.9)
XCTAssertTrue(rgb.g > 0.9)
XCTAssertTrue(rgb.b > 0.9)
#expect(rgb.r > 0.9)
#expect(rgb.g > 0.9)
#expect(rgb.b > 0.9)
}
func testCiede2000() {
@Test("Standard CIEDE2000 vector (Sharma)")
func ciede2000() {
let a = LabColor(l: 50, a: -1.3802, b: -84.2814)
let b = LabColor(l: 50, a: 0.0000, b: -82.7485)
XCTAssertTrue(abs(ColorDifference.deltaE00(a, b) - 1.00) < 0.001)
#expect(abs(ColorDifference.deltaE00(a, b) - 1.00) < 0.001)
}
func testClassify() {
XCTAssertEqual(ColorDifference.classify(deltaE: 0.5, 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)
@Test("Classification respects thresholds")
func classify() {
#expect(ColorDifference.classify(deltaE: 0.5, goodMax: 2.0, warningMax: 5.0) == .good)
#expect(ColorDifference.classify(deltaE: 3.0, goodMax: 2.0, warningMax: 5.0) == .warning)
#expect(ColorDifference.classify(deltaE: 6.0, goodMax: 2.0, warningMax: 5.0) == .bad)
}
}
final class MeasurementArtefactTests: XCTestCase {
@Suite("MeasurementArtefacts")
struct MeasurementArtefactTests {
private func makeCwd() throws -> URL {
let url = FileManager.default.temporaryDirectory
@@ -232,7 +216,8 @@ final class MeasurementArtefactTests: XCTestCase {
return url
}
func testDiscovery() throws {
@Test("Discovers passes in order")
func discovery() throws {
let cwd = try makeCwd()
defer { try? FileManager.default.removeItem(at: cwd) }
@@ -241,10 +226,11 @@ final class MeasurementArtefactTests: XCTestCase {
try "C".write(to: cwd.appendingPathComponent("target_pass10.ti3"), atomically: true, encoding: .utf8)
let passes = MeasurementArtefacts.passSnapshots(basename: "target", cwd: cwd)
XCTAssertEqual(passes.map(\.lastPathComponent), ["target_pass1.ti3", "target_pass3.ti3", "target_pass10.ti3"])
#expect(passes.map(\.lastPathComponent) == ["target_pass1.ti3", "target_pass3.ti3", "target_pass10.ti3"])
}
func testSnapshotPromote() throws {
@Test("Snapshot and promote are atomic")
func snapshotPromote() throws {
let cwd = try makeCwd()
defer { try? FileManager.default.removeItem(at: cwd) }
@@ -252,15 +238,16 @@ final class MeasurementArtefactTests: XCTestCase {
try "canonical".write(to: canonical, atomically: true, encoding: .utf8)
let pass = try MeasurementArtefacts.snapshotPass(basename: "target", cwd: cwd)
XCTAssertEqual(pass.lastPathComponent, "target_pass1.ti3")
XCTAssertFalse(FileManager.default.fileExists(atPath: canonical.path))
#expect(pass.lastPathComponent == "target_pass1.ti3")
#expect(!FileManager.default.fileExists(atPath: canonical.path))
let promoted = try MeasurementArtefacts.promotePass(pass: pass, basename: "target", cwd: cwd)
XCTAssertEqual(promoted.lastPathComponent, "target.ti3")
XCTAssertTrue(FileManager.default.fileExists(atPath: promoted.path))
#expect(promoted.lastPathComponent == "target.ti3")
#expect(FileManager.default.fileExists(atPath: promoted.path))
}
func testCollision() throws {
@Test("Pass collisions handled")
func collision() throws {
let cwd = try makeCwd()
defer { try? FileManager.default.removeItem(at: cwd) }
@@ -270,25 +257,28 @@ final class MeasurementArtefactTests: XCTestCase {
try "v2".write(to: canonical, atomically: true, encoding: .utf8)
let pass2 = try MeasurementArtefacts.snapshotPass(basename: "target", cwd: cwd)
XCTAssertEqual(pass2.lastPathComponent, "target_pass2.ti3")
#expect(pass2.lastPathComponent == "target_pass2.ti3")
}
}
final class AverageArgsTests: XCTestCase {
@Suite("AverageArgs")
struct AverageArgsTests {
func testPassCount() {
@Test("Requires at least two pass files")
func passCount() {
let cwd = URL(fileURLWithPath: "/tmp")
let config = AverageConfig(
workingDirectory: cwd,
basename: "target",
passFiles: [URL(fileURLWithPath: "target_pass1.ti3")]
)
XCTAssertThrowsError(try AverageArgs.build(config: config)) { error in
XCTAssertTrue(error is AverageArgError)
#expect(throws: AverageArgError.self) {
_ = try AverageArgs.build(config: config)
}
}
func testOrdering() throws {
@Test("Output is last and inputs are relative")
func ordering() throws {
let cwd = URL(fileURLWithPath: "/tmp")
let config = AverageConfig(
workingDirectory: cwd,
@@ -299,8 +289,8 @@ final class AverageArgsTests: XCTestCase {
]
)
let args = try AverageArgs.build(config: config)
XCTAssertEqual(args.first, "-v")
XCTAssertEqual(args.last, "target.ti3")
XCTAssertEqual(args, ["-v", "target_pass1.ti3", "target_pass2.ti3", "target.ti3"])
#expect(args.first == "-v")
#expect(args.last == "target.ti3")
#expect(args == ["-v", "target_pass1.ti3", "target_pass2.ti3", "target.ti3"])
}
}
@@ -1,96 +0,0 @@
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)
}
}
@@ -1,406 +0,0 @@
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))
}
}
@@ -1,126 +0,0 @@
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())
}
}
+244 -221
View File
@@ -1,98 +1,116 @@
import Testing
import Foundation
import XCTest
@testable import ICCeryCore
final class ProfilingPresetTests: XCTestCase {
@Suite("ProfilingPreset")
struct ProfilingPresetTests {
func testRoundTrip() throws {
@Test("snake_case keys round-trip through Codable")
func roundTrip() throws {
var p = PresetCatalog.highQualityCMYK
p.colprofInputViewingCond = "D50_2"
let data = try JSONEncoder().encode(p)
let decoded = try JSONDecoder().decode(ProfilingPreset.self, from: data)
XCTAssertEqual(decoded, p)
#expect(decoded == p)
// Spot-check the wire format.
let obj = try JSONSerialization.jsonObject(with: data) as! [String: Any]
XCTAssertEqual(obj["colour_space"] as? String, "cmyk")
XCTAssertEqual(obj["patch_count"] as? Int, 1500)
XCTAssertEqual(obj["total_ink_limit"] as? Int, 320)
XCTAssertEqual(obj["bit_depth"] as? Int, 16)
XCTAssertEqual(obj["colprof_input_viewing_cond"] as? String, "D50_2")
#expect(obj["colour_space"] as? String == "cmyk")
#expect(obj["patch_count"] as? Int == 1500)
#expect(obj["total_ink_limit"] as? Int == 320)
#expect(obj["bit_depth"] as? Int == 16)
#expect(obj["colprof_input_viewing_cond"] as? String == "D50_2")
}
func testSchemaTolerance() throws {
@Test("Unknown keys ignored; missing required field fails")
func schemaTolerance() throws {
let json = """
{"id":"x","name":"N","colour_space":"rgb","patch_count":10,
"white_patches":1,"black_patches":1,"instrument":"i1",
"page_size":"A4","bit_depth":8,"dpi":300,"future_key":42}
""".data(using: .utf8)!
let ok = try JSONDecoder().decode(ProfilingPreset.self, from: json)
XCTAssertEqual(ok.id, "x")
#expect(ok.id == "x")
let missing = """
{"id":"x","name":"N","colour_space":"rgb"}
""".data(using: .utf8)!
XCTAssertThrowsError(try JSONDecoder().decode(ProfilingPreset.self, from: missing)) { error in XCTAssertTrue(error is DecodingError) }
#expect(throws: DecodingError.self) {
try JSONDecoder().decode(ProfilingPreset.self, from: missing)
}
}
func testValidation() {
XCTAssertThrowsError(try ProfilingPreset(id: "a", name: "n", colourSpace: "lab").validated()) { error in XCTAssertTrue(error is ProfilingPreset.ValidationError) }
XCTAssertThrowsError(try ProfilingPreset(id: "a", name: "n", dpi: 10).validated()) { error in XCTAssertTrue(error is ProfilingPreset.ValidationError) }
XCTAssertThrowsError(try ProfilingPreset(id: "a", name: "n", bitDepth: 12).validated()) { error in XCTAssertTrue(error is ProfilingPreset.ValidationError) }
XCTAssertThrowsError(try ProfilingPreset(id: "a", name: "n", patchCount: 0).validated()) { error in XCTAssertTrue(error is ProfilingPreset.ValidationError) }
@Test("Validation rejects bad colour space / dpi / bit depth")
func validation() {
#expect(throws: ProfilingPreset.ValidationError.self) {
try ProfilingPreset(id: "a", name: "n", colourSpace: "lab").validated()
}
#expect(throws: ProfilingPreset.ValidationError.self) {
try ProfilingPreset(id: "a", name: "n", dpi: 10).validated()
}
#expect(throws: ProfilingPreset.ValidationError.self) {
try ProfilingPreset(id: "a", name: "n", bitDepth: 12).validated()
}
#expect(throws: ProfilingPreset.ValidationError.self) {
try ProfilingPreset(id: "a", name: "n", patchCount: 0).validated()
}
}
}
final class PresetCatalogTests: XCTestCase {
@Suite("PresetCatalog")
struct PresetCatalogTests {
func testBuiltIns() {
XCTAssertEqual(PresetCatalog.builtIns.count, 4)
@Test("Four built-ins with the documented values")
func builtIns() {
#expect(PresetCatalog.builtIns.count == 4)
let byID = Dictionary(uniqueKeysWithValues: PresetCatalog.builtIns.map { ($0.id, $0) })
let std = byID["preset-std-rgb"]!
XCTAssertTrue(std.colourSpace == "rgb" && std.patchCount == 800
#expect(std.colourSpace == "rgb" && std.patchCount == 800
&& std.pageSize == "A4" && std.bitDepth == 8
&& std.dpi == 300 && std.colprofQuality == "m"
&& std.whitePatches == 4 && std.blackPatches == 4)
let hq = byID["preset-hq-cmyk"]!
XCTAssertTrue(hq.colourSpace == "cmyk" && hq.patchCount == 1500
#expect(hq.colourSpace == "cmyk" && hq.patchCount == 1500
&& hq.pageSize == "A3" && hq.bitDepth == 16
&& hq.dpi == 300 && hq.colprofQuality == "h"
&& hq.totalInkLimit == 320 && hq.blackPatches == 8)
let draft = byID["preset-draft-rgb"]!
XCTAssertTrue(draft.colourSpace == "rgb" && draft.patchCount == 400
#expect(draft.colourSpace == "rgb" && draft.patchCount == 400
&& draft.pageSize == "A4" && draft.bitDepth == 8
&& draft.dpi == 150 && draft.colprofQuality == "l")
let ultra = byID["preset-ultra-rgb"]!
XCTAssertTrue(ultra.colourSpace == "rgb" && ultra.patchCount == 2500
#expect(ultra.colourSpace == "rgb" && ultra.patchCount == 2500
&& ultra.pageSize == "A3" && ultra.bitDepth == 16
&& ultra.dpi == 300 && ultra.colprofQuality == "u"
&& ultra.ofpsHighQuality == true
&& ultra.whitePatches == 6 && ultra.blackPatches == 6)
for p in PresetCatalog.builtIns {
XCTAssertEqual(p.instrument, "i1")
XCTAssertEqual(p.colprofFwa, "D50")
XCTAssertEqual(p.randomSeed, 1)
XCTAssertEqual(p.noRandomize, false)
XCTAssertEqual(p.colprofAlgorithm, "l")
#expect(p.instrument == "i1")
#expect(p.colprofFwa == "D50")
#expect(p.randomSeed == 1)
#expect(p.noRandomize == false)
#expect(p.colprofAlgorithm == "l")
}
}
func testOverlay() {
@Test("Custom presets overlay by id; built-ins are not deletable")
func overlay() {
let custom = ProfilingPreset(
id: "preset-std-rgb", name: "Shadowed", patchCount: 42)
let all = PresetCatalog.all(custom: [custom])
XCTAssertEqual(all.count, 4)
XCTAssertEqual(all.first { $0.id == "preset-std-rgb" }?.patchCount, 42)
XCTAssertTrue(PresetCatalog.isBuiltIn("preset-std-rgb"))
XCTAssertFalse(PresetCatalog.isBuiltIn("custom-1"))
#expect(all.count == 4)
#expect(all.first { $0.id == "preset-std-rgb" }?.patchCount == 42)
#expect(PresetCatalog.isBuiltIn("preset-std-rgb"))
#expect(!PresetCatalog.isBuiltIn("custom-1"))
}
}
final class PresetStoreTests: XCTestCase {
@Suite("PresetStore")
struct PresetStoreTests {
private func tempSettingsURL() throws -> URL {
let dir = FileManager.default.temporaryDirectory
@@ -101,52 +119,58 @@ final class PresetStoreTests: XCTestCase {
return dir.appendingPathComponent("settings.json")
}
func testCrud() throws {
@Test("CRUD + export/import round-trip")
func crud() throws {
let url = try tempSettingsURL()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
var p = ProfilingPreset(id: "custom-x", name: "Mine", patchCount: 999, dpi: 150)
try store.saveCustom(p)
XCTAssertEqual(store.customs().count, 1)
XCTAssertEqual(store.all().count, 5)
#expect(store.customs().count == 1)
#expect(store.all().count == 5)
p.name = "Renamed"
try store.saveCustom(p)
XCTAssertEqual(store.customs().count, 1)
XCTAssertEqual(store.customs()[0].name, "Renamed")
#expect(store.customs().count == 1)
#expect(store.customs()[0].name == "Renamed")
let data = try store.export(p)
let imported = try store.import(data)
XCTAssertEqual(imported.name, "Renamed")
XCTAssertEqual(imported.dpi, 150)
#expect(imported.name == "Renamed")
#expect(imported.dpi == 150)
XCTAssertTrue(try store.deleteCustom(id: "custom-x"))
XCTAssertTrue(store.customs().isEmpty)
XCTAssertFalse(try store.deleteCustom(id: "preset-std-rgb"))
#expect(try store.deleteCustom(id: "custom-x"))
#expect(store.customs().isEmpty)
#expect(try !store.deleteCustom(id: "preset-std-rgb"))
}
func testImportBuiltinCollision() throws {
@Test("Import rewrites a built-in id to a fresh custom id")
func importBuiltinCollision() throws {
let url = try tempSettingsURL()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
let data = try store.export(PresetCatalog.standardRGB)
let imported = try store.import(data)
XCTAssertTrue(imported.id.hasPrefix("custom-"))
XCTAssertFalse(PresetCatalog.isBuiltIn(imported.id))
#expect(imported.id.hasPrefix("custom-"))
#expect(!PresetCatalog.isBuiltIn(imported.id))
}
func testBuiltInImmutable() throws {
@Test("Built-ins are immutable through saveCustom")
func builtInImmutable() throws {
let url = try tempSettingsURL()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
var shadowed = PresetCatalog.standardRGB
shadowed.name = "Hacked"
XCTAssertThrowsError(try store.saveCustom(shadowed)) { error in XCTAssertTrue(error is PresetStore.PresetStoreError) }
#expect(throws: PresetStore.PresetStoreError.self) {
try store.saveCustom(shadowed)
}
}
}
final class PresetMigrationTests: XCTestCase {
@Suite("AppSettings preset migration")
struct PresetMigrationTests {
private func tempSettingsURL() throws -> URL {
let dir = FileManager.default.temporaryDirectory
@@ -155,7 +179,8 @@ final class PresetMigrationTests: XCTestCase {
return dir.appendingPathComponent("settings.json")
}
func testLegacyMigration() throws {
@Test("Legacy M1 custom_presets migrate to typed schema")
func legacyMigration() throws {
let url = try tempSettingsURL()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let legacy = """
@@ -169,19 +194,20 @@ final class PresetMigrationTests: XCTestCase {
try legacy.write(to: url)
let settings = SettingsStore(fileURL: url).load()
XCTAssertEqual(settings.customPresets.count, 1)
#expect(settings.customPresets.count == 1)
let p = settings.customPresets[0]
XCTAssertEqual(p.name, "Old One")
XCTAssertTrue(p.id.hasPrefix("custom-0-"))
XCTAssertEqual(p.colourSpace, "cmyk")
XCTAssertEqual(p.patchCount, 900)
XCTAssertEqual(p.dpi, 150)
XCTAssertEqual(p.bitDepth, 16)
XCTAssertEqual(p.instrument, "p3")
XCTAssertEqual(p.pageSize, "A3")
#expect(p.name == "Old One")
#expect(p.id.hasPrefix("custom-0-"))
#expect(p.colourSpace == "cmyk")
#expect(p.patchCount == 900)
#expect(p.dpi == 150)
#expect(p.bitDepth == 16)
#expect(p.instrument == "p3")
#expect(p.pageSize == "A3")
}
func testTypedRoundTrip() throws {
@Test("Typed presets load and re-save as the typed schema")
func typedRoundTrip() throws {
let url = try tempSettingsURL()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let store = SettingsStore(fileURL: url)
@@ -189,45 +215,50 @@ final class PresetMigrationTests: XCTestCase {
s.customPresets = [ProfilingPreset(id: "c1", name: "C1", patchCount: 700)]
try store.save(s)
let loaded = store.load()
XCTAssertEqual(loaded.customPresets.first?.patchCount, 700)
#expect(loaded.customPresets.first?.patchCount == 700)
}
func testDraftDPI() throws {
@Test("Draft preset dpi=150 survives Codable + settings round-trip")
func draftDPI() throws {
let url = try tempSettingsURL()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
let data = try store.export(PresetCatalog.draftRGB)
let obj = try JSONSerialization.jsonObject(with: data) as! [String: Any]
XCTAssertEqual(obj["dpi"] as? Int, 150)
#expect(obj["dpi"] as? Int == 150)
let back = try store.import(data)
XCTAssertEqual(back.dpi, 150)
#expect(back.dpi == 150)
}
}
final class PresetMappingTests: XCTestCase {
func testDraftDpi() {
@Suite("Preset mapping")
struct PresetMappingTests {
@Test("Draft 150 DPI maps into PrinttargConfig")
func draftDpi() {
let cfg = PrinttargConfig(
preset: PresetCatalog.draftRGB,
basename: "t",
workingDirectory: nil,
calibrationFile: nil
)
XCTAssertEqual(cfg.dpi, 150)
XCTAssertEqual(cfg.layoutOrder, .deterministic)
#expect(cfg.dpi == 150)
#expect(cfg.layoutOrder == .deterministic)
}
func testOptionalNil() {
@Test("Nil optional targen fields stay nil")
func optionalNil() {
let preset = ProfilingPreset(id: "x", name: "n", patchCount: 800)
let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
XCTAssertNil(cfg.greySteps)
XCTAssertNil(cfg.singleChannelSteps)
XCTAssertNil(cfg.neutralSteps)
XCTAssertNil(cfg.totalInkLimit)
XCTAssertNil(cfg.darkEmphasis)
XCTAssertNil(cfg.devicePower)
#expect(cfg.greySteps == nil)
#expect(cfg.singleChannelSteps == nil)
#expect(cfg.neutralSteps == nil)
#expect(cfg.totalInkLimit == nil)
#expect(cfg.darkEmphasis == nil)
#expect(cfg.devicePower == nil)
}
func testRoundTripConfigs() {
@Test("Custom page and FWA survive a config round-trip")
func roundTripConfigs() {
var preset = PresetCatalog.highQualityCMYK
preset.pageSize = "210x297"
preset.colprofFwa = "D50"
@@ -237,9 +268,9 @@ final class PresetMappingTests: XCTestCase {
preset: preset, basename: "job", workingDirectory: nil, calibrationFile: nil
)
let colprof = ColprofConfig(preset: preset, basename: "job", workingDirectory: nil)
XCTAssertEqual(printtarg.pageSize, .custom)
XCTAssertEqual(printtarg.customPageWidth, 210)
XCTAssertEqual(colprof.fwa, "D50")
#expect(printtarg.pageSize == .custom)
#expect(printtarg.customPageWidth == 210)
#expect(colprof.fwa == "D50")
let back = ProfilingPreset(
id: preset.id,
name: preset.name,
@@ -250,14 +281,15 @@ final class PresetMappingTests: XCTestCase {
calibrationFile: preset.calibrationFile,
applyCalibration: preset.applyCalibration
)
XCTAssertEqual(back.dpi, preset.dpi)
XCTAssertEqual(back.colourSpace, "cmyk")
XCTAssertEqual(back.pageSize, "210x297")
XCTAssertEqual(back.colprofFwa, "D50")
XCTAssertNil(back.greySteps)
#expect(back.dpi == preset.dpi)
#expect(back.colourSpace == "cmyk")
#expect(back.pageSize == "210x297")
#expect(back.colprofFwa == "D50")
#expect(back.greySteps == nil)
}
func testFullRoundTrip() {
@Test("Full preset round-trips through all three configs with every field asserted")
func fullRoundTrip() {
let preset = ProfilingPreset(
id: "custom-full",
name: "Full",
@@ -296,21 +328,21 @@ final class PresetMappingTests: XCTestCase {
)
let targen = TargenConfig(preset: preset, basename: "j", workingDirectory: nil)
XCTAssertEqual(targen.colourSpace, .cmyk)
XCTAssertEqual(targen.patchCount, 1500)
XCTAssertEqual(targen.whitePatches, 6)
XCTAssertEqual(targen.blackPatches, 8)
XCTAssertEqual(targen.greySteps, 9)
XCTAssertEqual(targen.singleChannelSteps, 7)
XCTAssertEqual(targen.neutralSteps, 4)
XCTAssertEqual(targen.neutralConcentration, 0.7)
XCTAssertEqual(targen.preconditioningProfile, "/tmp/pre.icm")
XCTAssertEqual(targen.ofpsHighQuality, true)
XCTAssertEqual(targen.ofpsAdaptation, 0.2)
XCTAssertEqual(targen.fullSpreadAlgorithm, .uniformRandom)
XCTAssertEqual(targen.totalInkLimit, 280)
XCTAssertEqual(targen.darkEmphasis, 1.3)
XCTAssertEqual(targen.devicePower, 1.2)
#expect(targen.colourSpace == .cmyk)
#expect(targen.patchCount == 1500)
#expect(targen.whitePatches == 6)
#expect(targen.blackPatches == 8)
#expect(targen.greySteps == 9)
#expect(targen.singleChannelSteps == 7)
#expect(targen.neutralSteps == 4)
#expect(targen.neutralConcentration == 0.7)
#expect(targen.preconditioningProfile == "/tmp/pre.icm")
#expect(targen.ofpsHighQuality == true)
#expect(targen.ofpsAdaptation == 0.2)
#expect(targen.fullSpreadAlgorithm == .uniformRandom)
#expect(targen.totalInkLimit == 280)
#expect(targen.darkEmphasis == 1.3)
#expect(targen.devicePower == 1.2)
let printtarg = PrinttargConfig(
preset: preset,
@@ -318,25 +350,25 @@ final class PresetMappingTests: XCTestCase {
workingDirectory: nil,
calibrationFile: preset.calibrationFile
)
XCTAssertEqual(printtarg.instrument, .p3)
XCTAssertEqual(printtarg.pageSize, .custom)
XCTAssertEqual(printtarg.customPageWidth, 250)
XCTAssertEqual(printtarg.customPageHeight, 300)
XCTAssertEqual(printtarg.bitDepth, .sixteen)
XCTAssertEqual(printtarg.dpi, 360)
XCTAssertEqual(printtarg.layoutOrder, .customSeed)
XCTAssertEqual(printtarg.customSeed, 42)
XCTAssertEqual(printtarg.calibrationFile, "/tmp/a.cal")
#expect(printtarg.instrument == .p3)
#expect(printtarg.pageSize == .custom)
#expect(printtarg.customPageWidth == 250)
#expect(printtarg.customPageHeight == 300)
#expect(printtarg.bitDepth == .sixteen)
#expect(printtarg.dpi == 360)
#expect(printtarg.layoutOrder == .customSeed)
#expect(printtarg.customSeed == 42)
#expect(printtarg.calibrationFile == "/tmp/a.cal")
let colprof = ColprofConfig(preset: preset, basename: "j", workingDirectory: nil)
XCTAssertEqual(colprof.algorithm, "x")
XCTAssertEqual(colprof.quality, "u")
XCTAssertEqual(colprof.intent, "p")
XCTAssertEqual(colprof.fwa, "D65")
XCTAssertEqual(colprof.illuminant, "D65")
XCTAssertEqual(colprof.observer, "1931_2")
XCTAssertEqual(colprof.inputViewingCond, "D50_2")
XCTAssertEqual(colprof.outputViewingCond, "D65_2")
#expect(colprof.algorithm == "x")
#expect(colprof.quality == "u")
#expect(colprof.intent == "p")
#expect(colprof.fwa == "D65")
#expect(colprof.illuminant == "D65")
#expect(colprof.observer == "1931_2")
#expect(colprof.inputViewingCond == "D50_2")
#expect(colprof.outputViewingCond == "D65_2")
let back = ProfilingPreset(
id: preset.id,
@@ -348,126 +380,117 @@ final class PresetMappingTests: XCTestCase {
calibrationFile: preset.calibrationFile,
applyCalibration: preset.applyCalibration
)
XCTAssertEqual(back, preset)
#expect(back == preset)
}
func testFullSpreadAlgorithms() {
let cases: [(String, FullSpreadAlgorithm)] = [
("ofps", .ofps),
("t", .target),
("r", .random),
("R", .uniformRandom),
("q", .quasiRandom),
("Q", .uniformQuasiRandom),
("i", .invertedQuasiRandom),
("I", .invertedUniformQuasiRandom)
]
for (value, expected) in cases {
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
preset.fullSpreadAlgorithm = value
let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
if expected == .ofps {
// ofps is the default no flag emitted, stored value is nil.
XCTAssertNil(cfg.fullSpreadAlgorithm)
} else {
XCTAssertEqual(cfg.fullSpreadAlgorithm, expected)
}
let back = ProfilingPreset(
id: "x", name: "n", description: "",
targen: cfg,
printtarg: PrinttargConfig(
preset: preset, basename: "t",
workingDirectory: nil, calibrationFile: nil
),
colprof: ColprofConfig(preset: preset, basename: "t", workingDirectory: nil),
calibrationFile: nil,
applyCalibration: nil
)
XCTAssertEqual(back.fullSpreadAlgorithm, value)
@Test("Every full-spread algorithm round-trips", arguments: [
("ofps", FullSpreadAlgorithm.ofps),
("t", .target),
("r", .random),
("R", .uniformRandom),
("q", .quasiRandom),
("Q", .uniformQuasiRandom),
("i", .invertedQuasiRandom),
("I", .invertedUniformQuasiRandom)
])
func fullSpreadAlgorithms(value: String, expected: FullSpreadAlgorithm) {
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
preset.fullSpreadAlgorithm = value
let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
if expected == .ofps {
// ofps is the default no flag emitted, stored value is nil.
#expect(cfg.fullSpreadAlgorithm == nil)
} else {
#expect(cfg.fullSpreadAlgorithm == expected)
}
let back = ProfilingPreset(
id: "x", name: "n", description: "",
targen: cfg,
printtarg: PrinttargConfig(
preset: preset, basename: "t",
workingDirectory: nil, calibrationFile: nil
),
colprof: ColprofConfig(preset: preset, basename: "t", workingDirectory: nil),
calibrationFile: nil,
applyCalibration: nil
)
#expect(back.fullSpreadAlgorithm == value)
}
func testOfpsHighQualityFalse() {
@Test("Explicit ofpsHighQuality=false is preserved, distinct from nil")
func ofpsHighQualityFalse() {
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
preset.ofpsHighQuality = false
let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
XCTAssertEqual(cfg.ofpsHighQuality, false)
#expect(cfg.ofpsHighQuality == false)
preset.ofpsHighQuality = nil
let nilCfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
XCTAssertNil(nilCfg.ofpsHighQuality)
#expect(nilCfg.ofpsHighQuality == nil)
}
func testLayoutMapping() {
let cases: [(Bool?, Int?, LayoutOrder, Int)] = [
(true, nil, .raster, 1),
(true, 7, .raster, 7),
(false, nil, .deterministic, 1),
(false, 1, .deterministic, 1),
(nil, 1, .deterministic, 1),
(false, 5, .customSeed, 5)
]
for (noRandomize, seed, layout, expectedSeed) in cases {
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
preset.noRandomize = noRandomize
preset.randomSeed = seed
let cfg = PrinttargConfig(
preset: preset, basename: "t",
workingDirectory: nil, calibrationFile: nil
)
XCTAssertEqual(cfg.layoutOrder, layout)
XCTAssertEqual(cfg.customSeed, expectedSeed)
}
@Test("noRandomize/seed layout mapping rules", arguments: [
(true, nil, LayoutOrder.raster, 1),
(true, 7, .raster, 7),
(false, nil, .deterministic, 1),
(false, 1, .deterministic, 1),
(nil, 1, .deterministic, 1),
(false, 5, .customSeed, 5)
] as [(Bool?, Int?, LayoutOrder, Int)])
func layoutMapping(noRandomize: Bool?, seed: Int?, layout: LayoutOrder, expectedSeed: Int) {
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
preset.noRandomize = noRandomize
preset.randomSeed = seed
let cfg = PrinttargConfig(
preset: preset, basename: "t",
workingDirectory: nil, calibrationFile: nil
)
#expect(cfg.layoutOrder == layout)
#expect(cfg.customSeed == expectedSeed)
}
func testCustomPageFallback() {
let cases: [(String, PageSize, Double, Double)] = [
("250x300", .custom, 250.0, 300.0),
("50x50", .custom, 50.0, 50.0),
("foo", .a4, 210.0, 297.0),
("30x40", .a4, 210.0, 297.0),
("210x", .a4, 210.0, 297.0)
]
for (raw, page, w, h) in cases {
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
preset.pageSize = raw
let cfg = PrinttargConfig(
preset: preset, basename: "t",
workingDirectory: nil, calibrationFile: nil
)
XCTAssertEqual(cfg.pageSize, page)
XCTAssertEqual(cfg.customPageWidth, w)
XCTAssertEqual(cfg.customPageHeight, h)
}
@Test("Custom page fallback matrix", arguments: [
("250x300", PageSize.custom, 250.0, 300.0),
("50x50", .custom, 50.0, 50.0),
("foo", .a4, 210.0, 297.0),
("30x40", .a4, 210.0, 297.0),
("210x", .a4, 210.0, 297.0)
] as [(String, PageSize, Double, Double)])
func customPageFallback(raw: String, page: PageSize, w: Double, h: Double) {
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
preset.pageSize = raw
let cfg = PrinttargConfig(
preset: preset, basename: "t",
workingDirectory: nil, calibrationFile: nil
)
#expect(cfg.pageSize == page)
#expect(cfg.customPageWidth == w)
#expect(cfg.customPageHeight == h)
}
func testFwaToSelection() {
let cases: [(String?, ColprofFwaSelection)] = [
(nil, .none),
("none", .none),
("NONE", .none),
("", .empty),
("D50", .D50),
("d50", .D50),
("D65", .D65),
("d65", .D65),
("/tmp/fwa.sp", .custom)
]
for (raw, expected) in cases {
XCTAssertEqual(ColprofFwaSelection(presetValue: raw), expected)
}
@Test("FWA preset value → selection matrix", arguments: [
(nil, ColprofFwaSelection.none),
("none", .none),
("NONE", .none),
("", .empty),
("D50", .D50),
("d50", .D50),
("D65", .D65),
("d65", .D65),
("/tmp/fwa.sp", .custom)
] as [(String?, ColprofFwaSelection)])
func fwaToSelection(raw: String?, expected: ColprofFwaSelection) {
#expect(ColprofFwaSelection(presetValue: raw) == expected)
}
func testFwaToPresetValue() {
let cases: [(ColprofFwaSelection, String?)] = [
(.none, nil),
(.empty, ""),
(.D50, "D50"),
(.D65, "D65"),
(.custom, "/tmp/fwa.sp")
]
for (selection, expected) in cases {
XCTAssertEqual(selection.presetValue(customPath: "/tmp/fwa.sp"), expected)
}
@Test("FWA selection → preset value matrix", arguments: [
(ColprofFwaSelection.none, nil),
(.empty, ""),
(.D50, "D50"),
(.D65, "D65"),
(.custom, "/tmp/fwa.sp")
] as [(ColprofFwaSelection, String?)])
func fwaToPresetValue(selection: ColprofFwaSelection, expected: String?) {
#expect(selection.presetValue(customPath: "/tmp/fwa.sp") == expected)
}
}
@@ -1,19 +1,21 @@
import Testing
import Foundation
import XCTest
@testable import ICCeryCore
@testable import ICCery
/// Issue #82 preset application through the live view models, under an
/// isolated `TestAppEnvironment` (temp stores, fresh ProcessManager).
@Suite("PresetViewModelMapping")
@MainActor
final class PresetViewModelMappingTests: XCTestCase {
struct PresetViewModelMappingTests {
private func makeWorkflow() throws -> (TestAppEnvironment, TargetWorkflowViewModel) {
let env = try TestAppEnvironment.make()
return (env, TargetWorkflowViewModel(environment: env.environment))
}
func testNilFwaClearsCustomPath() throws {
@Test("Applying a nil-FWA preset after a custom FWA clears the stale path")
func nilFwaClearsCustomPath() throws {
let (env, vm) = try makeWorkflow()
defer { env.cleanup() }
@@ -22,17 +24,18 @@ final class PresetViewModelMappingTests: XCTestCase {
colprofFwa: "/tmp/fwa.sp"
)
vm.applyPreset(customPreset)
XCTAssertEqual(vm.profile.fwaSelection, .custom)
XCTAssertEqual(vm.profile.fwaCustomPath, "/tmp/fwa.sp")
#expect(vm.profile.fwaSelection == .custom)
#expect(vm.profile.fwaCustomPath == "/tmp/fwa.sp")
customPreset.colprofFwa = nil
vm.applyPreset(customPreset)
XCTAssertEqual(vm.profile.fwaSelection, .none)
XCTAssertEqual(vm.profile.fwaCustomPath, "")
XCTAssertNil(vm.profile.fwaValue)
#expect(vm.profile.fwaSelection == .none)
#expect(vm.profile.fwaCustomPath == "")
#expect(vm.profile.fwaValue == nil)
}
func testCustomFwaRoundTrip() throws {
@Test("Custom FWA preset path survives the round-trip to colprof_fwa")
func customFwaRoundTrip() throws {
let (env, vm) = try makeWorkflow()
defer { env.cleanup() }
@@ -41,12 +44,13 @@ final class PresetViewModelMappingTests: XCTestCase {
colprofFwa: "/tmp/other.sp"
)
vm.applyPreset(preset)
XCTAssertEqual(vm.profile.fwaSelection, .custom)
XCTAssertEqual(vm.profile.fwaCustomPath, "/tmp/other.sp")
XCTAssertEqual(vm.profile.fwaValue, "/tmp/other.sp")
#expect(vm.profile.fwaSelection == .custom)
#expect(vm.profile.fwaCustomPath == "/tmp/other.sp")
#expect(vm.profile.fwaValue == "/tmp/other.sp")
}
func testPresetCalibrationReachesStage2() throws {
@Test("Preset calibration reaches Stage 2 instead of stale live state")
func presetCalibrationReachesStage2() throws {
let (env, vm) = try makeWorkflow()
defer { env.cleanup() }
@@ -61,12 +65,13 @@ final class PresetViewModelMappingTests: XCTestCase {
)
vm.applyPreset(preset)
XCTAssertTrue(vm.profile.applyCalibration)
XCTAssertEqual(vm.profile.calibrationFile, "/tmp/preset.cal")
XCTAssertEqual(vm.buildPrinttargConfig().calibrationFile, "/tmp/preset.cal")
#expect(vm.profile.applyCalibration)
#expect(vm.profile.calibrationFile == "/tmp/preset.cal")
#expect(vm.buildPrinttargConfig().calibrationFile == "/tmp/preset.cal")
}
func testDisabledCalibrationClearsStage2() throws {
@Test("Preset with calibration disabled clears Stage 2 calibration")
func disabledCalibrationClearsStage2() throws {
let (env, vm) = try makeWorkflow()
defer { env.cleanup() }
@@ -80,11 +85,12 @@ final class PresetViewModelMappingTests: XCTestCase {
)
vm.applyPreset(preset)
XCTAssertFalse(vm.profile.applyCalibration)
XCTAssertNil(vm.buildPrinttargConfig().calibrationFile)
#expect(!vm.profile.applyCalibration)
#expect(vm.buildPrinttargConfig().calibrationFile == nil)
}
func testFormFieldsApply() throws {
@Test("Preset Stage 1/2 form fields apply to the live form")
func formFieldsApply() throws {
let (env, vm) = try makeWorkflow()
defer { env.cleanup() }
@@ -100,22 +106,22 @@ final class PresetViewModelMappingTests: XCTestCase {
)
vm.applyPreset(preset)
XCTAssertEqual(vm.colourSpace, .cmyk)
XCTAssertEqual(vm.effectivePatchCount, 1500)
XCTAssertEqual(vm.whitePatches, 6)
XCTAssertEqual(vm.blackPatches, 8)
XCTAssertTrue(vm.greyStepsEnabled && vm.greySteps == 9)
XCTAssertEqual(vm.algorithm, .random)
XCTAssertEqual(vm.tiffDpi, 150)
XCTAssertEqual(vm.pageSize, .custom)
XCTAssertTrue(vm.customPageW == 250 && vm.customPageH == 300)
XCTAssertEqual(vm.selectedPresetID, "c-form")
#expect(vm.colourSpace == .cmyk)
#expect(vm.effectivePatchCount == 1500)
#expect(vm.whitePatches == 6)
#expect(vm.blackPatches == 8)
#expect(vm.greyStepsEnabled && vm.greySteps == 9)
#expect(vm.algorithm == .random)
#expect(vm.tiffDpi == 150)
#expect(vm.pageSize == .custom)
#expect(vm.customPageW == 250 && vm.customPageH == 300)
#expect(vm.selectedPresetID == "c-form")
// Disabled advanced controls stay nil in the snapshot, not
// numeric sentinels.
preset.greySteps = nil
vm.applyPreset(preset)
XCTAssertFalse(vm.greyStepsEnabled)
XCTAssertNil(vm.buildTargenConfig().greySteps)
#expect(!vm.greyStepsEnabled)
#expect(vm.buildTargenConfig().greySteps == nil)
}
}
+17 -13
View File
@@ -1,12 +1,13 @@
import Testing
import Foundation
import XCTest
@testable import ICCeryCore
@testable import ICCery
/// Issue 13 panel outcome mapping (cancel nil, ok result).
/// The real `NSPrintPanel` is never run in tests; these exercise the
/// `UITestHooks` seam the UI tests rely on.
final class PrintPanelStubTests: XCTestCase {
@Suite("PrintPanelStub")
struct PrintPanelStubTests {
private func withEnv(
_ vars: [String: String?],
@@ -27,17 +28,19 @@ final class PrintPanelStubTests: XCTestCase {
try body()
}
func testCancelIsNil() throws {
@Test("Cancel returns nil — not an error")
func cancelIsNil() throws {
try withEnv([
"ICCERY_UI_TESTING": "1",
"ICCERY_TEST_PRINT_PANEL": "cancel",
]) {
XCTAssertTrue(UITestHooks.printPanelStubbed)
XCTAssertNil(UITestHooks.printPanelResult(forQueue: "q"))
#expect(UITestHooks.printPanelStubbed)
#expect(UITestHooks.printPanelResult(forQueue: "q") == nil)
}
}
func testOkResult() throws {
@Test("OK returns captured options + selected printer")
func okResult() throws {
try withEnv([
"ICCERY_UI_TESTING": "1",
"ICCERY_TEST_PRINT_PANEL": "ok",
@@ -45,14 +48,15 @@ final class PrintPanelStubTests: XCTestCase {
"ICCERY_TEST_PANEL_PRINTER": "Other_Queue",
]) {
let result = UITestHooks.printPanelResult(forQueue: "q")
XCTAssertEqual(result?.selectedPrinter, "Other_Queue")
XCTAssertEqual(result?.options.cupsOptions, "MediaType=Photo InputSlot=Rear")
XCTAssertEqual(result?.options.mediaType, "Photo")
XCTAssertEqual(result?.options.ppdUncorrectedPassthrough, true)
#expect(result?.selectedPrinter == "Other_Queue")
#expect(result?.options.cupsOptions == "MediaType=Photo InputSlot=Rear")
#expect(result?.options.mediaType == "Photo")
#expect(result?.options.ppdUncorrectedPassthrough == true)
}
}
func testOkDefaultsPrinter() throws {
@Test("OK defaults selected printer to the opened queue")
func okDefaultsPrinter() throws {
try withEnv([
"ICCERY_UI_TESTING": "1",
"ICCERY_TEST_PRINT_PANEL": "ok",
@@ -60,8 +64,8 @@ final class PrintPanelStubTests: XCTestCase {
"ICCERY_TEST_PANEL_PRINTER": nil,
]) {
let result = UITestHooks.printPanelResult(forQueue: "My_Queue")
XCTAssertEqual(result?.selectedPrinter, "My_Queue")
XCTAssertNil(result?.options.cupsOptions)
#expect(result?.selectedPrinter == "My_Queue")
#expect(result?.options.cupsOptions == nil)
}
}
}
+14 -29
View File
@@ -1,21 +1,24 @@
import Foundation
import XCTest
import Testing
@testable import ICCeryCore
final class PrintcalArgsTests: XCTestCase {
@Suite("PrintcalArgs")
struct PrintcalArgsTests {
private let tmp = URL(fileURLWithPath: "/tmp/out.cal")
func testDefaults() throws {
@Test("Default printcal argv")
func defaults() throws {
let config = PrintcalConfig(
ti3Basename: "CAL_demo",
outputURL: tmp
)
let args = try PrintcalArgs.build(config: config)
XCTAssertEqual(args, ["-v", "-e", "-o", "/tmp/out.cal", "CAL_demo"])
#expect(args == ["-v", "-e", "-o", "/tmp/out.cal", "CAL_demo"])
}
func testAllOptions() throws {
@Test("All options and channel limits")
func allOptions() throws {
let config = PrintcalConfig(
ti3Basename: "demo",
outputURL: tmp,
@@ -29,7 +32,7 @@ final class PrintcalArgsTests: XCTestCase {
]
)
let args = try PrintcalArgs.build(config: config)
XCTAssertEqual(args, [
#expect(args == [
"-v", "-e",
"-I", "-z",
"-a", "/tmp/old.cal",
@@ -41,34 +44,16 @@ final class PrintcalArgsTests: XCTestCase {
])
}
func testWhitespacePreviousCal() throws {
let config = PrintcalConfig(
ti3Basename: "demo",
outputURL: tmp,
previousCalPath: " \n\t "
)
let args = try PrintcalArgs.build(config: config)
XCTAssertFalse(args.contains("-a"))
XCTAssertEqual(args, ["-v", "-e", "-o", "/tmp/out.cal", "CAL_demo"])
}
func testPreviousCalTrimmed() throws {
let config = PrintcalConfig(
ti3Basename: "demo",
outputURL: tmp,
previousCalPath: " /tmp/old.cal "
)
let args = try PrintcalArgs.build(config: config)
XCTAssertEqual(args[args.firstIndex(of: "-a")! + 1], "/tmp/old.cal")
}
func testRejectsBadChannelLimit() {
@Test("Rejects invalid per-channel limit")
func rejectsBadChannelLimit() {
let config = PrintcalConfig(
ti3Basename: "demo",
outputURL: tmp,
channelLimits: [PrintcalChannelLimit(channel: "K", percent: 150)]
)
XCTAssertThrowsError(try PrintcalArgs.build(config: config))
#expect(throws: (any Error).self) {
_ = try PrintcalArgs.build(config: config)
}
}
}
+120 -107
View File
@@ -1,8 +1,9 @@
import XCTest
import Testing
import Foundation
@testable import ICCeryCore
final class PrinttargArgsTests: XCTestCase {
@Suite("PrinttargArgs")
struct PrinttargArgsTests {
private func config(
instrument: PrintInstrument = .i1,
@@ -27,121 +28,119 @@ final class PrinttargArgsTests: XCTestCase {
)
}
func testBaseline() throws {
@Test("Baseline: -v -u -i i1 -p A4 -R 1 -t 300")
func baseline() throws {
let args = try PrinttargArgs.build(config: config())
XCTAssertEqual(args, ["-v", "-u", "-i", "i1", "-p", "A4",
#expect(args == ["-v", "-u", "-i", "i1", "-p", "A4",
"-R", "1", "-t", "300", "target"])
}
func testDeterministicDefault() throws {
@Test("Default layout is deterministic -R 1, never bare")
func deterministicDefault() throws {
let args = try PrinttargArgs.build(config: config())
XCTAssertTrue(args.contains("-R"))
XCTAssertFalse(args.contains("-r"))
XCTAssertEqual(args[args.firstIndex(of: "-R")! + 1], "1")
#expect(args.contains("-R"))
#expect(!args.contains("-r"))
#expect(args[args.firstIndex(of: "-R")! + 1] == "1")
}
func testCustomSeed() throws {
@Test("Custom seed -R N; seed < 1 throws")
func customSeed() throws {
let args = try PrinttargArgs.build(config: config(layout: .customSeed, seed: 42))
XCTAssertEqual(args[args.firstIndex(of: "-R")! + 1], "42")
XCTAssertThrowsError(try PrinttargArgs.build(config: config(layout: .customSeed, seed: 0))) { error in
XCTAssertTrue(error is PrinttargArgError)
#expect(args[args.firstIndex(of: "-R")! + 1] == "42")
#expect(throws: PrinttargArgError.self) {
try PrinttargArgs.build(config: config(layout: .customSeed, seed: 0))
}
}
func testRaster() throws {
@Test("Raster emits -r and supersedes seed (printtarg -r, not targen -r)")
func raster() throws {
let args = try PrinttargArgs.build(config: config(layout: .raster, seed: 9))
XCTAssertTrue(args.contains("-r"))
XCTAssertFalse(args.contains("-R"))
#expect(args.contains("-r"))
#expect(!args.contains("-R"))
}
func testLabel() throws {
@Test("Label: -d emits the resolved string, not a colour space")
func label() throws {
let args = try PrinttargArgs.build(
config: config(label: "ICCery - t - P - I - D - A - 01/02/2026 03:04"))
let i = args.firstIndex(of: "-d")!
XCTAssertTrue(args[i + 1].hasPrefix("ICCery - t"))
#expect(args[i + 1].hasPrefix("ICCery - t"))
}
func testBitDepthAndDPI() throws {
XCTAssertTrue(try PrinttargArgs.build(config: config(bitDepth: .sixteen, dpi: 600))
@Test("Bit depth: -t 8-bit, -T 16-bit; DPI range 72-600")
func bitDepthAndDPI() throws {
#expect(try PrinttargArgs.build(config: config(bitDepth: .sixteen, dpi: 600))
.contains("-T"))
XCTAssertTrue(try PrinttargArgs.build(config: config(bitDepth: .eight, dpi: 72))
#expect(try PrinttargArgs.build(config: config(bitDepth: .eight, dpi: 72))
.contains("-t"))
XCTAssertThrowsError(try PrinttargArgs.build(config: config(dpi: 71))) { error in
XCTAssertTrue(error is PrinttargArgError)
#expect(throws: PrinttargArgError.self) {
try PrinttargArgs.build(config: config(dpi: 71))
}
XCTAssertThrowsError(try PrinttargArgs.build(config: config(dpi: 601))) { error in
XCTAssertTrue(error is PrinttargArgError)
#expect(throws: PrinttargArgError.self) {
try PrinttargArgs.build(config: config(dpi: 601))
}
}
func testInstruments() throws {
@Test("All instruments emit their Argyll code")
func instruments() throws {
let expected: [(PrintInstrument, String)] = [
(.i1, "i1"), (.p3, "p3"), (.cm, "CM"), (.ss, "SS"),
(.dtp20, "20"), (.dtp22, "22"), (.dtp41, "41"), (.dtp51, "51"),
]
for (inst, code) in expected {
let args = try PrinttargArgs.build(config: config(instrument: inst))
XCTAssertEqual(args[args.firstIndex(of: "-i")! + 1], code)
#expect(args[args.firstIndex(of: "-i")! + 1] == code)
}
}
func testPageSizes() throws {
@Test("All fixed page sizes; custom emits WxH in mm")
func pageSizes() throws {
for size in PageSize.allCases where size != .custom {
let args = try PrinttargArgs.build(config: config(pageSize: size))
XCTAssertEqual(args[args.firstIndex(of: "-p")! + 1], size.rawValue)
#expect(args[args.firstIndex(of: "-p")! + 1] == size.rawValue)
}
let custom = try PrinttargArgs.build(config: config(
pageSize: .custom, customW: 150, customH: 220))
XCTAssertEqual(custom[custom.firstIndex(of: "-p")! + 1], "150x220")
#expect(custom[custom.firstIndex(of: "-p")! + 1] == "150x220")
}
func testCustomPageTooSmall() {
XCTAssertThrowsError(try PrinttargArgs.build(config: config(pageSize: .custom, customW: 49.9))) { error in
XCTAssertTrue(error is PrinttargArgError)
@Test("Custom page below 50 mm throws")
func customPageTooSmall() {
#expect(throws: PrinttargArgError.self) {
try PrinttargArgs.build(config: config(pageSize: .custom, customW: 49.9))
}
XCTAssertThrowsError(try PrinttargArgs.build(config: config(pageSize: .custom, customH: 10))) { error in
XCTAssertTrue(error is PrinttargArgError)
#expect(throws: PrinttargArgError.self) {
try PrinttargArgs.build(config: config(pageSize: .custom, customH: 10))
}
}
func testCalibrationFlags() throws {
@Test("Calibration: -K applies, -I embeds")
func calibrationFlags() throws {
let k = try PrinttargArgs.build(config: config(calFile: "/tmp/a.cal"))
XCTAssertEqual(k[k.firstIndex(of: "-K")! + 1], "/tmp/a.cal")
#expect(k[k.firstIndex(of: "-K")! + 1] == "/tmp/a.cal")
let i = try PrinttargArgs.build(config: config(calFile: "/tmp/a.cal", calEmbed: true))
XCTAssertEqual(i[i.firstIndex(of: "-I")! + 1], "/tmp/a.cal")
XCTAssertFalse(i.contains("-K"))
#expect(i[i.firstIndex(of: "-I")! + 1] == "/tmp/a.cal")
#expect(!i.contains("-K"))
}
func testCalProtection() throws {
@Test("CAL_ basename never gets -K or -I")
func calProtection() throws {
let args = try PrinttargArgs.build(
config: config(calFile: "/tmp/a.cal", basename: "CAL_test"))
XCTAssertFalse(args.contains("-K"))
XCTAssertFalse(args.contains("-I"))
#expect(!args.contains("-K"))
#expect(!args.contains("-I"))
}
func testWhitespaceOptions() throws {
let args = try PrinttargArgs.build(
config: config(label: " \n ", calFile: " \t "))
XCTAssertFalse(args.contains("-d"))
XCTAssertFalse(args.contains("-K"))
XCTAssertFalse(args.contains("-I"))
}
func testTrimmedOptions() throws {
let args = try PrinttargArgs.build(
config: config(label: " My Label ", calFile: " /tmp/a.cal "))
XCTAssertEqual(args[args.firstIndex(of: "-d")! + 1], "My Label")
XCTAssertEqual(args[args.firstIndex(of: "-K")! + 1], "/tmp/a.cal")
}
func testUnsafeBasename() {
XCTAssertThrowsError(try PrinttargArgs.build(config: config(basename: "../x"))) { error in
XCTAssertTrue(error is PathSecurity.Error)
@Test("Unsafe basename throws")
func unsafeBasename() {
#expect(throws: PathSecurity.Error.self) {
try PrinttargArgs.build(config: config(basename: "../x"))
}
}
}
final class PrinttargLabelTests: XCTestCase {
@Suite("PrinttargLabel")
struct PrinttargLabelTests {
private var fixedDate: Date {
var comps = DateComponents()
@@ -150,33 +149,37 @@ final class PrinttargLabelTests: XCTestCase {
return Calendar(identifier: .gregorian).date(from: comps)!
}
func testAutomatic() {
@Test("Automatic label: ICCery - basename - P - I - DP - AP - DD/MM/YYYY HH:MM")
func automatic() {
let label = PrinttargLabel.automatic(
basename: "tgt",
metadata: TargetLabelMetadata(
printer: "Epson", inkSet: "CMYK",
driverPaper: "Photo", actualPaper: "Matte"),
date: fixedDate, timeZone: .current)
XCTAssertTrue(label.hasPrefix("ICCery - tgt - Epson - CMYK - Photo - Matte - "))
XCTAssertTrue(label.hasSuffix("03/02/2026") || label.contains("/02/2026"))
#expect(label.hasPrefix("ICCery - tgt - Epson - CMYK - Photo - Matte - "))
#expect(label.hasSuffix("03/02/2026") || label.contains("/02/2026"))
}
func testUnspecified() {
@Test("Missing metadata becomes Unspecified")
func unspecified() {
let label = PrinttargLabel.automatic(
basename: "tgt", metadata: TargetLabelMetadata(),
date: fixedDate, timeZone: .current)
XCTAssertTrue(label.contains(" - Unspecified - Unspecified - Unspecified - Unspecified - "))
#expect(label.contains(" - Unspecified - Unspecified - Unspecified - Unspecified - "))
}
func testManualWins() {
@Test("Manual label wins over automatic")
func manualWins() {
let resolved = PrinttargLabel.resolved(
customLabel: " My Label ", basename: "tgt",
metadata: TargetLabelMetadata(), date: fixedDate)
XCTAssertEqual(resolved, "My Label")
#expect(resolved == "My Label")
}
}
final class PrinttargManifestTests: XCTestCase {
@Suite("PrinttargManifest")
struct PrinttargManifestTests {
private let prettySingle = """
Some log line
@@ -205,58 +208,66 @@ final class PrinttargManifestTests: XCTestCase {
}
"""
func testSinglePage() throws {
@Test("Decodes a single-page pretty manifest amid log noise")
func singlePage() throws {
let m = try PrinttargManifestExtractor.manifest(from: prettySingle)
XCTAssertEqual(m.event, "manifest")
XCTAssertEqual(m.pages.count, 1)
XCTAssertEqual(m.pages[0].filename, "target.tif")
XCTAssertEqual(m.pages[0].patches, 800)
#expect(m.event == "manifest")
#expect(m.pages.count == 1)
#expect(m.pages[0].filename == "target.tif")
#expect(m.pages[0].patches == 800)
}
func testMultiPage() throws {
@Test("Multi-page manifest preserves order")
func multiPage() throws {
let m = try PrinttargManifestExtractor.manifest(from: prettyMulti)
XCTAssertEqual(m.pages.map(\.filename), ["p1.tif", "p2.tif"])
#expect(m.pages.map(\.filename) == ["p1.tif", "p2.tif"])
}
func testNoJSON() {
XCTAssertThrowsError(try PrinttargManifestExtractor.manifest(from: "plain text\nno json")) { error in
XCTAssertTrue(error is ManifestError)
@Test("No JSON document → noJSONDocument")
func noJSON() {
#expect(throws: ManifestError.self) {
try PrinttargManifestExtractor.manifest(from: "plain text\nno json")
}
}
func testWrongEvent() {
@Test("Wrong event → wrongEvent")
func wrongEvent() {
let stdout = "{\n \"event\": \"row\",\n \"row\": 1\n}\n"
XCTAssertThrowsError(try PrinttargManifestExtractor.manifest(from: stdout)) { error in
XCTAssertTrue(error is ManifestError)
#expect(throws: ManifestError.self) {
try PrinttargManifestExtractor.manifest(from: stdout)
}
}
func testRowColorsNotManifest() {
@Test("ROW_COLORS_JSON line is never treated as the manifest")
func rowColorsNotManifest() {
let stdout = "ROW_COLORS_JSON: {\"a\":1}\n{\"event\":\"manifest\",\"pages\":[]}"
// Extraction only starts at a '{' that begins a trimmed line,
// so the ROW_COLORS_JSON line is skipped entirely.
let m = try? PrinttargManifestExtractor.manifest(from: stdout)
XCTAssertNotNil(m)
XCTAssertEqual(m?.event, "manifest")
#expect(m != nil)
#expect(m?.event == "manifest")
}
func testBracesInFilename() throws {
@Test("Braces inside a quoted filename do not corrupt the scan")
func bracesInFilename() throws {
let stdout = "log\n{\n\"event\": \"manifest\",\n\"pages\": [{\"filename\": \"a}b.tif\", \"patches\": 1, \"width_mm\": 50, \"height_mm\": 50}]\n}\n"
let m = try PrinttargManifestExtractor.manifest(from: stdout)
XCTAssertEqual(m.pages[0].filename, "a}b.tif")
#expect(m.pages[0].filename == "a}b.tif")
}
func testUnsafeFilenames() {
@Test("Unsafe / non-TIFF filenames rejected")
func unsafeFilenames() {
for bad in ["../x.tif", "/abs/x.tif", "dir/x.tif", "x.txt", ""] {
let stdout = "{\n\"event\":\"manifest\",\"pages\":[{\"filename\":\"\(bad)\",\"patches\":1,\"width_mm\":50,\"height_mm\":50}]\n}"
XCTAssertThrowsError(try PrinttargManifestExtractor.manifest(from: stdout)) { error in
XCTAssertTrue(error is ManifestError)
#expect(throws: ManifestError.self) {
try PrinttargManifestExtractor.manifest(from: stdout)
}
}
}
}
final class ArgyllRunnerPrinttargTests: XCTestCase {
@Suite("ArgyllRunner Printtarg")
struct ArgyllRunnerPrinttargTests {
private func makeFixture(_ body: String, name: String = "printtarg") throws -> URL {
let dir = FileManager.default.temporaryDirectory
@@ -308,7 +319,8 @@ final class ArgyllRunnerPrinttargTests: XCTestCase {
try Data(bytes).write(to: url)
}
func testSuccess() async throws {
@Test("Successful printtarg emits .ti2 + manifest + PNG previews")
func success() async throws {
let dir = try makeFixture("""
#!/bin/sh
last=""
@@ -327,17 +339,18 @@ final class ArgyllRunnerPrinttargTests: XCTestCase {
processManager: ProcessManager(), binaryResolver: resolver)
let config = PrinttargConfig(basename: "pt", workingDirectory: dir)
let result = try await runner.runPrinttarg(config: config)
XCTAssertEqual(result.ti2URL.lastPathComponent, "pt.ti2")
XCTAssertEqual(result.manifest.pages.count, 1)
XCTAssertEqual(result.pages.count, 1)
#expect(result.ti2URL.lastPathComponent == "pt.ti2")
#expect(result.manifest.pages.count == 1)
#expect(result.pages.count == 1)
let png = result.pages[0].previewPNG
XCTAssertNotNil(png)
#expect(png != nil)
if let png {
XCTAssertEqual(png.prefix(8), Data([0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A]))
#expect(png.prefix(8) == Data([0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A]))
}
}
func testFailure() async throws {
@Test("Non-zero exit throws processFailed and stays on stage")
func failure() async throws {
let dir = try makeFixture("""
#!/bin/sh
echo "oops" >&2
@@ -347,16 +360,14 @@ final class ArgyllRunnerPrinttargTests: XCTestCase {
let runner = ArgyllRunner(
processManager: ProcessManager(),
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir))
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
await #expect(throws: ArgyllRunnerError.self) {
try await runner.runPrinttarg(
config: PrinttargConfig(basename: "x", workingDirectory: dir))
} errorHandler: { error in
XCTAssertEqual(error, .toolFailed(
tool: "printtarg", code: 3, logs: ["oops"]))
}
}
func testNoManifest() async throws {
@Test("Exit 0 without manifest → malformedManifest")
func noManifest() async throws {
let dir = try makeFixture("""
#!/bin/sh
last=""
@@ -369,13 +380,14 @@ final class ArgyllRunnerPrinttargTests: XCTestCase {
let runner = ArgyllRunner(
processManager: ProcessManager(),
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir))
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
await #expect(throws: ArgyllRunnerError.self) {
try await runner.runPrinttarg(
config: PrinttargConfig(basename: "x", workingDirectory: dir))
}
}
func testNoTi2() async throws {
@Test("Exit 0 without .ti2 → missingArtefact")
func noTi2() async throws {
let dir = try makeFixture("""
#!/bin/sh
printf '{\\n"event":"manifest",\\n"pages":[]\\n}\\n'
@@ -385,13 +397,14 @@ final class ArgyllRunnerPrinttargTests: XCTestCase {
let runner = ArgyllRunner(
processManager: ProcessManager(),
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir))
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
await #expect(throws: ArgyllRunnerError.self) {
try await runner.runPrinttarg(
config: PrinttargConfig(basename: "x", workingDirectory: dir))
}
}
func testDeterminism() async throws {
@Test("Deterministic config produces byte-identical .ti2")
func determinism() async throws {
let dir = try makeFixture("""
#!/bin/sh
last=""
@@ -411,6 +424,6 @@ final class ArgyllRunnerPrinttargTests: XCTestCase {
config: PrinttargConfig(basename: "b", workingDirectory: dir))
let d1 = try Data(contentsOf: dir.appendingPathComponent("a.ti2"))
let d2 = try Data(contentsOf: dir.appendingPathComponent("b.ti2"))
XCTAssertEqual(d1, d2)
#expect(d1 == d2)
}
}
+117 -134
View File
@@ -1,11 +1,11 @@
import XCTest
import Testing
import Foundation
@testable import ICCeryCore
/// Helpers shared across ProcessManager tests. Fixture binaries are shell
/// scripts written to a temp dir no resource bundling required.
/// XCTest executes test methods serially by default.
final class ProcessManagerTests: XCTestCase {
@Suite("ProcessManager", .serialized)
struct ProcessManagerTests {
// MARK: - Fixture plumbing
@@ -43,7 +43,7 @@ final class ProcessManagerTests: XCTestCase {
if box.finish() { cont.resume(returning: box.events) }
}
Task {
try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
try? await Task.sleep(for: .seconds(timeout))
if box.finish() { cont.resume(returning: box.events) }
}
}
@@ -84,7 +84,7 @@ final class ProcessManagerTests: XCTestCase {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if exitCount(in: box) > 0 { return true }
try? await Task.sleep(nanoseconds: 10_000_000)
try? await Task.sleep(for: .milliseconds(10))
}
return false
}
@@ -93,7 +93,7 @@ final class ProcessManagerTests: XCTestCase {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if FileManager.default.fileExists(atPath: url.path) { return true }
try? await Task.sleep(nanoseconds: 10_000_000)
try? await Task.sleep(for: .milliseconds(10))
}
return false
}
@@ -106,14 +106,14 @@ final class ProcessManagerTests: XCTestCase {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if await manager.isRunning(id) { return true }
try? await Task.sleep(nanoseconds: 10_000_000)
try? await Task.sleep(for: .milliseconds(10))
}
return false
}
// MARK: - Tests
func testStreamsStdoutAndEmitsExit() async throws {
@Test func streamsStdoutAndEmitsExit() async throws {
let pm = ProcessManager()
let bin = try script("lines.sh", "#!/bin/sh\necho hello\necho world\n")
async let events = collect(pm, id: "t1")
@@ -122,21 +122,21 @@ final class ProcessManagerTests: XCTestCase {
let lines = evs.compactMap { e -> String? in
if case .stdout(_, let l) = e { return l }; return nil
}
XCTAssertEqual(lines, ["hello", "world"])
XCTAssertTrue(evs.contains(.exit(id: "t1", code: 0)))
#expect(lines == ["hello", "world"])
#expect(evs.contains(.exit(id: "t1", code: 0)))
}
func testRoutesStderrSeparately() async throws {
@Test func routesStderrSeparately() async throws {
let pm = ProcessManager()
let bin = try script("err.sh", "#!/bin/sh\necho out\necho oops 1>&2\n")
async let evs = collect(pm, id: "t2")
try await pm.runStreaming(id: "t2", binary: bin, arguments: [])
let events = await evs
XCTAssertTrue(events.contains(.stdout(id: "t2", line: "out")))
XCTAssertTrue(events.contains(.stderr(id: "t2", line: "oops")))
#expect(events.contains(.stdout(id: "t2", line: "out")))
#expect(events.contains(.stderr(id: "t2", line: "oops")))
}
func testStripsRowColorsJSONPrefix() async throws {
@Test func stripsRowColorsJSONPrefix() async throws {
let pm = ProcessManager()
let bin = try script(
"rows.sh",
@@ -149,22 +149,21 @@ final class ProcessManagerTests: XCTestCase {
if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) }
return nil
}
XCTAssertEqual(rows, ["{\"row\":1}"])
XCTAssertTrue(events.contains(.stdout(id: "t3", line: "plain")))
#expect(rows == ["{\"row\":1}"])
#expect(events.contains(.stdout(id: "t3", line: "plain")))
// Prefixed lines must not leak into stdout.
XCTAssertFalse(events.contains(.stdout(id: "t3", line: "ROW_COLORS_JSON: {\"row\":1}")))
#expect(!events.contains(.stdout(id: "t3", line: "ROW_COLORS_JSON: {\"row\":1}")))
}
func testUnterminatedTailFlushesOnExit() async throws {
@Test func unterminatedTailFlushesOnExit() async throws {
let pm = ProcessManager()
let bin = try script("tail.sh", "#!/bin/sh\nprintf 'no-newline'\n")
async let evs = collect(pm, id: "t4")
try await pm.runStreaming(id: "t4", binary: bin, arguments: [])
let t4SawTail = await evs.contains(.stdout(id: "t4", line: "no-newline"))
XCTAssertTrue(t4SawTail)
#expect(await evs.contains(.stdout(id: "t4", line: "no-newline")))
}
func testStdinRoundTrip() async throws {
@Test func stdinRoundTrip() async throws {
let pm = ProcessManager()
// Read two lines then exit naturally a killed sh would lose its
// buffered stdio output, which is exactly the chartread pattern.
@@ -177,23 +176,21 @@ final class ProcessManagerTests: XCTestCase {
try await pm.sendStdin(id: "t5", text: " \n")
try await pm.sendStdin(id: "t5", text: "d\n")
let events = await evs
XCTAssertTrue(events.contains(.stdout(id: "t5", line: "got: ")))
XCTAssertTrue(events.contains(.stdout(id: "t5", line: "got:d")))
#expect(events.contains(.stdout(id: "t5", line: "got: ")))
#expect(events.contains(.stdout(id: "t5", line: "got:d")))
}
func testDuplicateIDRejected() async throws {
@Test func duplicateIDRejected() async throws {
let pm = ProcessManager()
let bin = try script("slow.sh", "#!/bin/sh\nsleep 30\n")
try await pm.runStreaming(id: "t6", binary: bin, arguments: [])
await assertAsyncThrows(expectedType: ProcessError.self) {
await #expect(throws: ProcessError.duplicateID("t6")) {
try await pm.runStreaming(id: "t6", binary: bin, arguments: [])
} errorHandler: { error in
XCTAssertEqual(error, .duplicateID("t6"))
}
await pm.kill(id: "t6")
}
func testKillEmitsExitAndClosesStdin() async throws {
@Test func killEmitsExitAndClosesStdin() async throws {
let pm = ProcessManager()
let bin = try script("slow2.sh", "#!/bin/sh\ncat\n")
async let evs = collect(pm, id: "t7")
@@ -202,51 +199,49 @@ final class ProcessManagerTests: XCTestCase {
let events = await evs
// exit emitted exactly once
let exits = events.filter { if case .exit = $0 { return true }; return false }
XCTAssertEqual(exits.count, 1)
await assertAsyncThrows(expectedType: ProcessError.self) {
#expect(exits.count == 1)
await #expect(throws: ProcessError.unknownID("t7")) {
try await pm.sendStdin(id: "t7", text: "d\n")
} errorHandler: { error in
XCTAssertEqual(error, .unknownID("t7"))
}
}
func testKillAllCountsSignaled() async throws {
@Test func killAllCountsSignaled() async throws {
let pm = ProcessManager()
let bin = try script("slow3.sh", "#!/bin/sh\nsleep 30\n")
try await pm.runStreaming(id: "a", binary: bin, arguments: [])
try await pm.runStreaming(id: "b", binary: bin, arguments: [])
let count = await pm.killAll()
XCTAssertEqual(count, 2)
#expect(count == 2)
}
func testCapturedRunReturnsBothStreams() async throws {
@Test func capturedRunReturnsBothStreams() async throws {
let pm = ProcessManager()
let bin = try script("cap.sh", "#!/bin/sh\necho out-data\necho err-data 1>&2\nexit 3\n")
let result = try await pm.runCaptured(id: "cap", binary: bin, arguments: [])
XCTAssertTrue(result.stdout.contains("out-data"))
XCTAssertTrue(result.stderr.contains("err-data"))
XCTAssertEqual(result.exitCode, 3)
#expect(result.stdout.contains("out-data"))
#expect(result.stderr.contains("err-data"))
#expect(result.exitCode == 3)
}
func testCapturedRunFastExit() async throws {
@Test func capturedRunFastExit() async throws {
let pm = ProcessManager()
let bin = try script("fast.sh", "#!/bin/sh\nexit 7\n")
let result = try await pm.runCaptured(id: "fast", binary: bin, arguments: [])
XCTAssertEqual(result.exitCode, 7)
XCTAssertEqual(result.stdout, "")
XCTAssertEqual(result.stderr, "")
#expect(result.exitCode == 7)
#expect(result.stdout == "")
#expect(result.stderr == "")
}
func testCapturedRunStderrOnly() async throws {
@Test func capturedRunStderrOnly() async throws {
let pm = ProcessManager()
let bin = try script("stderr-only.sh", "#!/bin/sh\necho 'mock lp failure' 1>&2\nexit 1\n")
let result = try await pm.runCaptured(id: "stderr-only", binary: bin, arguments: [])
XCTAssertEqual(result.exitCode, 1)
XCTAssertEqual(result.stdout, "")
XCTAssertTrue(result.stderr.contains("mock lp failure"))
#expect(result.exitCode == 1)
#expect(result.stdout == "")
#expect(result.stderr.contains("mock lp failure"))
}
func testCapturedRunDoesNotDeadlockOnLargeOutput() async throws {
@Test func capturedRunDoesNotDeadlockOnLargeOutput() async throws {
let pm = ProcessManager()
// 5000 lines each stream exceeds the 64 KiB pipe buffer.
let bin = try script(
@@ -254,29 +249,26 @@ final class ProcessManagerTests: XCTestCase {
"#!/bin/sh\ni=0; while [ $i -lt 5000 ]; do echo \"out-$i\"; echo \"err-$i\" 1>&2; i=$((i+1)); done\n"
)
let result = try await pm.runCaptured(id: "big", binary: bin, arguments: [])
XCTAssertTrue(result.stdout.contains("out-4999"))
XCTAssertTrue(result.stderr.contains("err-4999"))
#expect(result.stdout.contains("out-4999"))
#expect(result.stderr.contains("err-4999"))
}
func testArgyllEnvVarIsSet() async throws {
@Test func argyllEnvVarIsSet() async throws {
let pm = ProcessManager()
let bin = try script("env.sh", "#!/bin/sh\necho \"ANI=$ARGYLL_NOT_INTERACTIVE\"\n")
async let evs = collect(pm, id: "t10")
try await pm.runStreaming(id: "t10", binary: bin, arguments: [])
let t10SawEnv = await evs.contains(.stdout(id: "t10", line: "ANI=1"))
XCTAssertTrue(t10SawEnv)
#expect(await evs.contains(.stdout(id: "t10", line: "ANI=1")))
}
func testUnknownIDStdinThrows() async throws {
@Test func unknownIDStdinThrows() async throws {
let pm = ProcessManager()
await assertAsyncThrows(expectedType: ProcessError.self) {
await #expect(throws: ProcessError.unknownID("nope")) {
try await pm.sendStdin(id: "nope", text: "d\n")
} errorHandler: { error in
XCTAssertEqual(error, .unknownID("nope"))
}
}
func testExplicitPartialFlushEmitsRowColorsJSON() async throws {
@Test func explicitPartialFlushEmitsRowColorsJSON() async throws {
let pm = ProcessManager()
let marker = Self.fixtureDir
.appendingPathComponent("partial-row-ready-\(UUID().uuidString)")
@@ -287,8 +279,7 @@ final class ProcessManagerTests: XCTestCase {
let box = Box()
let observer = observe(pm, id: "t11", into: box)
try await pm.runStreaming(id: "t11", binary: bin, arguments: [marker.path])
let markerReady = await waitForFile(marker)
XCTAssertTrue(markerReady)
#expect(await waitForFile(marker))
// Retry the flush so the pipe-ingest task can win the actor race
// on a loaded host; the first successful flush emits the row.
var flushed = false
@@ -298,25 +289,24 @@ final class ProcessManagerTests: XCTestCase {
flushed = true
break
}
try await Task.sleep(nanoseconds: 20_000_000)
try await Task.sleep(for: .milliseconds(20))
}
XCTAssertTrue(flushed)
#expect(flushed)
await pm.kill(id: "t11")
let sawExit = await waitForExit(in: box)
XCTAssertTrue(sawExit)
#expect(await waitForExit(in: box))
observer.cancel()
let events = box.events
let rows = events.compactMap { e -> String? in
if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) }
return nil
}
XCTAssertEqual(rows, ["{\"row\":9}"])
#expect(rows == ["{\"row\":9}"])
// Prefixed tails must not leak into stdout, even via finalize.
XCTAssertFalse(events.contains(.stdout(id: "t11", line: "ROW_COLORS_JSON: {\"row\":9}")))
XCTAssertEqual(exitCount(in: box), 1)
#expect(!events.contains(.stdout(id: "t11", line: "ROW_COLORS_JSON: {\"row\":9}")))
#expect(exitCount(in: box) == 1)
}
func testUnterminatedRowTailFinalizesAsJSONRow() async throws {
@Test func unterminatedRowTailFinalizesAsJSONRow() async throws {
let pm = ProcessManager()
let bin = try script(
"row-tail.sh",
@@ -325,70 +315,68 @@ final class ProcessManagerTests: XCTestCase {
let box = Box()
let observer = observe(pm, id: "t12", into: box)
try await pm.runStreaming(id: "t12", binary: bin, arguments: [])
let sawExit = await waitForExit(in: box)
XCTAssertTrue(sawExit)
#expect(await waitForExit(in: box))
observer.cancel()
let events = box.events
let rows = events.compactMap { e -> String? in
if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) }
return nil
}
XCTAssertEqual(rows, ["{\"row\":42}"])
XCTAssertFalse(events.contains(.stdout(id: "t12", line: "ROW_COLORS_JSON: {\"row\":42}")))
#expect(rows == ["{\"row\":42}"])
#expect(!events.contains(.stdout(id: "t12", line: "ROW_COLORS_JSON: {\"row\":42}")))
let rowIndex = events.firstIndex {
if case .jsonRow = $0 { return true }; return false
}
let exitIndexes = events.indices.filter {
if case .exit = events[$0] { return true }; return false
}
XCTAssertEqual(exitIndexes.count, 1)
#expect(exitIndexes.count == 1)
if let rowIndex, let exitIndex = exitIndexes.first {
XCTAssertTrue(rowIndex < exitIndex)
#expect(rowIndex < exitIndex)
} else {
XCTFail("expected a jsonRow before the exit event")
Issue.record("expected a jsonRow before the exit event")
}
}
func testFastStreamingExitEmitsExactlyOneExit() async throws {
@Test func fastStreamingExitEmitsExactlyOneExit() async throws {
let pm = ProcessManager()
let bin = try script("fast-stream.sh", "#!/bin/sh\nexit 0\n")
let box = Box()
let observer = observe(pm, id: "t13", into: box)
try await pm.runStreaming(id: "t13", binary: bin, arguments: [])
let sawExit = await waitForExit(in: box)
XCTAssertTrue(sawExit)
#expect(await waitForExit(in: box))
// The grace window must outlast the 2 s finalize watchdog so a
// duplicate emission from it would be observed.
try await Task.sleep(nanoseconds: 2_500_000_000)
try await Task.sleep(for: .milliseconds(2500))
observer.cancel()
XCTAssertEqual(box.events, [.exit(id: "t13", code: 0)])
#expect(box.events == [.exit(id: "t13", code: 0)])
}
func testFastCapturedExitEmitsExactlyOneExit() async throws {
@Test func fastCapturedExitEmitsExactlyOneExit() async throws {
let pm = ProcessManager()
let bin = try script("fast-cap.sh", "#!/bin/sh\nexit 7\n")
let box = Box()
let observer = observe(pm, id: "t14", into: box)
let result = try await pm.runCaptured(id: "t14", binary: bin, arguments: [])
XCTAssertEqual(result.exitCode, 7)
#expect(result.exitCode == 7)
// Both the termination handler and the waitUntilExit watchdog
// resume the same box; give the slower path time to fire.
try await Task.sleep(nanoseconds: 500_000_000)
try await Task.sleep(for: .milliseconds(500))
observer.cancel()
XCTAssertEqual(box.events, [.exit(id: "t14", code: 7)])
#expect(box.events == [.exit(id: "t14", code: 7)])
}
func testCapturedRunSetsArgyllNotInteractive() async throws {
@Test func capturedRunSetsArgyllNotInteractive() async throws {
let pm = ProcessManager()
let bin = try script(
"cap-env.sh",
"#!/bin/sh\necho \"ANI=$ARGYLL_NOT_INTERACTIVE\"\n"
)
let result = try await pm.runCaptured(id: "t15", binary: bin, arguments: [])
XCTAssertEqual(result.stdout, "ANI=1\n")
#expect(result.stdout == "ANI=1\n")
}
func testKillAllTerminatesStreamingAndCapturedChildren() async throws {
@Test func killAllTerminatesStreamingAndCapturedChildren() async throws {
let pm = ProcessManager()
let marker = Self.fixtureDir
.appendingPathComponent("mixed-cap-ready-\(UUID().uuidString)")
@@ -402,88 +390,83 @@ final class ProcessManagerTests: XCTestCase {
let capTask = Task {
try await pm.runCaptured(id: "t17", binary: capBin, arguments: [marker.path])
}
let markerReady = await waitForFile(marker)
XCTAssertTrue(markerReady)
let t16Running = await waitForRunning(pm, id: "t16")
XCTAssertTrue(t16Running)
let t17Running = await waitForRunning(pm, id: "t17")
XCTAssertTrue(t17Running)
let killed = await pm.killAll()
XCTAssertEqual(killed, 2)
#expect(await waitForFile(marker))
#expect(await waitForRunning(pm, id: "t16"))
#expect(await waitForRunning(pm, id: "t17"))
#expect(await pm.killAll() == 2)
_ = try await capTask.value
let streamExit = await waitForExit(in: streamBox)
XCTAssertTrue(streamExit)
let capExit = await waitForExit(in: capBox)
XCTAssertTrue(capExit)
#expect(await waitForExit(in: streamBox))
#expect(await waitForExit(in: capBox))
// Grace window outlasts the streaming finalize watchdog.
try await Task.sleep(nanoseconds: 2_500_000_000)
try await Task.sleep(for: .milliseconds(2500))
streamObserver.cancel()
capObserver.cancel()
let t16RunningAfter = await pm.isRunning("t16")
XCTAssertFalse(t16RunningAfter)
let t17RunningAfter = await pm.isRunning("t17")
XCTAssertFalse(t17RunningAfter)
XCTAssertEqual(exitCount(in: streamBox), 1)
XCTAssertEqual(exitCount(in: capBox), 1)
#expect(!(await pm.isRunning("t16")))
#expect(!(await pm.isRunning("t17")))
#expect(exitCount(in: streamBox) == 1)
#expect(exitCount(in: capBox) == 1)
}
}
final class ProcessLineDecoderTests: XCTestCase {
func testSplitsAcrossChunkBoundaries() {
@Suite("ProcessLineDecoder")
struct ProcessLineDecoderTests {
@Test func splitsAcrossChunkBoundaries() {
var d = ProcessLineDecoder()
XCTAssertEqual(d.feed(Data("he".utf8)), [])
XCTAssertEqual(d.feed(Data("llo\nwor".utf8)), ["hello"])
XCTAssertEqual(d.feed(Data("ld\n".utf8)), ["world"])
XCTAssertNil(d.finish())
#expect(d.feed(Data("he".utf8)) == [])
#expect(d.feed(Data("llo\nwor".utf8)) == ["hello"])
#expect(d.feed(Data("ld\n".utf8)) == ["world"])
#expect(d.finish() == nil)
}
func testCrlfIsStripped() {
@Test func crlfIsStripped() {
var d = ProcessLineDecoder()
XCTAssertEqual(d.feed(Data("a\r\nb\r\n".utf8)), ["a", "b"])
#expect(d.feed(Data("a\r\nb\r\n".utf8)) == ["a", "b"])
}
func testFinishReturnsRemainder() {
@Test func finishReturnsRemainder() {
var d = ProcessLineDecoder()
_ = d.feed(Data("x".utf8))
XCTAssertEqual(d.finish(), "x")
XCTAssertNil(d.finish())
#expect(d.finish() == "x")
#expect(d.finish() == nil)
}
}
final class JSONAccumulatorTests: XCTestCase {
func testMultilinePrettyJSON() {
@Suite("JSONAccumulator")
struct JSONAccumulatorTests {
@Test func multilinePrettyJSON() {
var acc = JSONAccumulator()
XCTAssertNil(acc.feed(line: "{"))
XCTAssertNil(acc.feed(line: " \"k\": 1"))
#expect(acc.feed(line: "{") == nil)
#expect(acc.feed(line: " \"k\": 1") == nil)
let done = acc.feed(line: "}")
XCTAssertNotNil(done)
#expect(done != nil)
let obj = try? JSONSerialization.jsonObject(with: done!) as? [String: Int]
XCTAssertEqual(obj?["k"], 1)
#expect(obj?["k"] == 1)
}
func testNonJSONLinesIgnored() {
@Test func nonJSONLinesIgnored() {
var acc = JSONAccumulator()
XCTAssertNil(acc.feed(line: "Reading instrument..."))
XCTAssertNil(acc.feed(line: "still text"))
XCTAssertNil(acc.completeData)
#expect(acc.feed(line: "Reading instrument...") == nil)
#expect(acc.feed(line: "still text") == nil)
#expect(acc.completeData == nil)
}
func testDecodeTyped() {
@Test func decodeTyped() {
struct Doc: Decodable { let n: Int }
var acc = JSONAccumulator()
// Split so the doc completes on the second feed.
XCTAssertNil(acc.feed(line: "{\"n\":"))
#expect(acc.feed(line: "{\"n\":") == nil)
let data = acc.feed(line: "7}")
XCTAssertNotNil(data)
#expect(data != nil)
let doc = data.flatMap { try? JSONDecoder().decode(Doc.self, from: $0) }
XCTAssertEqual(doc?.n, 7)
XCTAssertTrue(acc.isEmpty)
#expect(doc?.n == 7)
#expect(acc.isEmpty)
}
}
final class LogSanitizerTests: XCTestCase {
func testHomeIsRewritten() {
@Suite("LogSanitizer")
struct LogSanitizerTests {
@Test func homeIsRewritten() {
let path = "\(NSHomeDirectory())/Documents/foo.ti1"
XCTAssertEqual(LogSanitizer.sanitize(path), "~/Documents/foo.ti1")
#expect(LogSanitizer.sanitize(path) == "~/Documents/foo.ti1")
}
}
@@ -1,66 +0,0 @@
import Foundation
import XCTest
@testable import ICCery
/// Direct contracts for the shared logged-run helper (issue #80).
///
/// `runLogged` owns the running-flag transition (`false true false`)
/// and the log-reset decision; these tests pin both sides of the
/// contract plus the coalesced `@MainActor` log hop.
@MainActor
final class ProcessRunSupportTests: XCTestCase {
private struct SentinelError: Error {}
func testSuccessTransitions() async throws {
var running: [Bool] = []
var resets = 0
var received: [String] = []
let result = try await ProcessRunSupport.runLogged(
setRunning: { running.append($0) },
resetLog: { resets += 1 },
onLog: { batch in
// MainActor.assertIsolated() needs Swift 5.9; the runner is
// on Xcode 14.2 (Swift 5.7) (#115).
XCTAssertTrue(Thread.isMainThread)
received.append(contentsOf: batch)
}
) { onLog in
onLog(["alpha", "beta"])
return 42
}
XCTAssertEqual(result, 42)
XCTAssertEqual(running, [true, false])
XCTAssertEqual(resets, 1)
// The sink hops back through a main-actor Task; yield until the
// coalesced batch lands.
for _ in 0..<200 where received.isEmpty {
try await Task.sleep(nanoseconds: 10_000_000)
}
XCTAssertEqual(received, ["alpha", "beta"])
}
func testFailureTransitions() async throws {
var running: [Bool] = []
var resets = 0
do {
_ = try await ProcessRunSupport.runLogged(
setRunning: { running.append($0) },
resetLog: { resets += 1 },
onLog: { _ in }
) { _ -> Int in
throw SentinelError()
}
XCTFail("Expected runLogged to rethrow")
} catch is SentinelError {
// Expected path.
}
XCTAssertEqual(running, [true, false])
XCTAssertEqual(resets, 1)
}
}
@@ -1,15 +1,17 @@
import Foundation
import XCTest
import Testing
@testable import ICCeryCore
final class ProfcheckArgsTests: XCTestCase {
@Suite("ProfcheckArgs")
struct ProfcheckArgsTests {
func testArgv() throws {
@Test("Hard-coded argv")
func argv() throws {
let config = ProfcheckConfig(
ti3URL: URL(fileURLWithPath: "/tmp/target.ti3"),
iccURL: URL(fileURLWithPath: "/tmp/target.icc")
)
let args = try ProfcheckArgs.build(config: config)
XCTAssertEqual(args, ["-v", "-k", "-s", "-u", "/tmp/target.ti3", "/tmp/target.icc"])
#expect(args == ["-v", "-k", "-s", "-u", "/tmp/target.ti3", "/tmp/target.icc"])
}
}
@@ -1,39 +1,43 @@
import Foundation
import XCTest
import Testing
@testable import ICCeryCore
final class ProfcheckParserTests: XCTestCase {
@Suite("ProfcheckParser")
struct ProfcheckParserTests {
func testJsonReport() {
@Test("Prefers JSON report with de2000 keys")
func jsonReport() {
let output = """
No of test patches = 52
{"event": "report", "peak_de2000": 2.41, "avg_de2000": 0.85, "rms": 1.02}
Profile check complete, errors(CIEDE2000): max. = 9.99, avg. = 9.99, RMS = 9.99
"""
let report = ProfcheckParser.parse(output)
XCTAssertEqual(report.isValid, true)
XCTAssertEqual(report.patchCount, 52)
XCTAssertEqual(report.avgDE, 0.85)
XCTAssertEqual(report.maxDE, 2.41)
XCTAssertEqual(report.rmsDE, 1.02)
XCTAssertEqual(report.status, .excellent)
#expect(report.isValid == true)
#expect(report.patchCount == 52)
#expect(report.avgDE == 0.85)
#expect(report.maxDE == 2.41)
#expect(report.rmsDE == 1.02)
#expect(report.status == .excellent)
}
func testLegacyText() {
@Test("Falls back to legacy text")
func legacyText() {
let output = """
No of test patches = 120
Profile check complete, errors(CIEDE2000): max. = 3.50, avg. = 1.80, RMS = 0.95
"""
let report = ProfcheckParser.parse(output)
XCTAssertEqual(report.isValid, true)
XCTAssertEqual(report.patchCount, 120)
XCTAssertEqual(report.avgDE, 1.80)
XCTAssertEqual(report.maxDE, 3.50)
XCTAssertEqual(report.rmsDE, 0.95)
XCTAssertEqual(report.status, .good)
#expect(report.isValid == true)
#expect(report.patchCount == 120)
#expect(report.avgDE == 1.80)
#expect(report.maxDE == 3.50)
#expect(report.rmsDE == 0.95)
#expect(report.status == .good)
}
func testRegexFallback() {
@Test("Broad regex fallback")
func regexFallback() {
let output = """
No of test patches = 10
avg = 4.25
@@ -41,25 +45,27 @@ final class ProfcheckParserTests: XCTestCase {
rms = 2.30
"""
let report = ProfcheckParser.parse(output)
XCTAssertEqual(report.isValid, true)
XCTAssertEqual(report.avgDE, 4.25)
XCTAssertEqual(report.maxDE, 6.10)
XCTAssertEqual(report.rmsDE, 2.30)
XCTAssertEqual(report.status, .poor)
#expect(report.isValid == true)
#expect(report.avgDE == 4.25)
#expect(report.maxDE == 6.10)
#expect(report.rmsDE == 2.30)
#expect(report.status == .poor)
}
func testUnparseable() {
@Test("Unparseable output warns, not zeros")
func unparseable() {
let output = "some random text without metrics"
let report = ProfcheckParser.parse(output)
XCTAssertEqual(report.isValid, false)
XCTAssertNotNil(report.warning)
XCTAssertNil(report.avgDE)
#expect(report.isValid == false)
#expect(report.warning != nil)
#expect(report.avgDE == nil)
}
func testStatusBands() {
XCTAssertEqual(VerificationStatus.from(avgDE: 0.5), .excellent)
XCTAssertEqual(VerificationStatus.from(avgDE: 1.5), .good)
XCTAssertEqual(VerificationStatus.from(avgDE: 2.5), .acceptable)
XCTAssertEqual(VerificationStatus.from(avgDE: 4.0), .poor)
@Test("Status bands")
func statusBands() {
#expect(VerificationStatus.from(avgDE: 0.5) == .excellent)
#expect(VerificationStatus.from(avgDE: 1.5) == .good)
#expect(VerificationStatus.from(avgDE: 2.5) == .acceptable)
#expect(VerificationStatus.from(avgDE: 4.0) == .poor)
}
}
@@ -1,5 +1,5 @@
import Foundation
import XCTest
import Testing
@testable import ICCeryCore
/// A `FileManager` subclass that reports a temporary directory as the
@@ -18,7 +18,8 @@ private final class TestFileManager: FileManager {
}
}
final class ProfileInstallerTests: XCTestCase {
@Suite("ProfileInstaller")
struct ProfileInstallerTests {
private func makeTempDir() throws -> URL {
let fm = FileManager.default
@@ -38,7 +39,8 @@ final class ProfileInstallerTests: XCTestCase {
return url
}
func testUserInstall() throws {
@Test("Installs .icc to user ColorSync folder")
func userInstall() throws {
let fm = FileManager.default
let tmp = try makeTempDir()
let testFM = TestFileManager(home: tmp)
@@ -49,14 +51,15 @@ final class ProfileInstallerTests: XCTestCase {
fileManager: testFM
)
XCTAssertTrue(result.registered)
XCTAssertFalse(result.overwritten)
XCTAssertFalse(result.renamed)
XCTAssertTrue(result.destPath.hasSuffix("test.icc"))
XCTAssertTrue(fm.fileExists(atPath: result.destPath))
#expect(result.registered)
#expect(!result.overwritten)
#expect(!result.renamed)
#expect(result.destPath.hasSuffix("test.icc"))
#expect(fm.fileExists(atPath: result.destPath))
}
func testOverwriteSucceeds() throws {
@Test("Overwrite succeeds and replaces the existing file")
func overwriteSucceeds() throws {
let fm = FileManager.default
let tmp = try makeTempDir()
let testFM = TestFileManager(home: tmp)
@@ -67,7 +70,7 @@ final class ProfileInstallerTests: XCTestCase {
config: InstallProfileConfig(sourceURL: source),
fileManager: testFM
)
XCTAssertFalse(first.overwritten)
#expect(!first.overwritten)
// Change the source contents.
let newBytes: [UInt8] = (0..<256).map { UInt8(($0 + 100) % 256) }
@@ -84,14 +87,15 @@ final class ProfileInstallerTests: XCTestCase {
fileManager: testFM
)
XCTAssertTrue(second.overwritten)
XCTAssertFalse(second.renamed)
XCTAssertTrue(fm.fileExists(atPath: second.destPath))
#expect(second.overwritten)
#expect(!second.renamed)
#expect(fm.fileExists(atPath: second.destPath))
let installed = try Data(contentsOf: URL(fileURLWithPath: second.destPath))
XCTAssertEqual(Array(installed), newBytes)
#expect(Array(installed) == newBytes)
}
func testPreservesIcmExtension() throws {
@Test("Preserves .icm source extension")
func preservesIcmExtension() throws {
let tmp = try makeTempDir()
let testFM = TestFileManager(home: tmp)
let source = try makeSource(at: tmp, name: "m5_profile.icm")
@@ -101,11 +105,12 @@ final class ProfileInstallerTests: XCTestCase {
fileManager: testFM
)
XCTAssertEqual(URL(fileURLWithPath: result.destPath).pathExtension, "icm")
XCTAssertTrue(result.destPath.hasSuffix("m5_profile.icm"))
#expect(URL(fileURLWithPath: result.destPath).pathExtension == "icm")
#expect(result.destPath.hasSuffix("m5_profile.icm"))
}
func testRejectsParentTraversal() throws {
@Test("Rejects parent traversal in source path")
func rejectsParentTraversal() throws {
let fm = FileManager.default
let tmp = try makeTempDir()
@@ -120,19 +125,20 @@ final class ProfileInstallerTests: XCTestCase {
let sourceURL = tmp
.appendingPathComponent("..")
.appendingPathComponent(naughtyName)
XCTAssertTrue(fm.fileExists(atPath: sourceURL.path))
#expect(fm.fileExists(atPath: sourceURL.path))
do {
_ = try ProfileInstaller.install(config: InstallProfileConfig(sourceURL: sourceURL))
XCTFail("Expected unsafeStem error")
Issue.record("Expected unsafeStem error")
} catch let error as ProfileInstallError {
if case .unsafeStem = error { } else { XCTFail("Expected unsafeStem, got \(error)") }
if case .unsafeStem = error { } else { Issue.record("Expected unsafeStem, got \(error)") }
} catch {
XCTFail("Unexpected error type: \(error)")
Issue.record("Unexpected error type: \(error)")
}
}
func testAllowsDoubleDotStem() throws {
@Test("Allows stems with consecutive dots like foo..bar")
func allowsDoubleDotStem() throws {
let fm = FileManager.default
let tmp = try makeTempDir()
let testFM = TestFileManager(home: tmp)
@@ -143,22 +149,23 @@ final class ProfileInstallerTests: XCTestCase {
fileManager: testFM
)
XCTAssertTrue(result.destPath.hasSuffix("foo..bar.icc"))
XCTAssertTrue(fm.fileExists(atPath: result.destPath))
#expect(result.destPath.hasSuffix("foo..bar.icc"))
#expect(fm.fileExists(atPath: result.destPath))
}
func testRejectsSmallSource() throws {
@Test("Rejects source files that are too small")
func rejectsSmallSource() throws {
let tmp = try makeTempDir()
let source = tmp.appendingPathComponent("tiny.icc")
try Data(repeating: 0, count: 64).write(to: source)
do {
_ = try ProfileInstaller.install(config: InstallProfileConfig(sourceURL: source))
XCTFail("Expected sourceTooSmall error")
Issue.record("Expected sourceTooSmall error")
} catch let error as ProfileInstallError {
if case .sourceTooSmall = error { } else { XCTFail("Expected sourceTooSmall, got \(error)") }
if case .sourceTooSmall = error { } else { Issue.record("Expected sourceTooSmall, got \(error)") }
} catch {
XCTFail("Unexpected error type: \(error)")
Issue.record("Unexpected error type: \(error)")
}
}
}
@@ -1,406 +0,0 @@
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)
}
}
@@ -1,134 +0,0 @@
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)
}
}
+57 -54
View File
@@ -1,5 +1,5 @@
import Testing
import Foundation
import XCTest
@testable import ICCeryCore
private func tempStoreURL() -> URL {
@@ -8,90 +8,92 @@ private func tempStoreURL() -> URL {
.appendingPathComponent("settings.json")
}
final class AppSettingsTests: XCTestCase {
func testDefaults() {
@Suite("AppSettings")
struct AppSettingsTests {
@Test func defaults() {
let s = AppSettings.default
XCTAssertNil(s.argyllBinaryDir)
XCTAssertNil(s.defaultInstrument)
XCTAssertNil(s.logLevel)
XCTAssertEqual(s.deltaEGoodMax, 2.0)
XCTAssertEqual(s.deltaEWarningMax, 5.0)
XCTAssertTrue(s.customPresets.isEmpty)
XCTAssertFalse(s.enableI1Pro2Leds)
XCTAssertEqual(s.calibrationStaleDays, 30)
XCTAssertEqual(s.defaultInstallLocation, .user)
XCTAssertTrue(s.askBeforeOverwriteProfile)
XCTAssertFalse(s.openColorPanelAfterInstall)
XCTAssertTrue(s.isValid)
#expect(s.argyllBinaryDir == nil)
#expect(s.defaultInstrument == nil)
#expect(s.logLevel == nil)
#expect(s.deltaEGoodMax == 2.0)
#expect(s.deltaEWarningMax == 5.0)
#expect(s.customPresets.isEmpty)
#expect(!s.enableI1Pro2Leds)
#expect(s.calibrationStaleDays == 30)
#expect(s.defaultInstallLocation == .user)
#expect(s.askBeforeOverwriteProfile)
#expect(!s.openColorPanelAfterInstall)
#expect(s.isValid)
}
func testNegativeThresholds() {
@Test func negativeThresholds() {
var s = AppSettings.default
s.deltaEGoodMax = -1
XCTAssertEqual(s.validate(), [AppSettings.errorNegativeDeltaE])
#expect(s.validate() == [AppSettings.errorNegativeDeltaE])
s.deltaEGoodMax = 2.0
s.deltaEWarningMax = -0.5
// -0.5 < 0 negative error; good(2.0) >= warn(-0.5) order error too
XCTAssertTrue(s.validate() == [
#expect(s.validate() == [
AppSettings.errorNegativeDeltaE,
AppSettings.errorThresholdOrder,
])
}
func testGoodMustBeStrictlyLessThanWarning() {
@Test func goodMustBeStrictlyLessThanWarning() {
var s = AppSettings.default
s.deltaEGoodMax = 5.0
XCTAssertEqual(s.validate(), [AppSettings.errorThresholdOrder])
#expect(s.validate() == [AppSettings.errorThresholdOrder])
s.deltaEGoodMax = 6.0
XCTAssertEqual(s.validate(), [AppSettings.errorThresholdOrder])
#expect(s.validate() == [AppSettings.errorThresholdOrder])
s.deltaEGoodMax = 4.9
XCTAssertTrue(s.isValid)
#expect(s.isValid)
}
func testSnakeCaseKeys() throws {
@Test func snakeCaseKeys() throws {
let s = AppSettings.default
let data = try JSONEncoder().encode(s)
let json = String(data: data, encoding: .utf8)!
XCTAssertTrue(json.contains("\"delta_e_good_max\""))
XCTAssertTrue(json.contains("\"default_install_location\""))
XCTAssertTrue(json.contains("\"enable_i1pro2_leds\""))
#expect(json.contains("\"delta_e_good_max\""))
#expect(json.contains("\"default_install_location\""))
#expect(json.contains("\"enable_i1pro2_leds\""))
}
}
final class SettingsStoreTests: XCTestCase {
func testRoundTrip() throws {
@Suite("SettingsStore")
struct SettingsStoreTests {
@Test func roundTrip() throws {
let url = tempStoreURL()
let store = SettingsStore(fileURL: url)
var s = AppSettings.default
s.deltaEGoodMax = 1.5
s.defaultInstrument = "p3"
try store.save(s)
XCTAssertEqual(store.load(), s)
#expect(store.load() == s)
}
func testCorruptJsonFallsBackToDefaults() throws {
@Test func corruptJsonFallsBackToDefaults() throws {
let url = tempStoreURL()
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
)
try "{ not json".write(to: url, atomically: true, encoding: .utf8)
XCTAssertEqual(SettingsStore(fileURL: url).load(), .default)
#expect(SettingsStore(fileURL: url).load() == .default)
}
func testMissingFileReturnsDefaults() {
XCTAssertEqual(SettingsStore(fileURL: tempStoreURL()).load(), .default)
@Test func missingFileReturnsDefaults() {
#expect(SettingsStore(fileURL: tempStoreURL()).load() == .default)
}
func testInvalidSettingsNotPersisted() throws {
@Test func invalidSettingsNotPersisted() throws {
let url = tempStoreURL()
let store = SettingsStore(fileURL: url)
var s = AppSettings.default
s.deltaEGoodMax = 9.0 // >= warning 5.0
XCTAssertThrowsError(try store.save(s)) { error in XCTAssertTrue(error is SettingsStore.SettingsError) }
XCTAssertFalse(FileManager.default.fileExists(atPath: url.path))
#expect(throws: SettingsStore.SettingsError.self) { try store.save(s) }
#expect(!FileManager.default.fileExists(atPath: url.path))
}
func testInvalidSaveOverValidFilePreservesBytesAndPostsNothing() throws {
@Test func invalidSaveOverValidFilePreservesBytesAndPostsNothing() throws {
let url = tempStoreURL()
let store = SettingsStore(fileURL: url)
var valid = AppSettings.default
@@ -107,13 +109,13 @@ final class SettingsStoreTests: XCTestCase {
var invalid = AppSettings.default
invalid.deltaEGoodMax = 9.0
XCTAssertThrowsError(try store.save(invalid)) { error in XCTAssertTrue(error is SettingsStore.SettingsError) }
XCTAssertEqual(try Data(contentsOf: url), originalBytes)
XCTAssertFalse(fired)
XCTAssertEqual(store.load(), valid)
#expect(throws: SettingsStore.SettingsError.self) { try store.save(invalid) }
#expect(try Data(contentsOf: url) == originalBytes)
#expect(!fired)
#expect(store.load() == valid)
}
func testSavePostsNotification() async throws {
@Test func savePostsNotification() async throws {
let url = tempStoreURL()
let store = SettingsStore(fileURL: url)
var fired = false
@@ -122,11 +124,12 @@ final class SettingsStoreTests: XCTestCase {
) { _ in fired = true }
defer { NotificationCenter.default.removeObserver(token) }
try store.save(.default)
XCTAssertTrue(fired)
#expect(fired)
}
}
final class LogSinkTests: XCTestCase {
@Suite("LogSink")
struct LogSinkTests {
private func tempLog() -> (URL, LogSink) {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-log-\(UUID().uuidString)")
@@ -134,26 +137,26 @@ final class LogSinkTests: XCTestCase {
return (url, LogSink(fileURL: url))
}
func testWritesFormattedLines() {
@Test func writesFormattedLines() {
let (url, sink) = tempLog()
sink.setLevel(.debug)
sink.write(level: .info, category: "test", message: "hello")
let content = (try? String(contentsOf: url, encoding: .utf8)) ?? ""
XCTAssertTrue(content.contains("[INFO] test: hello"))
#expect(content.contains("[INFO] test: hello"))
}
func testLevelFilteringIsLive() {
@Test func levelFilteringIsLive() {
let (url, sink) = tempLog()
sink.setLevel(.error)
sink.write(level: .info, category: "t", message: "hidden")
sink.setLevel(.info) // runtime change, no restart (#158)
sink.write(level: .info, category: "t", message: "shown")
let content = (try? String(contentsOf: url, encoding: .utf8)) ?? ""
XCTAssertFalse(content.contains("hidden"))
XCTAssertTrue(content.contains("shown"))
#expect(!content.contains("hidden"))
#expect(content.contains("shown"))
}
func testRotatesAt5MiBKeeping5Segments() throws {
@Test func rotatesAt5MiBKeeping5Segments() throws {
let (url, sink) = tempLog()
sink.setLevel(.trace)
// Pre-fill the active log just under the cap, then cross it.
@@ -164,20 +167,20 @@ final class LogSinkTests: XCTestCase {
try big.write(to: url, atomically: true, encoding: .utf8)
sink.write(level: .info, category: "t", message: "trigger rotation")
XCTAssertTrue(FileManager.default.fileExists(
#expect(FileManager.default.fileExists(
atPath: url.appendingPathExtension("1").path
))
// Active log is small again.
let size = try FileManager.default.attributesOfItem(
atPath: url.path
)[.size] as? UInt64
XCTAssertTrue((size ?? 0) < 1024)
#expect((size ?? 0) < 1024)
}
func testTailExcerptCaps() throws {
@Test func tailExcerptCaps() throws {
let (url, sink) = tempLog()
sink.setLevel(.debug)
sink.write(level: .info, category: "t", message: "line")
XCTAssertTrue(sink.tailExcerpt(maxBytes: 8).count <= 8)
#expect(sink.tailExcerpt(maxBytes: 8).count <= 8)
}
}
@@ -1,46 +0,0 @@
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"))
}
}
}

Some files were not shown because too many files have changed in this diff Show More