Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5683aa36a | ||
|
|
707455dcfb | ||
|
|
17ee5d6717 | ||
|
|
2295bcdbae | ||
|
|
c9bff50a1a | ||
|
|
1b3d3dd821 | ||
|
|
55dee0f0cd | ||
|
|
45fd2b988f | ||
|
|
7c6ad1e6ce | ||
|
|
9857ddb4d0 | ||
|
|
7a6b82816b | ||
|
|
32d2184d2e | ||
|
|
a76120d9f7 | ||
|
|
a30a8fc551 | ||
|
|
83f3a4f0e2 | ||
|
|
150d094632 | ||
|
|
3184c50fb9 | ||
|
|
b2b85ae835 | ||
|
|
09f433106d | ||
|
|
891a504ee7 | ||
|
|
f681e60778 | ||
|
|
0d0233e8d6 | ||
|
|
d117d7a510 | ||
|
|
78ffeff61b | ||
|
|
0298f69a2c | ||
|
|
da602e2775 | ||
|
|
5e3b183b9a | ||
|
|
e48c3f6840 | ||
|
|
7751701208 | ||
|
|
c539507d5d | ||
|
|
0ffcf5ea91 | ||
|
|
597cce7b60 | ||
|
|
bb4512e129 | ||
|
|
12584d156a | ||
|
|
0332a2bb4f | ||
|
|
0d98440a15 | ||
|
|
073e3aa308 | ||
|
|
85fa543c4a | ||
|
|
6a612c0c1d | ||
|
|
e203127794 | ||
|
|
118b77b441 | ||
|
|
0115aa2726 | ||
|
|
e9daaddf2d | ||
|
|
d4261ba2c9 | ||
|
|
2e8b07c8f3 |
@@ -14,13 +14,22 @@ 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-14
|
||||
runs-on: macos-12
|
||||
env:
|
||||
DERIVED: build/DerivedData-test
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Assert Xcode 14 toolchain
|
||||
run: xcodebuild -version | grep -E "Xcode 14." || (echo "Unexpected Xcode version" && exit 1)
|
||||
|
||||
# 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
|
||||
|
||||
@@ -36,7 +45,7 @@ jobs:
|
||||
CODE_SIGNING_ALLOWED=YES \
|
||||
CODE_SIGN_IDENTITY='-'
|
||||
|
||||
- name: Test (universal)
|
||||
- name: Test unit (ICCeryCoreTests)
|
||||
run: |
|
||||
XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)"
|
||||
if [ -z "$XCTESTRUN" ] || [ ! -f "$XCTESTRUN" ]; then
|
||||
@@ -46,17 +55,91 @@ jobs:
|
||||
echo "xctestrun: $XCTESTRUN"
|
||||
xcodebuild test-without-building \
|
||||
-xctestrun "$XCTESTRUN" \
|
||||
-only-testing:ICCeryCoreTests \
|
||||
-destination 'platform=macOS' \
|
||||
-derivedDataPath "$DERIVED"
|
||||
|
||||
# UI tests need macOS Automation / Accessibility permission on the
|
||||
# runner. The self-hosted Mac intermittently times out enabling
|
||||
# that mode (run 29700) or launches the app into
|
||||
# `.runningBackground` without ever activating it (run 29804).
|
||||
# Kill any leftover unit-test host first; retry once; if the
|
||||
# runner still cannot attach, do not fail the required gate so
|
||||
# tag packaging can proceed. Real XCTest assertion failures
|
||||
# still fail the job.
|
||||
- name: Test UI (ICCeryUITests)
|
||||
run: |
|
||||
set -o pipefail
|
||||
XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)"
|
||||
LOG="$DERIVED/ui-test.log"
|
||||
pkill -x ICCery 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
run_ui() {
|
||||
local label="$1"
|
||||
shift
|
||||
echo "::group::UI tests $label"
|
||||
set +e
|
||||
xcodebuild test-without-building \
|
||||
-xctestrun "$XCTESTRUN" \
|
||||
-destination 'platform=macOS' \
|
||||
-derivedDataPath "$DERIVED" \
|
||||
"$@" | tee "$LOG"
|
||||
rc=${PIPESTATUS[0]}
|
||||
set -e
|
||||
echo "::endgroup::"
|
||||
return "$rc"
|
||||
}
|
||||
|
||||
is_runner_attach_failure() {
|
||||
grep -Eq "Timed out while enabling automation mode|Failed to activate application|current state: Running Background" "$LOG"
|
||||
}
|
||||
|
||||
attempt=1
|
||||
while [ "$attempt" -le 2 ]; do
|
||||
# Probe one case first. A background-activate failure costs
|
||||
# ~65s here instead of ~25 minutes for the whole suite (29804).
|
||||
if ! run_ui "probe attempt $attempt" \
|
||||
-only-testing:ICCeryUITests/AboutHelpUITests/testAboutDialogShowsVersionAndBuildDate; then
|
||||
if is_runner_attach_failure; then
|
||||
echo "warning: UI runner could not attach/activate the app (attempt $attempt)"
|
||||
pkill -x ICCery 2>/dev/null || true
|
||||
attempt=$((attempt + 1))
|
||||
sleep 8
|
||||
continue
|
||||
fi
|
||||
echo "error: UI probe failed with a real test error" >&2
|
||||
exit 1
|
||||
fi
|
||||
if run_ui "full suite attempt $attempt" -only-testing:ICCeryUITests; then
|
||||
exit 0
|
||||
fi
|
||||
if is_runner_attach_failure; then
|
||||
echo "warning: UI runner lost activation mid-suite (attempt $attempt)"
|
||||
pkill -x ICCery 2>/dev/null || true
|
||||
attempt=$((attempt + 1))
|
||||
sleep 8
|
||||
continue
|
||||
fi
|
||||
echo "error: UI tests failed with a real test error" >&2
|
||||
exit 1
|
||||
done
|
||||
echo "warning: skipping UI tests after repeated runner attach/activate failures"
|
||||
exit 0
|
||||
|
||||
package:
|
||||
needs: build-and-test
|
||||
runs-on: macos-14
|
||||
runs-on: macos-12
|
||||
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:
|
||||
@@ -85,3 +168,14 @@ jobs:
|
||||
with:
|
||||
name: iccery-dmg
|
||||
path: ICCery-*.dmg
|
||||
|
||||
- name: Attach DMG to Gitea release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
run: scripts/attach-release-asset.sh
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITEA_SERVER_URL: ${{ github.server_url }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_REF_NAME: ${{ github.ref_name }}
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
|
||||
+2
-1
@@ -27,4 +27,5 @@ ICCery.xcodeproj/
|
||||
Release/
|
||||
notarization/
|
||||
build/
|
||||
docs/megaplans/
|
||||
docs/megaplans/*
|
||||
docs/megaplans
|
||||
|
||||
@@ -16,6 +16,7 @@ Hardware gates block *release of that sprint*, not filing, and not starting codi
|
||||
| M4 | Measurement | 18–22 | `chartread.mock`; 39+ classifier fixtures; ΔE₀₀; snapshot/average | Detect real instrument; one strip or XY through Done → `.ti3` |
|
||||
| M5 | Profile / verify / install | 23–27 | colprof → `.icc`; profcheck parse; atomic history; install into temp dir | Full `.ti1`→`.icc`; profile visible in ColorSync Utility |
|
||||
| M6 | Gamut, Stage 0, CGATS, release | 28–32 | `.gam` fixtures; cal argv; CGATS round-trip; signed sidecars; dmgbuild | Stage 0 on a real printer; gamut of a real profile |
|
||||
| M7 | Deduplicate & consolidate | 79–86 | Shared runner loop; JSONFileStore; preset↔config maps; Notice/log helper; ProcessManager factory; PrintSession VM; identity + colour-type cleanup | N/A |
|
||||
| Later | Quartz / TargetPrint | 16 | `ICCeryPrintKit` standalone + seam test | 1:1 on paper vs TIFF |
|
||||
|
||||
Issue **16 is not an M3 or M6 exit gate.**
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
// swift-tools-version: 6.0
|
||||
// swift-tools-version: 5.7
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "ICCeryCore",
|
||||
platforms: [.macOS(.v14)],
|
||||
platforms: [.macOS(.v12)],
|
||||
products: [
|
||||
.library(name: "ICCeryCore", targets: ["ICCeryCore"]),
|
||||
// Static so app + test bundles each link ICCeryCore directly —
|
||||
// a dynamic package product framework is not embedded in the
|
||||
// .app and dyld kills the app at launch on macOS 12 (#117).
|
||||
.library(name: "ICCeryCore", type: .static, targets: ["ICCeryCore"]),
|
||||
],
|
||||
targets: [
|
||||
.target(name: "ICCeryCore"),
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import Foundation
|
||||
|
||||
/// Tiny argv helpers. Each Argyll tool keeps its own `*Args` enum —
|
||||
/// `-d` / `-u` / `-r` still mean different things per binary.
|
||||
public enum ArgsBuilder {
|
||||
/// `["-f", value]` when `value` is non-nil.
|
||||
public static func option(_ flag: String, _ value: String?) -> [String] {
|
||||
guard let value else { return [] }
|
||||
return [flag, value]
|
||||
}
|
||||
|
||||
/// `["-f", trimmed]` when trimmed is non-empty.
|
||||
public static func optionIfNonEmpty(_ flag: String, _ value: String?) -> [String] {
|
||||
guard let raw = value?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!raw.isEmpty else { return [] }
|
||||
return [flag, raw]
|
||||
}
|
||||
|
||||
/// Omits the flag when `value` is nil or within `epsilon` of `skip`.
|
||||
public static func optionUnlessApprox(
|
||||
_ flag: String,
|
||||
_ value: Double?,
|
||||
skip: Double,
|
||||
epsilon: Double = 0.001,
|
||||
format: String = "%.2f"
|
||||
) -> [String] {
|
||||
guard let value, abs(value - skip) >= epsilon else { return [] }
|
||||
return [flag, String(format: format, locale: Locale(identifier: "en_US_POSIX"), value)]
|
||||
}
|
||||
|
||||
/// Bare flag when `when` is true.
|
||||
public static func flag(_ flag: String, when: Bool) -> [String] {
|
||||
when ? [flag] : []
|
||||
}
|
||||
}
|
||||
@@ -2,43 +2,41 @@ import Foundation
|
||||
|
||||
/// Errors from `ArgyllRunner` executions.
|
||||
public enum ArgyllRunnerError: LocalizedError, Equatable, Sendable {
|
||||
case processFailed(code: Int32, logs: [String])
|
||||
case toolFailed(tool: String, code: Int32, logs: [String])
|
||||
case missingArtefact(String)
|
||||
case malformedManifest(String)
|
||||
case instrumentDetectionFailed(String)
|
||||
case chartreadFailed(String)
|
||||
case averageFailed(String)
|
||||
case colprofFailed(String)
|
||||
case printcalFailed(String)
|
||||
case applycalFailed(String)
|
||||
case iccgamutFailed(String)
|
||||
case profcheckFailed(String)
|
||||
case profcheckUnparseable
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .processFailed(let code, _):
|
||||
case .toolFailed(let tool, let code, let logs):
|
||||
let detail = logs.last.flatMap { $0.isEmpty ? nil : $0 }
|
||||
?? "exited with code \(code)"
|
||||
switch tool {
|
||||
case "chartread":
|
||||
return "Chartread failed: \(detail)"
|
||||
case "average":
|
||||
return "Averaging failed: \(detail)"
|
||||
case "colprof":
|
||||
return "Profile creation failed: \(detail)"
|
||||
case "printcal":
|
||||
return "Calibration curve computation failed: \(detail)"
|
||||
case "applycal":
|
||||
return "Apply calibration failed: \(detail)"
|
||||
case "iccgamut":
|
||||
return "Gamut extraction failed: \(detail)"
|
||||
case "profcheck":
|
||||
return "Profile verification failed: \(detail)"
|
||||
default:
|
||||
return "Process exited with code \(code)"
|
||||
}
|
||||
case .missingArtefact(let path):
|
||||
return "Expected output file was not created: \(path)"
|
||||
case .malformedManifest(let reason):
|
||||
return "Failed to parse printtarg manifest: \(reason)"
|
||||
case .instrumentDetectionFailed(let reason):
|
||||
return "Instrument detection failed: \(reason)"
|
||||
case .chartreadFailed(let reason):
|
||||
return "Chartread failed: \(reason)"
|
||||
case .averageFailed(let reason):
|
||||
return "Averaging failed: \(reason)"
|
||||
case .colprofFailed(let reason):
|
||||
return "Profile creation failed: \(reason)"
|
||||
case .printcalFailed(let reason):
|
||||
return "Calibration curve computation failed: \(reason)"
|
||||
case .applycalFailed(let reason):
|
||||
return "Apply calibration failed: \(reason)"
|
||||
case .iccgamutFailed(let reason):
|
||||
return "Gamut extraction failed: \(reason)"
|
||||
case .profcheckFailed(let reason):
|
||||
return "Profile verification failed: \(reason)"
|
||||
case .profcheckUnparseable:
|
||||
return "Profile verification produced unparseable output"
|
||||
}
|
||||
@@ -76,6 +74,48 @@ public struct ArgyllRunner: Sendable {
|
||||
self.binaryResolver = binaryResolver
|
||||
}
|
||||
|
||||
// MARK: - Shared streaming loop (issue #79)
|
||||
|
||||
private func runStreamingTool(
|
||||
name: String,
|
||||
id: String,
|
||||
arguments: [String],
|
||||
workingDirectory: URL?,
|
||||
flushPartialLines: Bool = false,
|
||||
onLogBatch: (@Sendable ([String]) -> Void)? = nil
|
||||
) async throws -> CollectedRun {
|
||||
let binaryURL = binaryResolver.resolve(name)
|
||||
await ensureNotRunning(id: id)
|
||||
let events = processManager.events()
|
||||
try await processManager.runStreaming(
|
||||
id: id,
|
||||
binary: binaryURL,
|
||||
arguments: arguments,
|
||||
workingDirectory: workingDirectory
|
||||
)
|
||||
let run = await collect(
|
||||
id: id,
|
||||
events: events,
|
||||
onLogBatch: onLogBatch,
|
||||
flushPartialLines: flushPartialLines
|
||||
)
|
||||
guard run.exitCode == 0 else {
|
||||
throw ArgyllRunnerError.toolFailed(
|
||||
tool: name,
|
||||
code: run.exitCode ?? -1,
|
||||
logs: run.lines
|
||||
)
|
||||
}
|
||||
return run
|
||||
}
|
||||
|
||||
private func requireArtefact(_ url: URL) throws -> URL {
|
||||
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||
throw ArgyllRunnerError.missingArtefact(url.path)
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
// MARK: - targen (Stage 1)
|
||||
|
||||
/// Runs `targen` streaming, collecting logs and verifying `.ti1`
|
||||
@@ -87,27 +127,16 @@ public struct ArgyllRunner: Sendable {
|
||||
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
|
||||
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
||||
let args = try TargenArgs.build(config: config)
|
||||
let binaryURL = binaryResolver.resolve("targen")
|
||||
let processId = ProcessID.targen(cleanBasename)
|
||||
|
||||
await ensureNotRunning(id: processId)
|
||||
let events = processManager.events()
|
||||
try await processManager.runStreaming(
|
||||
_ = try await runStreamingTool(
|
||||
name: "targen",
|
||||
id: processId,
|
||||
binary: binaryURL,
|
||||
arguments: args,
|
||||
workingDirectory: cwd
|
||||
workingDirectory: cwd,
|
||||
onLogBatch: onLogBatch
|
||||
)
|
||||
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
|
||||
|
||||
guard run.exitCode == 0 else {
|
||||
throw ArgyllRunnerError.processFailed(code: run.exitCode ?? -1, logs: run.lines)
|
||||
}
|
||||
let ti1URL = cwd.appendingPathComponent("\(cleanBasename).ti1")
|
||||
guard FileManager.default.fileExists(atPath: ti1URL.path) else {
|
||||
throw ArgyllRunnerError.missingArtefact(ti1URL.path)
|
||||
}
|
||||
return ti1URL
|
||||
return try requireArtefact(ti1URL)
|
||||
}
|
||||
|
||||
// MARK: - printtarg (Stage 2)
|
||||
@@ -122,26 +151,15 @@ public struct ArgyllRunner: Sendable {
|
||||
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
|
||||
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
||||
let args = try PrinttargArgs.build(config: config)
|
||||
let binaryURL = binaryResolver.resolve("printtarg")
|
||||
let processId = ProcessID.printtarg(cleanBasename)
|
||||
|
||||
await ensureNotRunning(id: processId)
|
||||
let events = processManager.events()
|
||||
try await processManager.runStreaming(
|
||||
let run = try await runStreamingTool(
|
||||
name: "printtarg",
|
||||
id: processId,
|
||||
binary: binaryURL,
|
||||
arguments: args,
|
||||
workingDirectory: cwd
|
||||
workingDirectory: cwd,
|
||||
onLogBatch: onLogBatch
|
||||
)
|
||||
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
|
||||
|
||||
guard run.exitCode == 0 else {
|
||||
throw ArgyllRunnerError.processFailed(code: run.exitCode ?? -1, logs: run.lines)
|
||||
}
|
||||
let ti2URL = cwd.appendingPathComponent("\(cleanBasename).ti2")
|
||||
guard FileManager.default.fileExists(atPath: ti2URL.path) else {
|
||||
throw ArgyllRunnerError.missingArtefact(ti2URL.path)
|
||||
}
|
||||
let ti2URL = try requireArtefact(cwd.appendingPathComponent("\(cleanBasename).ti2"))
|
||||
|
||||
let manifest: PrinttargManifest
|
||||
do {
|
||||
@@ -182,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(for: .milliseconds(100))
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
attempts += 1
|
||||
}
|
||||
}
|
||||
@@ -225,7 +243,7 @@ public struct ArgyllRunner: Sendable {
|
||||
if flushPartialLines {
|
||||
dotFlushTask = Task { [processManager] in
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: .milliseconds(500))
|
||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||
if Task.isCancelled { break }
|
||||
await processManager.flushPartialLine(id: processId)
|
||||
}
|
||||
@@ -339,28 +357,16 @@ public struct ArgyllRunner: Sendable {
|
||||
) async throws -> URL {
|
||||
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
||||
let args = try AverageArgs.build(config: config)
|
||||
let binaryURL = binaryResolver.resolve("average")
|
||||
let processId = ProcessID.average(config.basename)
|
||||
|
||||
await ensureNotRunning(id: processId)
|
||||
let events = processManager.events()
|
||||
try await processManager.runStreaming(
|
||||
_ = try await runStreamingTool(
|
||||
name: "average",
|
||||
id: processId,
|
||||
binary: binaryURL,
|
||||
arguments: args,
|
||||
workingDirectory: cwd
|
||||
workingDirectory: cwd,
|
||||
onLogBatch: onLogBatch
|
||||
)
|
||||
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
|
||||
|
||||
guard run.exitCode == 0 else {
|
||||
throw ArgyllRunnerError.averageFailed("average exited with code \(run.exitCode ?? -1)")
|
||||
}
|
||||
|
||||
let canonical = cwd.appendingPathComponent("\(config.basename).ti3")
|
||||
guard FileManager.default.fileExists(atPath: canonical.path) else {
|
||||
throw ArgyllRunnerError.missingArtefact(canonical.path)
|
||||
}
|
||||
return canonical
|
||||
return try requireArtefact(canonical)
|
||||
}
|
||||
|
||||
// MARK: - colprof (Stage 4)
|
||||
@@ -374,29 +380,15 @@ public struct ArgyllRunner: Sendable {
|
||||
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
|
||||
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
||||
let args = try ColprofArgs.build(config: config)
|
||||
let binaryURL = binaryResolver.resolve("colprof")
|
||||
let processId = ProcessID.colprof(cleanBasename)
|
||||
|
||||
await ensureNotRunning(id: processId)
|
||||
let events = processManager.events()
|
||||
try await processManager.runStreaming(
|
||||
_ = try await runStreamingTool(
|
||||
name: "colprof",
|
||||
id: processId,
|
||||
binary: binaryURL,
|
||||
arguments: args,
|
||||
workingDirectory: cwd
|
||||
workingDirectory: cwd,
|
||||
flushPartialLines: true,
|
||||
onLogBatch: onLogBatch
|
||||
)
|
||||
let run = await collect(
|
||||
id: processId,
|
||||
events: events,
|
||||
onLogBatch: onLogBatch,
|
||||
flushPartialLines: true
|
||||
)
|
||||
|
||||
guard run.exitCode == 0 else {
|
||||
throw ArgyllRunnerError.colprofFailed(
|
||||
"colprof exited with code \(run.exitCode ?? -1)"
|
||||
)
|
||||
}
|
||||
|
||||
// Argyll may produce `.icm` on Windows, but on macOS we expect `.icc`.
|
||||
// `resolveProfile` checks `.icm` first, then `.icc`, matching #69.
|
||||
@@ -456,16 +448,20 @@ public struct ArgyllRunner: Sendable {
|
||||
if Task.isCancelled {
|
||||
throw CancellationError()
|
||||
}
|
||||
throw ArgyllRunnerError.applycalFailed(
|
||||
result.stderr.isEmpty
|
||||
throw ArgyllRunnerError.toolFailed(
|
||||
tool: "applycal",
|
||||
code: result.exitCode,
|
||||
logs: [result.stderr.isEmpty
|
||||
? "applycal exited with code \(result.exitCode)"
|
||||
: result.stderr
|
||||
: result.stderr]
|
||||
)
|
||||
}
|
||||
|
||||
guard fm.fileExists(atPath: tmpURL.path) else {
|
||||
throw ArgyllRunnerError.applycalFailed(
|
||||
"applycal did not create temp profile"
|
||||
throw ArgyllRunnerError.toolFailed(
|
||||
tool: "applycal",
|
||||
code: -1,
|
||||
logs: ["applycal did not create temp profile"]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -473,8 +469,10 @@ public struct ArgyllRunner: Sendable {
|
||||
let size = attrs?[.size] as? UInt64 ?? 0
|
||||
guard size >= 128 else {
|
||||
try? fm.removeItem(at: tmpURL)
|
||||
throw ArgyllRunnerError.applycalFailed(
|
||||
"calibrated profile is too small (\(size) bytes)"
|
||||
throw ArgyllRunnerError.toolFailed(
|
||||
tool: "applycal",
|
||||
code: -1,
|
||||
logs: ["calibrated profile is too small (\(size) bytes)"]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -486,7 +484,11 @@ public struct ArgyllRunner: Sendable {
|
||||
}
|
||||
} catch {
|
||||
try? fm.removeItem(at: tmpURL)
|
||||
throw ArgyllRunnerError.applycalFailed(error.localizedDescription)
|
||||
throw ArgyllRunnerError.toolFailed(
|
||||
tool: "applycal",
|
||||
code: -1,
|
||||
logs: [error.localizedDescription]
|
||||
)
|
||||
}
|
||||
|
||||
return inputURL
|
||||
@@ -503,30 +505,16 @@ public struct ArgyllRunner: Sendable {
|
||||
let cwd = profileURL.deletingLastPathComponent()
|
||||
let stem = profileURL.deletingPathExtension().lastPathComponent
|
||||
let args = try IccgamutArgs.build(config: config)
|
||||
let binaryURL = binaryResolver.resolve("iccgamut")
|
||||
let processId = ProcessID.iccgamut(stem: stem)
|
||||
|
||||
await ensureNotRunning(id: processId)
|
||||
let events = processManager.events()
|
||||
try await processManager.runStreaming(
|
||||
_ = try await runStreamingTool(
|
||||
name: "iccgamut",
|
||||
id: processId,
|
||||
binary: binaryURL,
|
||||
arguments: args,
|
||||
workingDirectory: cwd
|
||||
workingDirectory: cwd,
|
||||
onLogBatch: onLogBatch
|
||||
)
|
||||
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
|
||||
|
||||
guard run.exitCode == 0 else {
|
||||
throw ArgyllRunnerError.iccgamutFailed(
|
||||
"iccgamut exited with code \(run.exitCode ?? -1)"
|
||||
)
|
||||
}
|
||||
|
||||
let gamURL = cwd.appendingPathComponent("\(stem).gam")
|
||||
guard FileManager.default.fileExists(atPath: gamURL.path) else {
|
||||
throw ArgyllRunnerError.missingArtefact(gamURL.path)
|
||||
}
|
||||
return gamURL
|
||||
return try requireArtefact(gamURL)
|
||||
}
|
||||
|
||||
// MARK: - profcheck (Stage 5 verification)
|
||||
@@ -539,30 +527,18 @@ public struct ArgyllRunner: Sendable {
|
||||
let cwd = config.ti3URL.deletingLastPathComponent()
|
||||
let ti3Path = config.ti3URL.path
|
||||
|
||||
let iccURL = Self.resolveProfileForVerification(config.iccURL)
|
||||
let iccURL = ArtefactProbe.resolveProfile(config.iccURL)
|
||||
let config = ProfcheckConfig(ti3URL: config.ti3URL, iccURL: iccURL)
|
||||
|
||||
let args = try ProfcheckArgs.build(config: config)
|
||||
let binaryURL = binaryResolver.resolve("profcheck")
|
||||
let processId = ProcessID.profcheck(ti3Path: ti3Path)
|
||||
|
||||
await ensureNotRunning(id: processId)
|
||||
let events = processManager.events()
|
||||
try await processManager.runStreaming(
|
||||
let run = try await runStreamingTool(
|
||||
name: "profcheck",
|
||||
id: processId,
|
||||
binary: binaryURL,
|
||||
arguments: args,
|
||||
workingDirectory: cwd
|
||||
workingDirectory: cwd,
|
||||
onLogBatch: onLogBatch
|
||||
)
|
||||
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
|
||||
|
||||
guard run.exitCode == 0 else {
|
||||
throw ArgyllRunnerError.profcheckFailed(
|
||||
run.stderr.isEmpty
|
||||
? "profcheck exited with code \(run.exitCode ?? -1)"
|
||||
: run.stderr
|
||||
)
|
||||
}
|
||||
|
||||
let output = (run.stdout + "\n" + run.stderr).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let report = ProfcheckParser.parse(output)
|
||||
@@ -572,15 +548,6 @@ public struct ArgyllRunner: Sendable {
|
||||
return report
|
||||
}
|
||||
|
||||
private static func resolveProfileForVerification(_ url: URL) -> URL {
|
||||
let fm = FileManager.default
|
||||
if fm.fileExists(atPath: url.path) { return url }
|
||||
let alt = url.pathExtension.lowercased() == "icc"
|
||||
? url.deletingPathExtension().appendingPathExtension("icm")
|
||||
: url.deletingPathExtension().appendingPathExtension("icc")
|
||||
return fm.fileExists(atPath: alt.path) ? alt : url
|
||||
}
|
||||
|
||||
// MARK: - chartread (Stage 3 interactive)
|
||||
|
||||
/// Runs `chartread` and returns an `AsyncStream` of typed events.
|
||||
@@ -596,7 +563,7 @@ public struct ArgyllRunner: Sendable {
|
||||
cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
||||
} catch {
|
||||
return AsyncStream { continuation in
|
||||
continuation.yield(.failed(ArgyllRunnerError.chartreadFailed(error.localizedDescription)))
|
||||
continuation.yield(.failed(ArgyllRunnerError.toolFailed(tool: "chartread", code: -1, logs: [error.localizedDescription])))
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
@@ -606,7 +573,7 @@ public struct ArgyllRunner: Sendable {
|
||||
args = try ChartreadArgs.build(config: config)
|
||||
} catch {
|
||||
return AsyncStream { continuation in
|
||||
continuation.yield(.failed(ArgyllRunnerError.chartreadFailed(error.localizedDescription)))
|
||||
continuation.yield(.failed(ArgyllRunnerError.toolFailed(tool: "chartread", code: -1, logs: [error.localizedDescription])))
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
@@ -625,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(for: .milliseconds(500))
|
||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -637,7 +604,7 @@ public struct ArgyllRunner: Sendable {
|
||||
workingDirectory: cwd
|
||||
)
|
||||
} catch {
|
||||
continuation.yield(.failed(ArgyllRunnerError.chartreadFailed(error.localizedDescription)))
|
||||
continuation.yield(.failed(ArgyllRunnerError.toolFailed(tool: "chartread", code: -1, logs: [error.localizedDescription])))
|
||||
continuation.finish()
|
||||
return
|
||||
}
|
||||
@@ -724,7 +691,11 @@ public struct ArgyllRunner: Sendable {
|
||||
continuation.yield(.failed(ArgyllRunnerError.missingArtefact(canonical.path)))
|
||||
}
|
||||
} else {
|
||||
continuation.yield(.failed(ArgyllRunnerError.chartreadFailed("chartread exited with code \(exitCode ?? -1)")))
|
||||
continuation.yield(.failed(ArgyllRunnerError.toolFailed(
|
||||
tool: "chartread",
|
||||
code: exitCode ?? -1,
|
||||
logs: ["chartread exited with code \(exitCode ?? -1)"]
|
||||
)))
|
||||
}
|
||||
continuation.finish()
|
||||
}
|
||||
@@ -768,30 +739,19 @@ public struct ArgyllRunner: Sendable {
|
||||
) async throws -> URL {
|
||||
let args = try CalibrationTargenArgs.build(config: config)
|
||||
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
||||
let calBasename = config.basename.hasPrefix("CAL_") ? config.basename : "CAL_\(config.basename)"
|
||||
let cleanBasename = try PathSecurity.sanitizeBasename(calBasename)
|
||||
let binaryURL = binaryResolver.resolve("targen")
|
||||
let processId = ProcessID.targen(cleanBasename)
|
||||
|
||||
await ensureNotRunning(id: processId)
|
||||
let events = processManager.events()
|
||||
try await processManager.runStreaming(
|
||||
id: processId,
|
||||
binary: binaryURL,
|
||||
arguments: args,
|
||||
workingDirectory: cwd
|
||||
let cleanBasename = try PathSecurity.sanitizeBasename(
|
||||
CalibrationIdentity.prefix(config.basename)
|
||||
)
|
||||
let processId = ProcessID.targen(cleanBasename)
|
||||
_ = try await runStreamingTool(
|
||||
name: "targen",
|
||||
id: processId,
|
||||
arguments: args,
|
||||
workingDirectory: cwd,
|
||||
onLogBatch: onLogBatch
|
||||
)
|
||||
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
|
||||
|
||||
guard run.exitCode == 0 else {
|
||||
throw ArgyllRunnerError.processFailed(code: run.exitCode ?? -1, logs: run.lines)
|
||||
}
|
||||
|
||||
let ti1URL = cwd.appendingPathComponent("\(cleanBasename).ti1")
|
||||
guard FileManager.default.fileExists(atPath: ti1URL.path) else {
|
||||
throw ArgyllRunnerError.missingArtefact(ti1URL.path)
|
||||
}
|
||||
return ti1URL
|
||||
return try requireArtefact(ti1URL)
|
||||
}
|
||||
|
||||
/// Computes a `.cal` curve from a measured `CAL_*.ti3`.
|
||||
@@ -805,7 +765,7 @@ public struct ArgyllRunner: Sendable {
|
||||
let args = try PrintcalArgs.build(config: config)
|
||||
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
||||
let binaryURL = binaryResolver.resolve("printcal")
|
||||
let calBasename = config.ti3Basename.hasPrefix("CAL_") ? config.ti3Basename : "CAL_\(config.ti3Basename)"
|
||||
let calBasename = CalibrationIdentity.prefix(config.ti3Basename)
|
||||
let processId = ProcessID.printcal(calBasename)
|
||||
|
||||
await ensureNotRunning(id: processId)
|
||||
@@ -821,10 +781,12 @@ public struct ArgyllRunner: Sendable {
|
||||
}
|
||||
|
||||
guard result.exitCode == 0 else {
|
||||
throw ArgyllRunnerError.printcalFailed(
|
||||
result.stderr.isEmpty
|
||||
throw ArgyllRunnerError.toolFailed(
|
||||
tool: "printcal",
|
||||
code: result.exitCode,
|
||||
logs: [result.stderr.isEmpty
|
||||
? "printcal exited with code \(result.exitCode)"
|
||||
: result.stderr
|
||||
: result.stderr]
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -56,23 +56,19 @@ public enum PrinttargArgs {
|
||||
}
|
||||
args.append(contentsOf: ["-R", "\(config.customSeed)"])
|
||||
case .raster:
|
||||
args.append("-r")
|
||||
args.append(contentsOf: ArgsBuilder.flag("-r", when: true))
|
||||
}
|
||||
|
||||
if let label = config.label?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!label.isEmpty {
|
||||
args.append(contentsOf: ["-d", label])
|
||||
}
|
||||
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-d", config.label))
|
||||
|
||||
guard (72...600).contains(config.dpi) else {
|
||||
throw PrinttargArgError.invalidDPI(config.dpi)
|
||||
}
|
||||
args.append(contentsOf: [config.bitDepth.flag, "\(config.dpi)"])
|
||||
|
||||
if !cleanBasename.hasPrefix("CAL_"),
|
||||
let cal = config.calibrationFile?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!cal.isEmpty {
|
||||
args.append(contentsOf: [config.calibrationEmbedOnly ? "-I" : "-K", cal])
|
||||
if !CalibrationIdentity.isCalibration(cleanBasename) {
|
||||
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty(
|
||||
config.calibrationEmbedOnly ? "-I" : "-K", config.calibrationFile))
|
||||
}
|
||||
|
||||
args.append(cleanBasename)
|
||||
|
||||
@@ -70,18 +70,12 @@ public enum TargenArgs {
|
||||
if let n = config.neutralSteps, n > 0 {
|
||||
args.append(contentsOf: ["-n", "\(n)"])
|
||||
}
|
||||
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)])
|
||||
}
|
||||
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 algFlag = config.fullSpreadAlgorithm?.flag {
|
||||
args.append(algFlag)
|
||||
}
|
||||
@@ -91,11 +85,9 @@ public enum TargenArgs {
|
||||
}
|
||||
args.append(contentsOf: ["-l", "\(inkLimit)"])
|
||||
}
|
||||
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(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))
|
||||
}
|
||||
|
||||
args.append(cleanBasename)
|
||||
|
||||
@@ -72,8 +72,8 @@ public enum CGATSParser {
|
||||
public static func parse(
|
||||
_ contents: String,
|
||||
sourceURL: URL? = nil
|
||||
) throws(CGATSParseError) -> CGATSDataset {
|
||||
guard !contents.isEmpty else { throw .emptyFile }
|
||||
) throws -> CGATSDataset {
|
||||
guard !contents.isEmpty else { throw CGATSParseError.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 .missingBeginDataFormat
|
||||
throw CGATSParseError.missingBeginDataFormat
|
||||
}
|
||||
guard let dataStart, let dataEnd, dataEnd > dataStart + 1 else {
|
||||
throw .missingBeginData
|
||||
throw CGATSParseError.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 .incorrectArity(line: lineIndex + 1, expected: fieldNames.count, got: rawRow.count)
|
||||
throw CGATSParseError.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 .nonNumericValue(field: name, value: raw, line: lineIndex + 1)
|
||||
throw CGATSParseError.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(CGATSParseError) -> (CGATSFormat, [String]) {
|
||||
) throws -> (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 .emptyFile }
|
||||
guard !lines.isEmpty else { throw CGATSParseError.emptyFile }
|
||||
|
||||
// Wrap a bare CSV / ISO28178 file in the canonical CGATS block
|
||||
// structure so the boundary-based parser below can handle it.
|
||||
|
||||
@@ -78,6 +78,21 @@ public enum ArtefactProbe {
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Resolve an explicit profile URL, flipping `.icc` ↔ `.icm` when the
|
||||
/// requested path is missing (#69 / issue #83). Any other extension
|
||||
/// (`.mpp`, `.txt`, …) is returned unchanged — never rewritten.
|
||||
public static func resolveProfile(
|
||||
_ url: URL,
|
||||
fileManager: FileManager = .default
|
||||
) -> URL {
|
||||
if fileManager.fileExists(atPath: url.path) { return url }
|
||||
let ext = url.pathExtension.lowercased()
|
||||
guard ext == "icc" || ext == "icm" else { return url }
|
||||
let alt = url.deletingPathExtension()
|
||||
.appendingPathExtension(ext == "icc" ? "icm" : "icc")
|
||||
return fileManager.fileExists(atPath: alt.path) ? alt : url
|
||||
}
|
||||
|
||||
/// Default extension for a *new* profile on macOS (#69).
|
||||
public static let defaultProfileExtension = "icc"
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import Foundation
|
||||
|
||||
/// Policy when a JSON file exists but cannot be decoded.
|
||||
public enum JSONCorruptPolicy: Sendable {
|
||||
/// Return `defaultValue` and leave the file untouched.
|
||||
case replaceWithDefault
|
||||
/// Throw the decode error. Callers must not overwrite the file.
|
||||
case throwCorrupt
|
||||
}
|
||||
|
||||
/// Shared pretty-printed JSON file façade used by settings, wizard state,
|
||||
/// and verification history.
|
||||
public struct JSONFileStore<T: Codable & Sendable>: Sendable {
|
||||
public let fileURL: URL
|
||||
public let corrupt: JSONCorruptPolicy
|
||||
private let defaultValue: @Sendable () -> T
|
||||
private let encoder: JSONEncoder
|
||||
private let decoder: JSONDecoder
|
||||
|
||||
public init(
|
||||
fileURL: URL,
|
||||
corrupt: JSONCorruptPolicy,
|
||||
defaultValue: @escaping @Sendable () -> T,
|
||||
dateEncoding: JSONEncoder.DateEncodingStrategy = .deferredToDate,
|
||||
dateDecoding: JSONDecoder.DateDecodingStrategy = .deferredToDate
|
||||
) {
|
||||
self.fileURL = fileURL
|
||||
self.corrupt = corrupt
|
||||
self.defaultValue = defaultValue
|
||||
self.encoder = JSONEncoder.icceryPretty(dateEncoding: dateEncoding)
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = dateDecoding
|
||||
self.decoder = decoder
|
||||
}
|
||||
|
||||
/// Encodes `value` with the shared pretty / sorted-keys encoder.
|
||||
public func encodePretty(_ value: T) throws -> Data {
|
||||
try encoder.encode(value)
|
||||
}
|
||||
|
||||
public func load() throws -> T {
|
||||
let fm = FileManager.default
|
||||
guard fm.fileExists(atPath: fileURL.path) else {
|
||||
return defaultValue()
|
||||
}
|
||||
let data: Data
|
||||
do {
|
||||
data = try Data(contentsOf: fileURL)
|
||||
} catch {
|
||||
switch corrupt {
|
||||
case .replaceWithDefault:
|
||||
return defaultValue()
|
||||
case .throwCorrupt:
|
||||
throw error
|
||||
}
|
||||
}
|
||||
do {
|
||||
return try decoder.decode(T.self, from: data)
|
||||
} catch {
|
||||
switch corrupt {
|
||||
case .replaceWithDefault:
|
||||
return defaultValue()
|
||||
case .throwCorrupt:
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func save(_ value: T) throws {
|
||||
try AtomicFileWriter.write(try encodePretty(value), to: fileURL)
|
||||
}
|
||||
}
|
||||
|
||||
extension JSONEncoder {
|
||||
/// Shared pretty-printed, sorted-keys encoder used by `JSONFileStore`
|
||||
/// and preset export.
|
||||
static func icceryPretty(
|
||||
dateEncoding: JSONEncoder.DateEncodingStrategy = .deferredToDate
|
||||
) -> JSONEncoder {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
encoder.dateEncodingStrategy = dateEncoding
|
||||
return encoder
|
||||
}
|
||||
}
|
||||
@@ -51,58 +51,6 @@ public struct PatchColor: Codable, Sendable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct CIEXYZ: Codable, Sendable, Equatable {
|
||||
public let x: Double
|
||||
public let y: Double
|
||||
public let z: Double
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
var container = try decoder.unkeyedContainer()
|
||||
self.x = try container.decode(Double.self)
|
||||
self.y = try container.decode(Double.self)
|
||||
self.z = try container.decode(Double.self)
|
||||
}
|
||||
|
||||
public init(x: Double, y: Double, z: Double) {
|
||||
self.x = x
|
||||
self.y = y
|
||||
self.z = z
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.unkeyedContainer()
|
||||
try container.encode(x)
|
||||
try container.encode(y)
|
||||
try container.encode(z)
|
||||
}
|
||||
}
|
||||
|
||||
public struct CIELab: Codable, Sendable, Equatable {
|
||||
public let l: Double
|
||||
public let a: Double
|
||||
public let b: Double
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
var container = try decoder.unkeyedContainer()
|
||||
self.l = try container.decode(Double.self)
|
||||
self.a = try container.decode(Double.self)
|
||||
self.b = try container.decode(Double.self)
|
||||
}
|
||||
|
||||
public init(l: Double, a: Double, b: Double) {
|
||||
self.l = l
|
||||
self.a = a
|
||||
self.b = b
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.unkeyedContainer()
|
||||
try container.encode(l)
|
||||
try container.encode(a)
|
||||
try container.encode(b)
|
||||
}
|
||||
}
|
||||
|
||||
public struct SpectralData: Codable, Sendable, Equatable {
|
||||
public let bands: Int
|
||||
public let startNM: Double
|
||||
|
||||
@@ -154,11 +154,9 @@ public enum ColorDifference {
|
||||
|
||||
/// Resolve a Lab from a `PatchColor`, computing it from XYZ when Lab is absent.
|
||||
public static func resolveLab(_ color: PatchColor) -> LabColor? {
|
||||
if let lab = color.lab {
|
||||
return LabColor(l: lab.l, a: lab.a, b: lab.b)
|
||||
}
|
||||
if let lab = color.lab { return lab }
|
||||
guard let xyz = color.xyz else { return nil }
|
||||
return LabColorMath.xyzToLab(XYZColor(x: xyz.x, y: xyz.y, z: xyz.z))
|
||||
return LabColorMath.xyzToLab(xyz)
|
||||
}
|
||||
|
||||
private static func atan2ToDegrees(_ y: Double, _ x: Double) -> Double {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import Foundation
|
||||
|
||||
/// XYZ tristimulus values, stored in the 0–100 scale used by the Argyll fork.
|
||||
public struct XYZColor: Sendable, Equatable {
|
||||
/// Unkeyed Codable matches `ROW_COLORS_JSON` `[x, y, z]`.
|
||||
public struct XYZColor: Codable, Sendable, Equatable {
|
||||
public let x: Double
|
||||
public let y: Double
|
||||
public let z: Double
|
||||
@@ -11,10 +12,24 @@ public struct XYZColor: Sendable, Equatable {
|
||||
self.y = y
|
||||
self.z = z
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
var container = try decoder.unkeyedContainer()
|
||||
self.x = try container.decode(Double.self)
|
||||
self.y = try container.decode(Double.self)
|
||||
self.z = try container.decode(Double.self)
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.unkeyedContainer()
|
||||
try container.encode(x)
|
||||
try container.encode(y)
|
||||
try container.encode(z)
|
||||
}
|
||||
}
|
||||
|
||||
/// CIELab value (D50).
|
||||
public struct LabColor: Sendable, Equatable {
|
||||
/// CIELab value (D50). Unkeyed Codable matches `ROW_COLORS_JSON` `[L, a, b]`.
|
||||
public struct LabColor: Codable, Sendable, Equatable {
|
||||
public let l: Double
|
||||
public let a: Double
|
||||
public let b: Double
|
||||
@@ -24,8 +39,26 @@ public struct LabColor: Sendable, Equatable {
|
||||
self.a = a
|
||||
self.b = b
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
var container = try decoder.unkeyedContainer()
|
||||
self.l = try container.decode(Double.self)
|
||||
self.a = try container.decode(Double.self)
|
||||
self.b = try container.decode(Double.self)
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.unkeyedContainer()
|
||||
try container.encode(l)
|
||||
try container.encode(a)
|
||||
try container.encode(b)
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON aliases used by `chartread` row payloads.
|
||||
public typealias CIEXYZ = XYZColor
|
||||
public typealias CIELab = LabColor
|
||||
|
||||
/// sRGB colour in 0–1 display space.
|
||||
public struct DisplayRGB: Sendable, Equatable {
|
||||
public let r: Double
|
||||
|
||||
@@ -136,31 +136,26 @@ public actor ProcessManager {
|
||||
) throws {
|
||||
guard !isRunning(id) else { throw ProcessError.duplicateID(id) }
|
||||
|
||||
let process = Process()
|
||||
let stdinPipe = Pipe()
|
||||
let stdoutPipe = Pipe()
|
||||
let stderrPipe = Pipe()
|
||||
process.executableURL = binary
|
||||
process.arguments = arguments
|
||||
process.currentDirectoryURL = workingDirectory
|
||||
process.standardInput = stdinPipe
|
||||
process.standardOutput = stdoutPipe
|
||||
process.standardError = stderrPipe
|
||||
process.environment = childEnvironment(extra: environment)
|
||||
|
||||
AppLogger(category: "process").debug(
|
||||
"spawn \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))"
|
||||
let prepared = makeProcess(
|
||||
binary: binary,
|
||||
arguments: arguments,
|
||||
workingDirectory: workingDirectory,
|
||||
environment: environment,
|
||||
includeStdin: true
|
||||
)
|
||||
let process = prepared.process
|
||||
|
||||
logSpawn(id: id, binary: binary, arguments: arguments, captured: false)
|
||||
|
||||
children[id] = RunningChild(
|
||||
process: process,
|
||||
stdin: stdinPipe.fileHandleForWriting,
|
||||
stdin: prepared.stdinPipe?.fileHandleForWriting,
|
||||
stdoutDecoder: ProcessLineDecoder(),
|
||||
stderrDecoder: ProcessLineDecoder()
|
||||
)
|
||||
|
||||
let stdoutHandle = stdoutPipe.fileHandleForReading
|
||||
let stderrHandle = stderrPipe.fileHandleForReading
|
||||
let stdoutHandle = prepared.stdoutPipe.fileHandleForReading
|
||||
let stderrHandle = prepared.stderrPipe.fileHandleForReading
|
||||
stdoutHandle.readabilityHandler = { [weak self] handle in
|
||||
let data = handle.availableData
|
||||
guard let self else { return }
|
||||
@@ -172,27 +167,23 @@ public actor ProcessManager {
|
||||
Task { await self.ingestOutput(data, id: id, isStderr: true, handle: handle) }
|
||||
}
|
||||
|
||||
process.terminationHandler = { [weak self] proc in
|
||||
attachTerminationHandler(process) { [weak self] code in
|
||||
guard let self else { return }
|
||||
Task { await self.didTerminate(id: id, code: proc.terminationStatus) }
|
||||
Task { await self.didTerminate(id: id, code: code) }
|
||||
}
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
// Fallback watchdog: very fast child exits can race past the
|
||||
// terminationHandler delivery on a loaded host. waitUntilExit()
|
||||
// blocks the detached thread and guarantees didTerminate runs.
|
||||
Task.detached { [weak self, process] in
|
||||
process.waitUntilExit()
|
||||
guard let self else { return }
|
||||
await self.didTerminate(id: id, code: process.terminationStatus)
|
||||
}
|
||||
} catch {
|
||||
preKillHooks.removeValue(forKey: id)
|
||||
children.removeValue(forKey: id)
|
||||
emit(.error(id: id, message: error.localizedDescription))
|
||||
throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)")
|
||||
}
|
||||
startWaitUntilExitWatchdog(process) { [weak self] code in
|
||||
guard let self else { return }
|
||||
Task { await self.didTerminate(id: id, code: code) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Spawn (captured)
|
||||
@@ -210,19 +201,18 @@ public actor ProcessManager {
|
||||
) async throws -> CapturedResult {
|
||||
guard !isRunning(id) else { throw ProcessError.duplicateID(id) }
|
||||
|
||||
let process = Process()
|
||||
let stdoutPipe = Pipe()
|
||||
let stderrPipe = Pipe()
|
||||
process.executableURL = binary
|
||||
process.arguments = arguments
|
||||
process.currentDirectoryURL = workingDirectory
|
||||
process.standardOutput = stdoutPipe
|
||||
process.standardError = stderrPipe
|
||||
process.environment = childEnvironment(extra: environment)
|
||||
|
||||
AppLogger(category: "process").debug(
|
||||
"spawn(captured) \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))"
|
||||
let prepared = makeProcess(
|
||||
binary: binary,
|
||||
arguments: arguments,
|
||||
workingDirectory: workingDirectory,
|
||||
environment: environment,
|
||||
includeStdin: false
|
||||
)
|
||||
let process = prepared.process
|
||||
let stdoutPipe = prepared.stdoutPipe
|
||||
let stderrPipe = prepared.stderrPipe
|
||||
|
||||
logSpawn(id: id, binary: binary, arguments: arguments, captured: true)
|
||||
|
||||
// Register and set up the termination hand-off before run() so
|
||||
// a very fast exit is never missed (#50, #52).
|
||||
@@ -275,20 +265,12 @@ public actor ProcessManager {
|
||||
}
|
||||
}
|
||||
let box = Box()
|
||||
capturedProcess.terminationHandler = { proc in
|
||||
_ = box.resume(with: proc.terminationStatus)
|
||||
attachTerminationHandler(capturedProcess) { status in
|
||||
_ = box.resume(with: status)
|
||||
}
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
// Fallback watchdog: very fast child exits can race past the
|
||||
// terminationHandler delivery on a loaded host. waitUntilExit()
|
||||
// blocks the detached thread and resumes the box if the handler
|
||||
// did not already do so (#50, #52).
|
||||
Task.detached { [capturedProcess] in
|
||||
capturedProcess.waitUntilExit()
|
||||
_ = box.resume(with: capturedProcess.terminationStatus)
|
||||
}
|
||||
} catch {
|
||||
_ = box.resume(with: -1)
|
||||
captured.removeValue(forKey: id)
|
||||
@@ -296,6 +278,9 @@ public actor ProcessManager {
|
||||
emit(.error(id: id, message: error.localizedDescription))
|
||||
throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)")
|
||||
}
|
||||
startWaitUntilExitWatchdog(capturedProcess) { status in
|
||||
_ = box.resume(with: status)
|
||||
}
|
||||
|
||||
// Close the parent write ends so readDataToEndOfFile() gets EOF
|
||||
// as soon as the child exits; the child still has its own copies.
|
||||
@@ -371,12 +356,7 @@ public actor ProcessManager {
|
||||
guard var child = children[id], !child.finalized else { return }
|
||||
|
||||
if let tail = child.stdoutDecoder.flushPartial() {
|
||||
if tail.hasPrefix(Self.rowColorsPrefix) {
|
||||
let payload = Data(tail.dropFirst(Self.rowColorsPrefix.count).utf8)
|
||||
emit(.jsonRow(id: id, payload: payload))
|
||||
} else {
|
||||
emit(.stdout(id: id, line: tail))
|
||||
}
|
||||
emitStdoutLine(id: id, line: tail)
|
||||
}
|
||||
if let tail = child.stderrDecoder.flushPartial() {
|
||||
emit(.stderr(id: id, line: tail))
|
||||
@@ -447,6 +427,86 @@ public actor ProcessManager {
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
private struct PreparedProcess {
|
||||
let process: Process
|
||||
let stdinPipe: Pipe?
|
||||
let stdoutPipe: Pipe
|
||||
let stderrPipe: Pipe
|
||||
}
|
||||
|
||||
private func makeProcess(
|
||||
binary: URL,
|
||||
arguments: [String],
|
||||
workingDirectory: URL?,
|
||||
environment: [String: String],
|
||||
includeStdin: Bool
|
||||
) -> PreparedProcess {
|
||||
let process = Process()
|
||||
let stdoutPipe = Pipe()
|
||||
let stderrPipe = Pipe()
|
||||
let stdinPipe: Pipe? = includeStdin ? Pipe() : nil
|
||||
process.executableURL = binary
|
||||
process.arguments = arguments
|
||||
process.currentDirectoryURL = workingDirectory
|
||||
process.standardOutput = stdoutPipe
|
||||
process.standardError = stderrPipe
|
||||
if let stdinPipe {
|
||||
process.standardInput = stdinPipe
|
||||
}
|
||||
process.environment = childEnvironment(extra: environment)
|
||||
return PreparedProcess(
|
||||
process: process,
|
||||
stdinPipe: stdinPipe,
|
||||
stdoutPipe: stdoutPipe,
|
||||
stderrPipe: stderrPipe
|
||||
)
|
||||
}
|
||||
|
||||
private nonisolated func logSpawn(
|
||||
id: String,
|
||||
binary: URL,
|
||||
arguments: [String],
|
||||
captured: Bool
|
||||
) {
|
||||
let prefix = captured ? "spawn(captured)" : "spawn"
|
||||
AppLogger(category: "process").debug(
|
||||
"\(prefix) \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))"
|
||||
)
|
||||
}
|
||||
|
||||
/// `terminationHandler` can lose a fast-exit race on a loaded host;
|
||||
/// `waitUntilExit` on a detached thread is the fallback (#50, #52).
|
||||
/// The handler is attached before `run()`; the wait thread starts
|
||||
/// only after a successful launch — `terminationStatus` on an
|
||||
/// unlaunched NSTask raises NSInvalidArgumentException.
|
||||
private func attachTerminationHandler(
|
||||
_ process: Process,
|
||||
onExit: @escaping @Sendable (Int32) -> Void
|
||||
) {
|
||||
process.terminationHandler = { proc in
|
||||
onExit(proc.terminationStatus)
|
||||
}
|
||||
}
|
||||
|
||||
private func startWaitUntilExitWatchdog(
|
||||
_ process: Process,
|
||||
onExit: @escaping @Sendable (Int32) -> Void
|
||||
) {
|
||||
Task.detached { [process] in
|
||||
process.waitUntilExit()
|
||||
onExit(process.terminationStatus)
|
||||
}
|
||||
}
|
||||
|
||||
private func emitStdoutLine(id: String, line: String) {
|
||||
if line.hasPrefix(Self.rowColorsPrefix) {
|
||||
let payload = Data(line.dropFirst(Self.rowColorsPrefix.count).utf8)
|
||||
emit(.jsonRow(id: id, payload: payload))
|
||||
} else {
|
||||
emit(.stdout(id: id, line: line))
|
||||
}
|
||||
}
|
||||
|
||||
private func childEnvironment(extra: [String: String]) -> [String: String] {
|
||||
var env = ProcessInfo.processInfo.environment
|
||||
env["ARGYLL_NOT_INTERACTIVE"] = "1"
|
||||
@@ -478,15 +538,14 @@ public actor ProcessManager {
|
||||
|
||||
let log = AppLogger(category: "subprocess")
|
||||
for line in lines {
|
||||
if !isStderr, line.hasPrefix(Self.rowColorsPrefix) {
|
||||
let payload = Data(line.dropFirst(Self.rowColorsPrefix.count).utf8)
|
||||
emit(.jsonRow(id: id, payload: payload))
|
||||
} else if isStderr {
|
||||
if !isStderr {
|
||||
emitStdoutLine(id: id, line: line)
|
||||
if !line.hasPrefix(Self.rowColorsPrefix) {
|
||||
log.info("[\(id)] \(line)")
|
||||
}
|
||||
} else {
|
||||
log.warn("[\(id)] \(line)")
|
||||
emit(.stderr(id: id, line: line))
|
||||
} else {
|
||||
log.info("[\(id)] \(line)")
|
||||
emit(.stdout(id: id, line: line))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -500,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(for: .seconds(2))
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||
guard let self else { return }
|
||||
await self.forceKill(id: id)
|
||||
await self.forceFinalize(id: id)
|
||||
@@ -528,11 +587,7 @@ public actor ProcessManager {
|
||||
// Flush unterminated tail lines.
|
||||
if var decoder = Optional(child.stdoutDecoder),
|
||||
let tail = decoder.finish() {
|
||||
if tail.hasPrefix(Self.rowColorsPrefix) {
|
||||
emit(.jsonRow(id: id, payload: Data(tail.dropFirst(Self.rowColorsPrefix.count).utf8)))
|
||||
} else {
|
||||
emit(.stdout(id: id, line: tail))
|
||||
}
|
||||
emitStdoutLine(id: id, line: tail)
|
||||
}
|
||||
if var decoder = Optional(child.stderrDecoder),
|
||||
let tail = decoder.finish() {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import Foundation
|
||||
|
||||
/// Canonical `CAL_` / original-stem pairing for Stage 0 (issue #29 / #83).
|
||||
///
|
||||
/// The live wizard basename, the persisted `calibrationOriginalBasename`,
|
||||
/// and the runner all derive identity from this type. Do not add a second
|
||||
/// `hasPrefix("CAL_")` ternary elsewhere.
|
||||
public struct CalibrationIdentity: Equatable, Sendable {
|
||||
/// Never has a `CAL_` prefix. Empty only when the live basename is empty.
|
||||
public var originalBasename: String
|
||||
/// Always `CAL_{original}` when original is non-empty.
|
||||
public var calibrationBasename: String
|
||||
|
||||
public init(originalBasename: String, calibrationBasename: String) {
|
||||
self.originalBasename = originalBasename
|
||||
self.calibrationBasename = calibrationBasename
|
||||
}
|
||||
|
||||
public static func isCalibration(_ basename: String) -> Bool {
|
||||
basename.hasPrefix("CAL_")
|
||||
}
|
||||
|
||||
/// The only place that adds a `CAL_` prefix.
|
||||
public static func prefix(_ original: String) -> String {
|
||||
if original.isEmpty { return original }
|
||||
return original.hasPrefix("CAL_") ? original : "CAL_\(original)"
|
||||
}
|
||||
|
||||
/// Strip a single leading `CAL_` if present.
|
||||
public static func strip(_ basename: String) -> String {
|
||||
basename.hasPrefix("CAL_") ? String(basename.dropFirst(4)) : basename
|
||||
}
|
||||
|
||||
/// Derive identity from the live wizard basename and the persisted
|
||||
/// original. A non-empty persisted original wins over a `CAL_` live
|
||||
/// name (Force Quit mid-calibration). An empty live basename always
|
||||
/// produces an empty identity — a persisted original must never
|
||||
/// resurrect a target that no longer exists (#83).
|
||||
public static func parse(liveBasename: String, persistedOriginal: String) -> CalibrationIdentity {
|
||||
guard !liveBasename.isEmpty else {
|
||||
return CalibrationIdentity(originalBasename: "", calibrationBasename: "")
|
||||
}
|
||||
let original: String
|
||||
if liveBasename.hasPrefix("CAL_") {
|
||||
original = persistedOriginal.isEmpty ? strip(liveBasename) : persistedOriginal
|
||||
} else {
|
||||
original = liveBasename
|
||||
}
|
||||
return CalibrationIdentity(
|
||||
originalBasename: original,
|
||||
calibrationBasename: prefix(original)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,7 @@ public enum CalibrationTargenArgs {
|
||||
args.append(contentsOf: ["-l", "\(inkLimit)"])
|
||||
}
|
||||
|
||||
let calBasename = cleanBasename.hasPrefix("CAL_") ? cleanBasename : "CAL_\(cleanBasename)"
|
||||
let calBasename = CalibrationIdentity.prefix(cleanBasename)
|
||||
args.append(calBasename)
|
||||
return args
|
||||
}
|
||||
|
||||
@@ -26,18 +26,13 @@ public enum ColprofArgs {
|
||||
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
|
||||
|
||||
var args: [String] = ["-v"]
|
||||
|
||||
args.append(contentsOf: ["-a", config.algorithm])
|
||||
args.append(contentsOf: ["-q", config.quality])
|
||||
|
||||
if let intent = config.intent?.trimmingCharacters(in: .whitespaces), !intent.isEmpty {
|
||||
args.append(contentsOf: ["-t", intent])
|
||||
}
|
||||
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-t", config.intent))
|
||||
|
||||
if let fwa = config.fwa?.trimmingCharacters(in: .whitespaces) {
|
||||
switch fwa.lowercased() {
|
||||
case "none", "":
|
||||
// "none" omits the flag; an explicit empty string means bare -f.
|
||||
if fwa.isEmpty {
|
||||
args.append("-f")
|
||||
}
|
||||
@@ -46,32 +41,20 @@ public enum ColprofArgs {
|
||||
}
|
||||
}
|
||||
|
||||
if let illuminant = config.illuminant?.trimmingCharacters(in: .whitespaces), !illuminant.isEmpty {
|
||||
args.append(contentsOf: ["-i", illuminant])
|
||||
}
|
||||
|
||||
if let observer = config.observer?.trimmingCharacters(in: .whitespaces), !observer.isEmpty {
|
||||
args.append(contentsOf: ["-o", observer])
|
||||
}
|
||||
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-i", config.illuminant))
|
||||
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-o", config.observer))
|
||||
|
||||
if let inputCond = config.inputViewingCond?.trimmingCharacters(in: .whitespaces),
|
||||
!inputCond.isEmpty, inputCond.lowercased() != "none" {
|
||||
args.append(contentsOf: ["-c", inputCond])
|
||||
}
|
||||
|
||||
if let outputCond = config.outputViewingCond?.trimmingCharacters(in: .whitespaces),
|
||||
!outputCond.isEmpty, outputCond.lowercased() != "none" {
|
||||
args.append(contentsOf: ["-d", outputCond])
|
||||
}
|
||||
|
||||
let profileDescription = config.description?.trimmingCharacters(in: .whitespaces)
|
||||
if let description = profileDescription, !description.isEmpty {
|
||||
args.append(contentsOf: ["-D", description])
|
||||
}
|
||||
|
||||
if let copyright = config.copyright?.trimmingCharacters(in: .whitespaces), !copyright.isEmpty {
|
||||
args.append(contentsOf: ["-C", copyright])
|
||||
}
|
||||
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-D", config.description))
|
||||
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-C", config.copyright))
|
||||
|
||||
args.append(cleanBasename)
|
||||
return args
|
||||
|
||||
@@ -79,16 +79,9 @@ public enum PrintcalArgs {
|
||||
|
||||
var args: [String] = ["-v", "-e"]
|
||||
|
||||
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])
|
||||
}
|
||||
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 let tac = config.totalInkLimit, tac > 0 {
|
||||
args.append(contentsOf: ["-m", String(format: "%.1f", tac)])
|
||||
} else if let tac = config.totalInkLimit {
|
||||
@@ -107,7 +100,7 @@ public enum PrintcalArgs {
|
||||
}
|
||||
args.append(contentsOf: ["-o", config.outputURL.path])
|
||||
|
||||
let calBasename = cleanBasename.hasPrefix("CAL_") ? cleanBasename : "CAL_\(cleanBasename)"
|
||||
let calBasename = CalibrationIdentity.prefix(cleanBasename)
|
||||
args.append(calBasename)
|
||||
return args
|
||||
}
|
||||
|
||||
@@ -13,8 +13,7 @@ public actor VerificationHistoryStore {
|
||||
private var records: [VerificationRecord] = []
|
||||
|
||||
private let capacity: Int
|
||||
private let encoder: JSONEncoder
|
||||
private let decoder: JSONDecoder
|
||||
private let fileStore: JSONFileStore<[VerificationRecord]>
|
||||
|
||||
public init(
|
||||
url: URL = AppPaths.appDataDir.appendingPathComponent("verification_history.json"),
|
||||
@@ -22,13 +21,13 @@ public actor VerificationHistoryStore {
|
||||
) {
|
||||
self.url = url
|
||||
self.capacity = capacity
|
||||
|
||||
self.encoder = JSONEncoder()
|
||||
self.encoder.dateEncodingStrategy = .iso8601
|
||||
self.encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
|
||||
self.decoder = JSONDecoder()
|
||||
self.decoder.dateDecodingStrategy = .iso8601
|
||||
self.fileStore = JSONFileStore(
|
||||
fileURL: url,
|
||||
corrupt: .throwCorrupt,
|
||||
defaultValue: { [] },
|
||||
dateEncoding: .iso8601,
|
||||
dateDecoding: .iso8601
|
||||
)
|
||||
}
|
||||
|
||||
/// Loads records from disk. Returns the existing cache if already loaded.
|
||||
@@ -37,10 +36,8 @@ public actor VerificationHistoryStore {
|
||||
/// is never overwritten in that case.
|
||||
public func load() throws -> [VerificationRecord] {
|
||||
guard records.isEmpty else { return records }
|
||||
let fm = FileManager.default
|
||||
guard fm.fileExists(atPath: url.path),
|
||||
let data = try? Data(contentsOf: url) else { return [] }
|
||||
records = try decoder.decode([VerificationRecord].self, from: data)
|
||||
guard FileManager.default.fileExists(atPath: url.path) else { return [] }
|
||||
records = try fileStore.load()
|
||||
return records
|
||||
}
|
||||
|
||||
@@ -76,7 +73,11 @@ public actor VerificationHistoryStore {
|
||||
}
|
||||
|
||||
/// Removes all history and updates disk.
|
||||
///
|
||||
/// Loads the existing history first and propagates any load error so an
|
||||
/// unparseable file is never overwritten.
|
||||
public func clear() throws {
|
||||
try load()
|
||||
try write([])
|
||||
records = []
|
||||
}
|
||||
@@ -106,8 +107,7 @@ public actor VerificationHistoryStore {
|
||||
|
||||
/// Writes `records` through a temp file and rename.
|
||||
private func write(_ records: [VerificationRecord]) throws {
|
||||
let data = try encoder.encode(records)
|
||||
try AtomicFileWriter.write(data, to: url)
|
||||
try fileStore.save(records)
|
||||
}
|
||||
|
||||
private func csvRow(_ fields: [String]) -> String {
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import Foundation
|
||||
|
||||
extension TargenConfig {
|
||||
/// Stage 1 fields of a profiling preset. Optional advanced flags stay
|
||||
/// `nil` when the preset omitted them so argv builders skip the flag.
|
||||
public init(preset: ProfilingPreset, basename: String, workingDirectory: URL?) {
|
||||
self.init(
|
||||
colourSpace: preset.colourSpace.lowercased() == "cmyk" ? .cmyk : .rgb,
|
||||
patchCount: preset.patchCount,
|
||||
whitePatches: preset.whitePatches,
|
||||
blackPatches: preset.blackPatches,
|
||||
greySteps: preset.greySteps,
|
||||
singleChannelSteps: preset.singleChannelSteps,
|
||||
neutralSteps: preset.neutralSteps,
|
||||
neutralConcentration: preset.neutralConcentration,
|
||||
preconditioningProfile: preset.preconditioningProfile,
|
||||
// An explicit `false` is preserved — distinguishable from a
|
||||
// missing key; `-G` is only emitted for `true` (#82).
|
||||
ofpsHighQuality: preset.ofpsHighQuality,
|
||||
ofpsAdaptation: preset.ofpsAdaptation,
|
||||
fullSpreadAlgorithm: preset.fullSpreadAlgorithm.flatMap { FullSpreadAlgorithm(presetValue: $0) }.flatMap { $0 == .ofps ? nil : $0 },
|
||||
totalInkLimit: preset.totalInkLimit,
|
||||
darkEmphasis: preset.darkEmphasis,
|
||||
devicePower: preset.devicePower,
|
||||
basename: basename,
|
||||
workingDirectory: workingDirectory
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension PrinttargConfig {
|
||||
public init(
|
||||
preset: ProfilingPreset,
|
||||
basename: String,
|
||||
workingDirectory: URL?,
|
||||
calibrationFile: String?,
|
||||
label: String? = nil
|
||||
) {
|
||||
let page: PageSize
|
||||
let customW: Double
|
||||
let customH: Double
|
||||
if let size = PageSize(rawValue: preset.pageSize) {
|
||||
page = size
|
||||
customW = 210
|
||||
customH = 297
|
||||
} else if let (w, h) = PageSize.parseCustom(preset.pageSize) {
|
||||
page = .custom
|
||||
customW = w
|
||||
customH = h
|
||||
} else {
|
||||
page = .a4
|
||||
customW = 210
|
||||
customH = 297
|
||||
}
|
||||
|
||||
let layout: LayoutOrder
|
||||
let seed = preset.randomSeed ?? 1
|
||||
if preset.noRandomize == true {
|
||||
layout = .raster
|
||||
} else if seed == 1 {
|
||||
layout = .deterministic
|
||||
} else {
|
||||
layout = .customSeed
|
||||
}
|
||||
|
||||
self.init(
|
||||
instrument: PrintInstrument(rawValue: preset.instrument) ?? .i1,
|
||||
pageSize: page,
|
||||
customPageWidth: customW,
|
||||
customPageHeight: customH,
|
||||
bitDepth: preset.bitDepth == 16 ? .sixteen : .eight,
|
||||
dpi: preset.dpi,
|
||||
layoutOrder: layout,
|
||||
customSeed: seed,
|
||||
label: label,
|
||||
calibrationFile: calibrationFile,
|
||||
calibrationEmbedOnly: false,
|
||||
basename: basename,
|
||||
workingDirectory: workingDirectory
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension ColprofConfig {
|
||||
public init(
|
||||
preset: ProfilingPreset,
|
||||
basename: String,
|
||||
workingDirectory: URL?,
|
||||
description: String? = nil,
|
||||
copyright: String? = nil
|
||||
) {
|
||||
self.init(
|
||||
algorithm: preset.colprofAlgorithm ?? "l",
|
||||
quality: preset.colprofQuality ?? "m",
|
||||
intent: Self.nilIfEmpty(preset.colprofIntent),
|
||||
fwa: preset.colprofFwa,
|
||||
illuminant: Self.nilIfEmpty(preset.colprofIlluminant),
|
||||
observer: Self.nilIfEmpty(preset.colprofObserver),
|
||||
inputViewingCond: Self.nilIfEmpty(preset.colprofInputViewingCond),
|
||||
outputViewingCond: Self.nilIfEmpty(preset.colprofOutputViewingCond),
|
||||
description: description,
|
||||
copyright: copyright,
|
||||
basename: basename,
|
||||
workingDirectory: workingDirectory
|
||||
)
|
||||
}
|
||||
|
||||
private static func nilIfEmpty(_ value: String?) -> String? {
|
||||
guard let value, !value.isEmpty else { return nil }
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
/// User-facing FWA selection for the Stage 4 form, plus the two
|
||||
/// directions of `colprof_fwa` conversion centralised here so the view
|
||||
/// models carry no mapping switches of their own (#82).
|
||||
public enum ColprofFwaSelection: String, CaseIterable, Sendable, Equatable {
|
||||
case none = "none"
|
||||
case empty = ""
|
||||
case D50 = "D50"
|
||||
case D65 = "D65"
|
||||
case custom = "custom"
|
||||
|
||||
public var displayName: String {
|
||||
switch self {
|
||||
case .none: return "None"
|
||||
case .empty: return "Bare (-f)"
|
||||
case .D50: return "D50"
|
||||
case .D65: return "D65"
|
||||
case .custom: return "Custom .sp"
|
||||
}
|
||||
}
|
||||
|
||||
/// Preset `colprof_fwa` → selection. `nil`/`"none"` map to `.none`,
|
||||
/// `""` to `.empty`, `D50`/`D65` case-insensitively, and any other
|
||||
/// string is a custom `.sp` path.
|
||||
public init(presetValue: String?) {
|
||||
switch presetValue?.lowercased() {
|
||||
case nil, "none": self = .none
|
||||
case "": self = .empty
|
||||
case "d50": self = .D50
|
||||
case "d65": self = .D65
|
||||
default: self = .custom
|
||||
}
|
||||
}
|
||||
|
||||
/// Selection → `colprof_fwa` value. `.custom` returns `customPath`.
|
||||
public func presetValue(customPath: String) -> String? {
|
||||
switch self {
|
||||
case .none: return nil
|
||||
case .empty: return ""
|
||||
case .D50: return "D50"
|
||||
case .D65: return "D65"
|
||||
case .custom: return customPath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PageSize {
|
||||
/// `"210x297"` custom page parse used by presets (issue #82).
|
||||
public static func parseCustom(_ raw: String) -> (Double, Double)? {
|
||||
let parts = raw.lowercased().split(separator: "x")
|
||||
guard parts.count == 2,
|
||||
let w = Double(parts[0]), let h = Double(parts[1]),
|
||||
w >= 50, h >= 50 else { return nil }
|
||||
return (w, h)
|
||||
}
|
||||
}
|
||||
|
||||
extension ProfilingPreset {
|
||||
/// Snapshot of the three live configs plus calibration toggles.
|
||||
public init(
|
||||
id: String,
|
||||
name: String,
|
||||
description: String,
|
||||
targen: TargenConfig,
|
||||
printtarg: PrinttargConfig,
|
||||
colprof: ColprofConfig,
|
||||
calibrationFile: String?,
|
||||
applyCalibration: Bool?
|
||||
) {
|
||||
let pageSize: String
|
||||
if printtarg.pageSize == .custom {
|
||||
pageSize = "\(Int(printtarg.customPageWidth))x\(Int(printtarg.customPageHeight))"
|
||||
} else {
|
||||
pageSize = printtarg.pageSize.rawValue
|
||||
}
|
||||
self.init(
|
||||
id: id,
|
||||
name: name,
|
||||
description: description,
|
||||
colourSpace: targen.colourSpace == .cmyk ? "cmyk" : "rgb",
|
||||
patchCount: targen.patchCount,
|
||||
whitePatches: targen.whitePatches,
|
||||
blackPatches: targen.blackPatches,
|
||||
greySteps: targen.greySteps,
|
||||
singleChannelSteps: targen.singleChannelSteps,
|
||||
neutralSteps: targen.neutralSteps,
|
||||
neutralConcentration: targen.neutralConcentration,
|
||||
preconditioningProfile: targen.preconditioningProfile,
|
||||
ofpsHighQuality: targen.ofpsHighQuality,
|
||||
ofpsAdaptation: targen.ofpsAdaptation,
|
||||
fullSpreadAlgorithm: (targen.fullSpreadAlgorithm ?? .ofps).presetValue,
|
||||
totalInkLimit: targen.totalInkLimit,
|
||||
darkEmphasis: targen.darkEmphasis,
|
||||
devicePower: targen.devicePower,
|
||||
instrument: printtarg.instrument.rawValue,
|
||||
pageSize: pageSize,
|
||||
bitDepth: printtarg.bitDepth.rawValue,
|
||||
dpi: printtarg.dpi,
|
||||
randomSeed: printtarg.layoutOrder == .deterministic ? 1 : printtarg.customSeed,
|
||||
noRandomize: printtarg.layoutOrder == .raster,
|
||||
calibrationFile: calibrationFile,
|
||||
applyCalibration: applyCalibration,
|
||||
colprofAlgorithm: colprof.algorithm,
|
||||
colprofQuality: colprof.quality,
|
||||
colprofIntent: colprof.intent,
|
||||
colprofFwa: colprof.fwa,
|
||||
colprofIlluminant: colprof.illuminant,
|
||||
colprofObserver: colprof.observer,
|
||||
colprofInputViewingCond: colprof.inputViewingCond,
|
||||
colprofOutputViewingCond: colprof.outputViewingCond
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -60,9 +60,7 @@ public final class PresetStore: Sendable {
|
||||
|
||||
/// Single-preset pretty JSON export.
|
||||
public func export(_ preset: ProfilingPreset) throws -> Data {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
return try encoder.encode(preset)
|
||||
return try JSONEncoder.icceryPretty().encode(preset)
|
||||
}
|
||||
|
||||
/// Parses + validates a preset from JSON. The preset is assigned a
|
||||
|
||||
@@ -4,8 +4,8 @@ import Foundation
|
||||
/// `~/Library/Application Support/com.gronod.iccery2/settings.json`
|
||||
/// (issue #5 — the v1 path is never read).
|
||||
///
|
||||
/// Writes are atomic (`AtomicFileWriter`). Invalid/corrupt JSON falls
|
||||
/// back to defaults. Saving posts `settingsDidChange` so #20 can
|
||||
/// Writes are atomic (`JSONFileStore` → `AtomicFileWriter`). Invalid/corrupt
|
||||
/// JSON falls back to defaults. Saving posts `settingsDidChange` so #20 can
|
||||
/// reclassify swatches.
|
||||
public final class SettingsStore: Sendable {
|
||||
|
||||
@@ -14,18 +14,19 @@ public final class SettingsStore: Sendable {
|
||||
Notification.Name("com.gronod.iccery2.settingsDidChange")
|
||||
|
||||
public let fileURL: URL
|
||||
private let store: JSONFileStore<AppSettings>
|
||||
|
||||
public init(fileURL: URL = AppPaths.appDataDir.appendingPathComponent("settings.json")) {
|
||||
self.fileURL = fileURL
|
||||
self.store = JSONFileStore(
|
||||
fileURL: fileURL,
|
||||
corrupt: .replaceWithDefault,
|
||||
defaultValue: { .default }
|
||||
)
|
||||
}
|
||||
|
||||
public func load() -> AppSettings {
|
||||
guard let data = try? Data(contentsOf: fileURL),
|
||||
let settings = try? JSONDecoder().decode(AppSettings.self, from: data)
|
||||
else {
|
||||
return .default
|
||||
}
|
||||
return settings
|
||||
(try? store.load()) ?? .default
|
||||
}
|
||||
|
||||
/// Validates before persisting — throws `SettingsError` listing
|
||||
@@ -35,9 +36,7 @@ public final class SettingsStore: Sendable {
|
||||
guard errors.isEmpty else {
|
||||
throw SettingsError.validationFailed(errors)
|
||||
}
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
try AtomicFileWriter.write(encoder.encode(settings), to: fileURL)
|
||||
try store.save(settings)
|
||||
NotificationCenter.default.post(name: Self.settingsDidChange, object: nil)
|
||||
}
|
||||
|
||||
|
||||
@@ -74,23 +74,24 @@ public struct WizardState: Codable, Equatable, Sendable {
|
||||
/// Atomic JSON persistence for `WizardState` (issue #4).
|
||||
public final class WizardStateStore: Sendable {
|
||||
public let fileURL: URL
|
||||
private let store: JSONFileStore<WizardState>
|
||||
|
||||
public init(
|
||||
fileURL: URL = AppPaths.appDataDir.appendingPathComponent("wizard_state.json")
|
||||
) {
|
||||
self.fileURL = fileURL
|
||||
self.store = JSONFileStore(
|
||||
fileURL: fileURL,
|
||||
corrupt: .replaceWithDefault,
|
||||
defaultValue: { .default }
|
||||
)
|
||||
}
|
||||
|
||||
public func load() -> WizardState {
|
||||
guard let data = try? Data(contentsOf: fileURL),
|
||||
let state = try? JSONDecoder().decode(WizardState.self, from: data)
|
||||
else { return .default }
|
||||
return state
|
||||
(try? store.load()) ?? .default
|
||||
}
|
||||
|
||||
public func save(_ state: WizardState) throws {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
try AtomicFileWriter.write(encoder.encode(state), to: fileURL)
|
||||
try store.save(state)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ All measurement, chart generation, and profile mathematics live in the [Gronod A
|
||||
| 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 | UAT-ready hardening of the v2.0 wizard paths |
|
||||
| M7 | Pre-UAT hardening & baseline consolidation — shipped on `develop` |
|
||||
| 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
|
||||
@@ -170,11 +171,11 @@ Agent / branch rules: [`AGENTS.md`](AGENTS.md), [`BUILD-PLAN.md`](BUILD-PLAN.md)
|
||||
|
||||
```
|
||||
develop
|
||||
└── milestone/mN-<slug> # integration only
|
||||
└── 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. M7 is small; its PRs target `develop` directly. 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
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ import ICCeryCore
|
||||
|
||||
/// Stage 0 calibration dashboard (issue #29, docs/07).
|
||||
struct CalibrationView: View {
|
||||
@Bindable var model: CalibrationViewModel
|
||||
@ObservedObject var model: CalibrationViewModel
|
||||
@ObservedObject var wizard: WizardViewModel
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
@@ -51,11 +52,15 @@ struct CalibrationView: View {
|
||||
HStack(spacing: 12) {
|
||||
Button("Generate Target") { model.generateTarget() }
|
||||
.accessibilityIdentifier("btnCalGenerate")
|
||||
.disabled(!model.canGenerate)
|
||||
.disabled(wizard.basename.isEmpty
|
||||
|| wizard.effectiveWorkingDirectory == nil
|
||||
|| model.isGenerating)
|
||||
|
||||
Button("Create Layout & Print") { model.createLayout() }
|
||||
.accessibilityIdentifier("btnCalLayout")
|
||||
.disabled(!model.canGenerate)
|
||||
.disabled(wizard.basename.isEmpty
|
||||
|| wizard.effectiveWorkingDirectory == nil
|
||||
|| model.isGenerating)
|
||||
|
||||
Button("Measure") { model.measureChart() }
|
||||
.accessibilityIdentifier("btnCalMeasure")
|
||||
@@ -68,7 +73,7 @@ struct CalibrationView: View {
|
||||
|
||||
if let url = model.computedCalURL {
|
||||
Toggle("Apply calibration to next profile", isOn: $model.applyToProfile)
|
||||
.onChange(of: model.applyToProfile) { model.updateApplyToProfile() }
|
||||
.onChange(of: model.applyToProfile) { _ in model.updateApplyToProfile() }
|
||||
.accessibilityIdentifier("calApplyToggle")
|
||||
|
||||
Text("Loaded: \(url.lastPathComponent)")
|
||||
@@ -90,15 +95,7 @@ struct CalibrationView: View {
|
||||
.frame(minHeight: 80, maxHeight: 120)
|
||||
}
|
||||
}
|
||||
|
||||
if let error = model.lastError {
|
||||
Section {
|
||||
Text(error)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
import Observation
|
||||
import SwiftUI
|
||||
import ICCeryCore
|
||||
|
||||
/// Stage 0 calibration workflow: generate wedge, print, measure, and
|
||||
/// compute `.cal` curves.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class CalibrationViewModel {
|
||||
final class CalibrationViewModel: ObservableObject {
|
||||
|
||||
let workflow: TargetWorkflowViewModel
|
||||
let profile: ProfileWorkflowViewModel
|
||||
@@ -15,17 +14,16 @@ final class CalibrationViewModel {
|
||||
|
||||
// MARK: - Form state
|
||||
|
||||
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?
|
||||
@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
|
||||
|
||||
init(workflow: TargetWorkflowViewModel, profile: ProfileWorkflowViewModel, environment: AppEnvironment) {
|
||||
self.workflow = workflow
|
||||
@@ -50,14 +48,15 @@ final class CalibrationViewModel {
|
||||
return cwd.appendingPathComponent("\(calBasename).ti3")
|
||||
}
|
||||
|
||||
private var calBasename: String {
|
||||
if wizard.basename.hasPrefix("CAL_") { return wizard.basename }
|
||||
let original = !wizard.calibrationOriginalBasename.isEmpty
|
||||
? wizard.calibrationOriginalBasename
|
||||
: wizard.basename
|
||||
return "CAL_\(original)"
|
||||
private var identity: CalibrationIdentity {
|
||||
CalibrationIdentity.parse(
|
||||
liveBasename: wizard.basename,
|
||||
persistedOriginal: wizard.calibrationOriginalBasename
|
||||
)
|
||||
}
|
||||
|
||||
private var calBasename: String { identity.calibrationBasename }
|
||||
|
||||
private var calOutputURL: URL? {
|
||||
guard let cwd = wizard.effectiveWorkingDirectory else { return nil }
|
||||
return cwd.appendingPathComponent("\(calBasename).cal")
|
||||
@@ -68,43 +67,38 @@ final class CalibrationViewModel {
|
||||
func generateTarget() {
|
||||
guard canGenerate, let cwd = wizard.effectiveWorkingDirectory else { return }
|
||||
// Snapshot the original (pre-CAL_) basename before changing the live one.
|
||||
if !wizard.basename.hasPrefix("CAL_") {
|
||||
wizard.calibrationOriginalBasename = wizard.basename
|
||||
} else if wizard.calibrationOriginalBasename.isEmpty {
|
||||
wizard.calibrationOriginalBasename = String(wizard.basename.dropFirst(4))
|
||||
}
|
||||
let original = wizard.calibrationOriginalBasename
|
||||
wizard.basename = "CAL_\(original)"
|
||||
let identity = CalibrationIdentity.parse(
|
||||
liveBasename: wizard.basename,
|
||||
persistedOriginal: wizard.calibrationOriginalBasename
|
||||
)
|
||||
wizard.calibrationOriginalBasename = identity.originalBasename
|
||||
wizard.basename = identity.calibrationBasename
|
||||
wizard.sessionMode = .calibration
|
||||
|
||||
isGenerating = true
|
||||
calibrationLog = []
|
||||
lastError = nil
|
||||
|
||||
let config = CalibrationTargenConfig(
|
||||
colourSpace: colourSpace,
|
||||
steps: steps,
|
||||
whitePatches: whitePatches,
|
||||
includeNeutralEmphasis: includeNeutralEmphasis,
|
||||
inkLimit: inkLimitValue,
|
||||
basename: original,
|
||||
basename: identity.originalBasename,
|
||||
workingDirectory: cwd
|
||||
)
|
||||
|
||||
Task { @MainActor in
|
||||
defer { self.isGenerating = false }
|
||||
|
||||
do {
|
||||
_ = try await self.environment.runner.runCalibrationTargen(config: config) { batch in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.calibrationLog.append(contentsOf: batch)
|
||||
}
|
||||
_ = 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)
|
||||
}
|
||||
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
|
||||
@@ -139,15 +133,13 @@ final class CalibrationViewModel {
|
||||
// "already exists" when the user declines overwrite. We do not
|
||||
// silently clobber.
|
||||
if FileManager.default.fileExists(atPath: outputURL.path) {
|
||||
lastError = "\(outputURL.lastPathComponent) already exists. Rename or overwrite it first."
|
||||
wizard.showNotice(lastError!, kind: .error)
|
||||
wizard.showNotice(
|
||||
"\(outputURL.lastPathComponent) already exists. Rename or overwrite it first.",
|
||||
kind: .error
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
isComputing = true
|
||||
calibrationLog = []
|
||||
lastError = nil
|
||||
|
||||
let config = PrintcalConfig(
|
||||
ti3Basename: calBasename,
|
||||
workingDirectory: cwd,
|
||||
@@ -160,13 +152,14 @@ final class CalibrationViewModel {
|
||||
)
|
||||
|
||||
Task { @MainActor in
|
||||
defer { self.isComputing = false }
|
||||
|
||||
do {
|
||||
let url = try await self.environment.runner.runPrintcal(config: config) { batch in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.calibrationLog.append(contentsOf: batch)
|
||||
}
|
||||
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)
|
||||
}
|
||||
self.computedCalURL = url
|
||||
self.profile.calibrationFile = url.path
|
||||
@@ -174,7 +167,6 @@ final class CalibrationViewModel {
|
||||
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
|
||||
|
||||
@@ -29,13 +29,8 @@ final class FileDialogService {
|
||||
|
||||
/// `selectTargetFile` — **save** panel for the new `.ti1` target.
|
||||
func selectTargetFile(startingAt start: URL? = nil) -> URL? {
|
||||
let panel = NSSavePanel()
|
||||
panel.nameFieldStringValue = "target.ti1"
|
||||
panel.allowedContentTypes = utTypes(["ti1"])
|
||||
panel.allowsOtherFileTypes = false
|
||||
panel.directoryURL = start
|
||||
panel.message = "Choose the .ti1 target file to create"
|
||||
return run(panel)
|
||||
save(named: "target.ti1", extensions: ["ti1"], startingAt: start,
|
||||
message: "Choose the .ti1 target file to create")
|
||||
}
|
||||
|
||||
/// `selectExistingTarget` — open `.ti1`/`.ti2` (docs/06 §Resume, #140).
|
||||
@@ -66,12 +61,7 @@ final class FileDialogService {
|
||||
|
||||
/// `selectCsvSavePath` — verification-history CSV export.
|
||||
func selectCsvSavePath(startingAt start: URL? = nil) -> URL? {
|
||||
let panel = NSSavePanel()
|
||||
panel.nameFieldStringValue = "verification-history.csv"
|
||||
panel.allowedContentTypes = utTypes(["csv"])
|
||||
panel.allowsOtherFileTypes = false
|
||||
panel.directoryURL = start
|
||||
return run(panel)
|
||||
save(named: "verification-history.csv", extensions: ["csv"], startingAt: start)
|
||||
}
|
||||
|
||||
/// `selectCalFile` — `.cal` calibration curves.
|
||||
@@ -88,17 +78,27 @@ final class FileDialogService {
|
||||
|
||||
/// `btnExportActivePreset` — save a `.json` preset file.
|
||||
func selectPresetSavePath(name: String, startingAt start: URL? = nil) -> URL? {
|
||||
let panel = NSSavePanel()
|
||||
panel.nameFieldStringValue = "\(name).json"
|
||||
panel.allowedContentTypes = utTypes(["json"])
|
||||
panel.allowsOtherFileTypes = false
|
||||
panel.directoryURL = start
|
||||
panel.message = "Export this preset as JSON"
|
||||
return run(panel)
|
||||
save(named: "\(name).json", extensions: ["json"], startingAt: start,
|
||||
message: "Export this preset as JSON")
|
||||
}
|
||||
|
||||
// MARK: - Internals (private — not a shared public picker API)
|
||||
|
||||
private func save(
|
||||
named: String,
|
||||
extensions: [String],
|
||||
startingAt start: URL?,
|
||||
message: String? = nil
|
||||
) -> URL? {
|
||||
let panel = NSSavePanel()
|
||||
panel.nameFieldStringValue = named
|
||||
panel.allowedContentTypes = utTypes(extensions)
|
||||
panel.allowsOtherFileTypes = false
|
||||
panel.directoryURL = start
|
||||
if let message { panel.message = message }
|
||||
return run(panel)
|
||||
}
|
||||
|
||||
private func open(
|
||||
extensions: [String],
|
||||
startingAt start: URL?,
|
||||
|
||||
@@ -69,12 +69,12 @@ internal struct GamutSceneGeometryBuilder {
|
||||
/// a* (green-red) axis is horizontal, L* (lightness) is vertical, and b*
|
||||
/// (blue-yellow) is depth.
|
||||
struct GamutView: View {
|
||||
@State private var viewModel: GamutViewModel
|
||||
@StateObject private var viewModel: GamutViewModel
|
||||
@State private var pause: () -> Void = {}
|
||||
@FocusState private var isFocused: Bool
|
||||
|
||||
init(profileGamURL: URL? = nil) {
|
||||
_viewModel = State(wrappedValue: GamutViewModel(profileGamURL: profileGamURL))
|
||||
_viewModel = StateObject(wrappedValue: GamutViewModel(profileGamURL: profileGamURL))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -87,11 +87,6 @@ struct GamutView: View {
|
||||
)
|
||||
.focusable()
|
||||
.focused($isFocused)
|
||||
.focusEffectDisabled()
|
||||
.onKeyPress(.init("R"), action: {
|
||||
viewModel.resetCamera()
|
||||
return .handled
|
||||
})
|
||||
.onAppear { isFocused = true }
|
||||
|
||||
VStack {
|
||||
@@ -148,6 +143,7 @@ private struct GamutSceneView: NSViewRepresentable {
|
||||
context.coordinator.scnView = scnView
|
||||
context.coordinator.scene = scene
|
||||
context.coordinator.buildScene(profile: profileMesh, reference: referenceMesh)
|
||||
context.coordinator.installKeyMonitor()
|
||||
|
||||
return scnView
|
||||
}
|
||||
@@ -168,6 +164,7 @@ private struct GamutSceneView: NSViewRepresentable {
|
||||
}
|
||||
|
||||
static func dismantleNSView(_ nsView: SCNView, coordinator: Coordinator) {
|
||||
coordinator.removeKeyMonitor()
|
||||
nsView.isPlaying = false
|
||||
}
|
||||
|
||||
@@ -175,6 +172,7 @@ private struct GamutSceneView: NSViewRepresentable {
|
||||
final class Coordinator: NSObject {
|
||||
weak var scnView: SCNView?
|
||||
weak var scene: SCNScene?
|
||||
private var keyMonitor: Any?
|
||||
|
||||
private let profileNode = SCNNode()
|
||||
private let referenceGroup = SCNNode()
|
||||
@@ -431,6 +429,31 @@ 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 }
|
||||
|
||||
|
||||
@@ -1,26 +1,25 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
import ICCeryCore
|
||||
import Observation
|
||||
|
||||
/// View model for the native SceneKit gamut viewer.
|
||||
///
|
||||
/// Loads the bundled `sRGB.gam` reference immediately and, optionally, a
|
||||
/// printer/profile `.gam` from the current working directory.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class GamutViewModel {
|
||||
final class GamutViewModel: ObservableObject {
|
||||
|
||||
/// Parsed reference sRGB gamut mesh.
|
||||
var sRGBMesh: GamutMesh?
|
||||
@Published var sRGBMesh: GamutMesh?
|
||||
|
||||
/// Parsed printer/profile gamut mesh.
|
||||
var profileMesh: GamutMesh?
|
||||
@Published var profileMesh: GamutMesh?
|
||||
|
||||
/// User-facing status line.
|
||||
var status = "Loading gamut…"
|
||||
@Published var status = "Loading gamut…"
|
||||
|
||||
/// Closure injected into the SceneKit view to request a camera reset.
|
||||
var resetCamera: () -> Void = {}
|
||||
@Published var resetCamera: () -> Void = {}
|
||||
|
||||
private let profileGamURL: URL?
|
||||
|
||||
|
||||
@@ -5,11 +5,11 @@ import SwiftUI
|
||||
@main
|
||||
struct ICCeryApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
@State private var workflow: TargetWorkflowViewModel
|
||||
@StateObject private var workflow: TargetWorkflowViewModel
|
||||
|
||||
init() {
|
||||
let environment = AppEnvironment.live()
|
||||
_workflow = State(initialValue: TargetWorkflowViewModel(environment: environment))
|
||||
_workflow = StateObject(wrappedValue: 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,15 +17,17 @@ struct ICCeryApp: App {
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
// Single fixed window (docs/21 §Shell: 1280×800, min 1100×700).
|
||||
Window("ICCery", id: "main") {
|
||||
// Single fixed window (docs/21 §Shell: 1280×800, min 1100×700);
|
||||
// metrics are applied by AppDelegate once the window exists.
|
||||
WindowGroup("ICCery") {
|
||||
RootView(workflow: workflow)
|
||||
.frame(minWidth: 1100, minHeight: 700)
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
.defaultSize(width: 1280, height: 800)
|
||||
.windowResizability(.contentMinSize)
|
||||
.defaultPosition(.center)
|
||||
.commands {
|
||||
// Single-window app: no File > New window.
|
||||
CommandGroup(replacing: .newItem) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +38,31 @@ struct ICCeryApp: App {
|
||||
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).
|
||||
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,8 +32,7 @@ enum XYStep: Equatable, Sendable {
|
||||
|
||||
/// Stage 3 workflow state and interaction (issues #18–#22).
|
||||
@MainActor
|
||||
@Observable
|
||||
final class MeasurementWorkflowViewModel {
|
||||
final class MeasurementWorkflowViewModel: ObservableObject {
|
||||
|
||||
// MARK: - Authorities
|
||||
|
||||
@@ -42,37 +41,37 @@ final class MeasurementWorkflowViewModel {
|
||||
|
||||
// MARK: - Settings-driven thresholds
|
||||
|
||||
private(set) var goodMax: Double = 2.0
|
||||
private(set) var warningMax: Double = 5.0
|
||||
private(set) var enableLEDs: Bool = false
|
||||
@Published private(set) var goodMax: Double = 2.0
|
||||
@Published private(set) var warningMax: Double = 5.0
|
||||
@Published private(set) var enableLEDs: Bool = false
|
||||
|
||||
// MARK: - Instrument detection
|
||||
|
||||
var instruments: [InstrumentDevice] = []
|
||||
var selectedInstrument: InstrumentSelection = .auto
|
||||
var isDetecting = false
|
||||
var detectionError: String?
|
||||
@Published var instruments: [InstrumentDevice] = []
|
||||
@Published var selectedInstrument: InstrumentSelection = .auto
|
||||
@Published var isDetecting = false
|
||||
@Published var detectionError: String?
|
||||
|
||||
// MARK: - Chartread session
|
||||
|
||||
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?
|
||||
@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?
|
||||
private var chartreadTask: Task<Void, Never>?
|
||||
|
||||
// MARK: - Averaging
|
||||
|
||||
var passSnapshots: [URL] = []
|
||||
var isFinishing = false
|
||||
var finishNotice: String?
|
||||
var finishNoticeIsError = false
|
||||
var resumedFromTi2 = false
|
||||
@Published var passSnapshots: [URL] = []
|
||||
@Published var isFinishing = false
|
||||
@Published var finishNotice: Notice?
|
||||
@Published var resumedFromTi2 = false
|
||||
|
||||
init(wizard: WizardViewModel, environment: AppEnvironment) {
|
||||
self.wizard = wizard
|
||||
@@ -186,7 +185,7 @@ final class MeasurementWorkflowViewModel {
|
||||
isChartreadRunning = true
|
||||
chartreadState = .idle
|
||||
currentPrompt = nil
|
||||
lastError = nil
|
||||
chartreadNotice = nil
|
||||
chartreadLog.removeAll()
|
||||
|
||||
// Optional: reset rows when starting a fresh first pass.
|
||||
@@ -228,7 +227,10 @@ final class MeasurementWorkflowViewModel {
|
||||
|
||||
case .exit(let code):
|
||||
if code != 0 {
|
||||
lastError = "chartread exited with code \(code)"
|
||||
chartreadNotice = Notice(
|
||||
kind: .error,
|
||||
text: "chartread exited with code \(code)"
|
||||
)
|
||||
}
|
||||
|
||||
case .completed(let canonicalURL):
|
||||
@@ -236,7 +238,7 @@ final class MeasurementWorkflowViewModel {
|
||||
completePass(canonicalURL: canonicalURL)
|
||||
|
||||
case .failed(let error):
|
||||
lastError = error.localizedDescription
|
||||
chartreadNotice = Notice(kind: .error, text: error.localizedDescription)
|
||||
chartreadState = .error
|
||||
isChartreadRunning = false
|
||||
}
|
||||
@@ -382,7 +384,10 @@ final class MeasurementWorkflowViewModel {
|
||||
discoverPassSnapshots()
|
||||
wizard.refreshGating()
|
||||
} catch {
|
||||
lastError = "Could not snapshot pass: \(error.localizedDescription)"
|
||||
chartreadNotice = Notice(
|
||||
kind: .error,
|
||||
text: "Could not snapshot pass: \(error.localizedDescription)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,33 +403,32 @@ final class MeasurementWorkflowViewModel {
|
||||
|
||||
func finishAndAverage() {
|
||||
guard !isFinishing, let cwd = workingDirectory, !passSnapshots.isEmpty else { return }
|
||||
isFinishing = true
|
||||
finishNotice = nil
|
||||
finishNoticeIsError = false
|
||||
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
let canonical: URL
|
||||
// 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 {
|
||||
canonical = try MeasurementArtefacts.promotePass(
|
||||
return try MeasurementArtefacts.promotePass(
|
||||
pass: pass,
|
||||
basename: self.basename,
|
||||
cwd: cwd
|
||||
)
|
||||
} else {
|
||||
}
|
||||
let config = AverageConfig(
|
||||
workingDirectory: cwd,
|
||||
basename: self.basename,
|
||||
passFiles: self.passSnapshots
|
||||
)
|
||||
canonical = try await self.environment.runner.runAverage(
|
||||
return try await self.environment.runner.runAverage(
|
||||
config: config,
|
||||
onLogBatch: { [weak self] batch in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.chartreadLog.append(contentsOf: batch)
|
||||
}
|
||||
}
|
||||
onLogBatch: onLog
|
||||
)
|
||||
}
|
||||
self.discoverPassSnapshots()
|
||||
@@ -432,7 +436,11 @@ final class MeasurementWorkflowViewModel {
|
||||
if self.wizard.isUnlocked(.buildProfile) {
|
||||
self.wizard.go(to: .buildProfile)
|
||||
} else {
|
||||
self.finishNotice = "Finished: \(canonical.lastPathComponent) ready."
|
||||
self.finishNotice = Notice(
|
||||
kind: .info,
|
||||
text: "Finished: \(canonical.lastPathComponent) ready.",
|
||||
autoHideAfter: nil
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// Fallback to pass 1 promotion if averaging failed.
|
||||
@@ -445,18 +453,26 @@ final class MeasurementWorkflowViewModel {
|
||||
)
|
||||
self.discoverPassSnapshots()
|
||||
self.wizard.refreshGating()
|
||||
self.finishNotice = "Averaging failed — promoted first pass."
|
||||
self.finishNoticeIsError = true
|
||||
self.finishNotice = Notice(
|
||||
kind: .error,
|
||||
text: "Averaging failed — promoted first pass.",
|
||||
autoHideAfter: nil
|
||||
)
|
||||
} catch {
|
||||
self.finishNotice = "Finish failed: \(error.localizedDescription)"
|
||||
self.finishNoticeIsError = true
|
||||
self.finishNotice = Notice(
|
||||
kind: .error,
|
||||
text: "Finish failed: \(error.localizedDescription)",
|
||||
autoHideAfter: nil
|
||||
)
|
||||
}
|
||||
} else {
|
||||
self.finishNotice = "Finish failed: \(error.localizedDescription)"
|
||||
self.finishNoticeIsError = true
|
||||
self.finishNotice = Notice(
|
||||
kind: .error,
|
||||
text: "Finish failed: \(error.localizedDescription)",
|
||||
autoHideAfter: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
self.isFinishing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,14 @@ 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()
|
||||
|
||||
@@ -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 {
|
||||
@Bindable var workflow: TargetWorkflowViewModel
|
||||
@ObservedObject 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 {
|
||||
@Bindable var workflow: TargetWorkflowViewModel
|
||||
@ObservedObject var workflow: TargetWorkflowViewModel
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
import ICCeryCore
|
||||
|
||||
/// CUPS queue selection, bound print panel, and `lp` spool (issues 12–15, 17 / #85).
|
||||
@MainActor
|
||||
final class PrintSessionViewModel: ObservableObject {
|
||||
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
|
||||
private var printTask: Task<Void, Never>?
|
||||
|
||||
init(wizard: WizardViewModel, environment: AppEnvironment) {
|
||||
self.wizard = wizard
|
||||
self.environment = environment
|
||||
}
|
||||
|
||||
func refreshPrinters() {
|
||||
let cups = environment.cupsService
|
||||
Task { @MainActor in
|
||||
do {
|
||||
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 reloadSelectedCapabilities()
|
||||
} catch {
|
||||
printNotice = Notice(
|
||||
kind: .error,
|
||||
text: "Could not list printers: \(error.localizedDescription)"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func reloadSelectedCapabilities() async {
|
||||
guard !selectedPrinter.isEmpty else {
|
||||
printerCaps = PrinterCapabilities()
|
||||
return
|
||||
}
|
||||
do {
|
||||
printerCaps = try await environment.cupsService
|
||||
.capabilities(for: selectedPrinter)
|
||||
if selectedMediaType == nil {
|
||||
selectedMediaType = printerCaps.mediaTypes.first?.id
|
||||
}
|
||||
if selectedTray == nil {
|
||||
selectedTray = printerCaps.trays.first?.id
|
||||
}
|
||||
} catch {
|
||||
printerCaps = PrinterCapabilities()
|
||||
}
|
||||
}
|
||||
|
||||
func openPrinterPreferences() {
|
||||
guard !selectedPrinter.isEmpty else { return }
|
||||
let queue = selectedPrinter
|
||||
let displayName = printers.first { $0.name == queue }?.displayName
|
||||
let cups = environment.cupsService
|
||||
Task { @MainActor in
|
||||
do {
|
||||
guard let result = try await PrintPanelService()
|
||||
.showProperties(
|
||||
queue: queue, displayName: displayName,
|
||||
cupsService: cups)
|
||||
else {
|
||||
printNotice = Notice(
|
||||
kind: .info,
|
||||
text: "Printer properties dialog cancelled.",
|
||||
autoHideAfter: nil
|
||||
)
|
||||
return
|
||||
}
|
||||
if let selected = result.selectedPrinter,
|
||||
printers.contains(where: { $0.name == selected }),
|
||||
selected != queue {
|
||||
selectedPrinter = selected
|
||||
await reloadSelectedCapabilities()
|
||||
}
|
||||
if let captured = result.options.cupsOptions {
|
||||
capturedCupsOptions[selectedPrinter] = captured
|
||||
}
|
||||
if let media = result.options.mediaType {
|
||||
selectedMediaType = media
|
||||
}
|
||||
printNotice = Notice(
|
||||
kind: .info,
|
||||
text: "Settings captured for \(selectedPrinter).",
|
||||
autoHideAfter: nil
|
||||
)
|
||||
} catch {
|
||||
printNotice = Notice(kind: .error, text: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func printAllPages(from result: PrinttargResult, pageSize: PageSize) {
|
||||
guard !isPrinting else { return }
|
||||
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).
|
||||
var printed = 0
|
||||
for page in result.pages {
|
||||
do {
|
||||
try await spool(page, index: page.index, pageSize: pageSize)
|
||||
printed += 1
|
||||
} catch {
|
||||
printNotice = Notice(
|
||||
kind: .error,
|
||||
text: "Print failed on \(page.page.filename): "
|
||||
+ error.localizedDescription
|
||||
)
|
||||
isPrinting = false
|
||||
self.printTask = nil
|
||||
return
|
||||
}
|
||||
}
|
||||
printNotice = Notice(
|
||||
kind: .info,
|
||||
text: "Sent \(printed) page(s) to \(selectedPrinter).",
|
||||
autoHideAfter: nil
|
||||
)
|
||||
isPrinting = false
|
||||
self.printTask = nil
|
||||
}
|
||||
printTask = task
|
||||
}
|
||||
|
||||
func printPage(_ page: GalleryPage, pageSize: PageSize) {
|
||||
guard !isPrinting else { return }
|
||||
isPrinting = true
|
||||
let task = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
try await spool(page, index: page.index, pageSize: pageSize)
|
||||
printNotice = Notice(
|
||||
kind: .info,
|
||||
text: "Sent \(page.page.filename) to \(selectedPrinter).",
|
||||
autoHideAfter: nil
|
||||
)
|
||||
} catch {
|
||||
printNotice = Notice(
|
||||
kind: .error,
|
||||
text: "Print failed: \(error.localizedDescription)"
|
||||
)
|
||||
}
|
||||
isPrinting = false
|
||||
self.printTask = nil
|
||||
}
|
||||
printTask = task
|
||||
}
|
||||
|
||||
private func spool(_ page: GalleryPage, index: Int, pageSize: PageSize) async throws {
|
||||
guard !selectedPrinter.isEmpty else {
|
||||
throw CupsError.noPrinterSelected
|
||||
}
|
||||
let options = PrintOptions(
|
||||
orientation: printOrientation,
|
||||
paperSize: pageSize == .custom ? nil : pageSize.rawValue,
|
||||
mediaType: selectedMediaType,
|
||||
ppdUncorrectedPassthrough: true,
|
||||
cupsOptions: capturedCupsOptions[selectedPrinter])
|
||||
try await environment.cupsService.printTarget(
|
||||
queue: selectedPrinter,
|
||||
tiffPath: page.fileURL.path,
|
||||
options: options,
|
||||
page: index)
|
||||
wizard.printerName = selectedPrinter
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Shared monospaced process-log disclosure used by Stage 1 and Stage 2.
|
||||
struct ProcessLogView: View {
|
||||
let lines: [String]
|
||||
var minHeight: CGFloat = 120
|
||||
var maxHeight: CGFloat = 200
|
||||
var containerId: String
|
||||
var logId: String
|
||||
|
||||
var body: some View {
|
||||
DisclosureGroup("Process log") {
|
||||
ScrollView {
|
||||
Text(lines.joined(separator: "\n"))
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(Theme.text)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.frame(minHeight: minHeight, maxHeight: maxHeight)
|
||||
.accessibilityIdentifier(logId)
|
||||
}
|
||||
.foregroundStyle(Theme.text)
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier(containerId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Foundation
|
||||
|
||||
/// Shared hop for coalesced Argyll log batches (issue #80).
|
||||
/// The runner invokes the sink off the main actor; this is the single hop back.
|
||||
enum ProcessRunSupport {
|
||||
static func logSink(
|
||||
_ apply: @escaping @MainActor @Sendable ([String]) -> Void
|
||||
) -> @Sendable ([String]) -> Void {
|
||||
{ batch in
|
||||
Task { @MainActor in apply(batch) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps a runner call with running/log bookkeeping.
|
||||
/// `work` stays on the main actor so `T` does not cross isolation
|
||||
/// (Swift 6: non-Sendable generic return from a nonisolated async fn).
|
||||
@MainActor
|
||||
static func runLogged<T>(
|
||||
setRunning: (Bool) -> Void,
|
||||
resetLog: () -> Void,
|
||||
onLog: @escaping @MainActor @Sendable ([String]) -> Void,
|
||||
work: @MainActor @escaping (@escaping @Sendable ([String]) -> Void) async throws -> T
|
||||
) async throws -> T {
|
||||
setRunning(true)
|
||||
resetLog()
|
||||
defer { setRunning(false) }
|
||||
return try await work(logSink(onLog))
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,11 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
import Observation
|
||||
import SwiftUI
|
||||
import ICCeryCore
|
||||
|
||||
/// User-facing FWA selection for the Stage 4 form.
|
||||
enum ColprofFwaSelection: String, CaseIterable, Sendable, Equatable {
|
||||
case none = "none"
|
||||
case empty = ""
|
||||
case D50 = "D50"
|
||||
case D65 = "D65"
|
||||
case custom = "custom"
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .none: return "None"
|
||||
case .empty: return "Bare (-f)"
|
||||
case .D50: return "D50"
|
||||
case .D65: return "D65"
|
||||
case .custom: return "Custom .sp"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stage 4/5 workflow: build a profile, verify it, track drift, and install.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ProfileWorkflowViewModel {
|
||||
final class ProfileWorkflowViewModel: ObservableObject {
|
||||
|
||||
let wizard: WizardViewModel
|
||||
let environment: AppEnvironment
|
||||
@@ -33,51 +13,50 @@ final class ProfileWorkflowViewModel {
|
||||
|
||||
// MARK: - Stage 4 form
|
||||
|
||||
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 = ""
|
||||
@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 = ""
|
||||
|
||||
// MARK: - Run state
|
||||
|
||||
var isColprofRunning = false
|
||||
var colprofLog: [String] = []
|
||||
var colprofProgress: String?
|
||||
var lastError: String?
|
||||
var createdProfileURL: URL?
|
||||
@Published var isColprofRunning = false
|
||||
@Published var colprofLog: [String] = []
|
||||
@Published var colprofProgress: String?
|
||||
@Published var createdProfileURL: URL?
|
||||
/// Path to the `.gam` gamut mesh extracted post-`colprof` (issue #28).
|
||||
var createdGamutURL: URL?
|
||||
@Published var createdGamutURL: URL?
|
||||
|
||||
// MARK: - Stage 4/5 calibration (issue #24)
|
||||
|
||||
var applyCalibration = false
|
||||
var calibrationFile: String = ""
|
||||
@Published var applyCalibration = false
|
||||
@Published var calibrationFile: String = ""
|
||||
|
||||
// MARK: - Stage 5 verification (issue #25)
|
||||
|
||||
var profcheckReport: ProfcheckReport?
|
||||
var profcheckWarning: String?
|
||||
var isProfcheckRunning = false
|
||||
@Published var profcheckReport: ProfcheckReport?
|
||||
@Published var profcheckWarning: String?
|
||||
@Published var isProfcheckRunning = false
|
||||
|
||||
// MARK: - History / drift (issue #26)
|
||||
|
||||
var verificationHistory: [VerificationRecord] = []
|
||||
var driftPrinterFilter: String? = nil
|
||||
var driftAlert: String?
|
||||
var isHistoryStoreError: String?
|
||||
@Published var verificationHistory: [VerificationRecord] = []
|
||||
@Published var driftPrinterFilter: String? = nil
|
||||
@Published var driftAlert: String?
|
||||
@Published var isHistoryStoreError: String?
|
||||
|
||||
// MARK: - Install (issue #27)
|
||||
|
||||
var installResult: InstallProfileResult?
|
||||
var showingInstallCollision = false
|
||||
var installCollisionMessage: String = ""
|
||||
@Published var installResult: InstallProfileResult?
|
||||
@Published var showingInstallCollision = false
|
||||
@Published var installCollisionMessage: String = ""
|
||||
var pendingInstallOptions: InstallProfileOptions?
|
||||
|
||||
init(wizard: WizardViewModel, environment: AppEnvironment) {
|
||||
@@ -110,39 +89,31 @@ final class ProfileWorkflowViewModel {
|
||||
}
|
||||
|
||||
var fwaValue: String? {
|
||||
switch fwaSelection {
|
||||
case .none: return nil
|
||||
case .empty: return ""
|
||||
case .D50: return "D50"
|
||||
case .D65: return "D65"
|
||||
case .custom: return fwaCustomPath
|
||||
}
|
||||
fwaSelection.presetValue(customPath: fwaCustomPath)
|
||||
}
|
||||
|
||||
// MARK: - Preset application
|
||||
|
||||
func applyPreset(_ preset: ProfilingPreset?) {
|
||||
guard let preset else { return }
|
||||
algorithm = preset.colprofAlgorithm ?? "l"
|
||||
quality = preset.colprofQuality ?? "m"
|
||||
intent = preset.colprofIntent ?? ""
|
||||
|
||||
if let fwa = preset.colprofFwa {
|
||||
switch fwa.lowercased() {
|
||||
case "none": fwaSelection = .none
|
||||
case "": fwaSelection = .empty
|
||||
case "d50": fwaSelection = .D50
|
||||
case "d65": fwaSelection = .D65
|
||||
default:
|
||||
fwaSelection = .custom
|
||||
fwaCustomPath = fwa
|
||||
}
|
||||
}
|
||||
|
||||
illuminant = preset.colprofIlluminant ?? ""
|
||||
observer = preset.colprofObserver ?? ""
|
||||
inputViewingCond = preset.colprofInputViewingCond ?? ""
|
||||
outputViewingCond = preset.colprofOutputViewingCond ?? ""
|
||||
let config = ColprofConfig(
|
||||
preset: preset,
|
||||
basename: wizard.basename,
|
||||
workingDirectory: wizard.effectiveWorkingDirectory
|
||||
)
|
||||
algorithm = config.algorithm
|
||||
quality = config.quality
|
||||
intent = config.intent ?? ""
|
||||
fwaSelection = ColprofFwaSelection(presetValue: config.fwa)
|
||||
fwaCustomPath = fwaSelection == .custom ? (config.fwa ?? "") : ""
|
||||
illuminant = config.illuminant ?? ""
|
||||
observer = config.observer ?? ""
|
||||
inputViewingCond = config.inputViewingCond ?? ""
|
||||
outputViewingCond = config.outputViewingCond ?? ""
|
||||
profileDescription = ""
|
||||
copyright = ""
|
||||
applyCalibration = preset.applyCalibration == true
|
||||
calibrationFile = preset.calibrationFile ?? ""
|
||||
}
|
||||
|
||||
/// Stage 4 form values for saving into a custom preset.
|
||||
@@ -192,29 +163,25 @@ final class ProfileWorkflowViewModel {
|
||||
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 url = try await runner.runColprof(config: config) { [weak self] batch in
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
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 {
|
||||
let progress = ColprofProgressClassifier.classify(line: last)
|
||||
self.updateProgress(progress)
|
||||
}
|
||||
self.updateProgress(ColprofProgressClassifier.classify(line: last))
|
||||
}
|
||||
}
|
||||
) { onLog in
|
||||
let url = try await runner.runColprof(config: config, onLogBatch: onLog)
|
||||
|
||||
var finalProfileURL = url
|
||||
|
||||
@@ -229,28 +196,26 @@ final class ProfileWorkflowViewModel {
|
||||
}
|
||||
|
||||
// Gamut extraction is best-effort for Stage 5 / M6 viewer.
|
||||
var gamutURL: URL?
|
||||
do {
|
||||
let gamConfig = IccgamutConfig(profileURL: finalProfileURL)
|
||||
let gamURL = try await runner.runIccgamut(config: gamConfig) { [weak self] batch in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.colprofLog.append(contentsOf: batch)
|
||||
}
|
||||
}
|
||||
self.createdGamutURL = gamURL
|
||||
self.colprofLog.append("Gamut mesh extracted: \(gamURL.lastPathComponent)")
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
self.createdProfileURL = finalProfileURL
|
||||
return (profileURL: finalProfileURL, gamutURL: gamutURL)
|
||||
}
|
||||
self.createdProfileURL = outcome.profileURL
|
||||
self.createdGamutURL = outcome.gamutURL
|
||||
self.wizard.refreshGating()
|
||||
self.wizard.showNotice("Profile created: \(finalProfileURL.lastPathComponent)")
|
||||
self.wizard.showNotice("Profile created: \(outcome.profileURL.lastPathComponent)")
|
||||
self.wizard.go(to: .verifyInstall)
|
||||
} catch {
|
||||
self.lastError = error.localizedDescription
|
||||
self.wizard.showNotice(
|
||||
"Profile creation failed: \(error.localizedDescription)",
|
||||
kind: .error
|
||||
@@ -317,25 +282,28 @@ final class ProfileWorkflowViewModel {
|
||||
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 report = try await runner.runProfcheck(config: config) { [weak self] batch in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.colprofLog.append(contentsOf: batch)
|
||||
}
|
||||
}
|
||||
self.profcheckReport = report
|
||||
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) {
|
||||
let updated = try await self.environment.historyStore.append(record)
|
||||
self.verificationHistory = updated
|
||||
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
|
||||
self.driftAlert = DriftAlert.compute(from: self.filteredHistory)
|
||||
}
|
||||
} catch let error as ArgyllRunnerError where error == .profcheckUnparseable {
|
||||
|
||||
@@ -5,12 +5,18 @@ import ICCeryCore
|
||||
/// Root layout: 270 pt sidebar + main stage area with the notification
|
||||
/// banner pinned to the top (docs/21 §Shell).
|
||||
struct RootView: View {
|
||||
@Bindable var workflow: TargetWorkflowViewModel
|
||||
@ObservedObject var workflow: TargetWorkflowViewModel
|
||||
/// Observed directly: nested ObservableObjects are not tracked
|
||||
/// through the parent's `objectWillChange`.
|
||||
@ObservedObject private var model: WizardViewModel
|
||||
@State private var showingSettings = false
|
||||
@State private var showingAbout = false
|
||||
@State private var showingAllHelp = false
|
||||
|
||||
private var model: WizardViewModel { workflow.wizard }
|
||||
init(workflow: TargetWorkflowViewModel) {
|
||||
self.workflow = workflow
|
||||
self._model = ObservedObject(wrappedValue: workflow.wizard)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
@@ -64,11 +70,11 @@ struct RootView: View {
|
||||
}
|
||||
|
||||
/// Content for the active wizard stage. Isolated into its own view so that
|
||||
/// `WizardViewModel` is tracked via `@Bindable` instead of the parent's
|
||||
/// `WizardViewModel` is tracked via `@ObservedObject` instead of the parent's
|
||||
/// `TargetWorkflowViewModel`, which does not observe nested `wizard` mutations.
|
||||
private struct WizardStageContent: View {
|
||||
@Bindable var model: WizardViewModel
|
||||
var workflow: TargetWorkflowViewModel
|
||||
@ObservedObject var model: WizardViewModel
|
||||
@ObservedObject var workflow: TargetWorkflowViewModel
|
||||
|
||||
var body: some View {
|
||||
switch model.stage {
|
||||
@@ -83,9 +89,9 @@ private struct WizardStageContent: View {
|
||||
case .verifyInstall:
|
||||
Stage5View(model: workflow.profile)
|
||||
case .calibrate:
|
||||
CalibrationView(model: workflow.calibration)
|
||||
CalibrationView(model: workflow.calibration, wizard: workflow.wizard)
|
||||
@unknown default:
|
||||
StagePlaceholderView(stage: model.stage)
|
||||
Stage1View(workflow: workflow)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@State var model = SettingsViewModel()
|
||||
@StateObject var model = SettingsViewModel()
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private static let instruments: [(code: String, label: String)] = [
|
||||
@@ -143,7 +143,6 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
|
||||
Divider()
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import AppKit
|
||||
import Combine
|
||||
import Foundation
|
||||
import ICCeryCore
|
||||
|
||||
@@ -6,12 +7,11 @@ import ICCeryCore
|
||||
/// validation; the log level is applied live via `LogSink` (#158) and a
|
||||
/// `settingsDidChange` notification fans out to #20.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class SettingsViewModel {
|
||||
final class SettingsViewModel: ObservableObject {
|
||||
|
||||
var settings: AppSettings
|
||||
var validationErrors: [String] = []
|
||||
var savedFlash = false
|
||||
@Published var settings: AppSettings
|
||||
@Published var validationErrors: [String] = []
|
||||
@Published var savedFlash = false
|
||||
|
||||
private let store: SettingsStore
|
||||
private let sink: LogSink
|
||||
@@ -33,7 +33,7 @@ final class SettingsViewModel {
|
||||
sink.applySettings(settings)
|
||||
savedFlash = true
|
||||
Task {
|
||||
try? await Task.sleep(for: .seconds(1.5))
|
||||
try? await Task.sleep(nanoseconds: 1_500_000_000)
|
||||
savedFlash = false
|
||||
}
|
||||
return true
|
||||
|
||||
@@ -4,12 +4,28 @@ import ICCeryCore
|
||||
/// 270 pt sidebar (docs/21 §Shell): logo, settings/about buttons, preset
|
||||
/// select, Calibrate Printer + status chip, and the 1–5 stepper.
|
||||
struct SidebarView: View {
|
||||
@Bindable var workflow: TargetWorkflowViewModel
|
||||
@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
|
||||
var onOpenSettings: () -> Void
|
||||
var onOpenAbout: () -> Void
|
||||
@Binding var showingAllHelp: Bool
|
||||
|
||||
private var model: WizardViewModel { workflow.wizard }
|
||||
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.onOpenSettings = onOpenSettings
|
||||
self.onOpenAbout = onOpenAbout
|
||||
self._showingAllHelp = showingAllHelp
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
|
||||
@@ -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 {
|
||||
@Bindable var workflow: TargetWorkflowViewModel
|
||||
@ObservedObject var workflow: TargetWorkflowViewModel
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
@@ -238,19 +238,12 @@ struct Stage1View: View {
|
||||
}
|
||||
|
||||
private var logSection: some View {
|
||||
DisclosureGroup("Process log") {
|
||||
ScrollView {
|
||||
Text(workflow.targenLog.joined(separator: "\n"))
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(Theme.text)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.frame(minHeight: 120, maxHeight: 200)
|
||||
.accessibilityIdentifier("targenLog")
|
||||
}
|
||||
.foregroundStyle(Theme.text)
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier("targenLogContainer")
|
||||
ProcessLogView(
|
||||
lines: workflow.targenLog,
|
||||
minHeight: 120,
|
||||
maxHeight: 200,
|
||||
containerId: "targenLogContainer",
|
||||
logId: "targenLog"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,19 @@ import ICCeryCore
|
||||
/// issues #9/#10, docs/09). Print controls are visible but inert —
|
||||
/// real spooling lands in M3.
|
||||
struct Stage2View: View {
|
||||
@Bindable var workflow: TargetWorkflowViewModel
|
||||
@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)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
@@ -171,20 +183,13 @@ struct Stage2View: View {
|
||||
}
|
||||
|
||||
private var logSection: some View {
|
||||
DisclosureGroup("Process log") {
|
||||
ScrollView {
|
||||
Text(workflow.printtargLog.joined(separator: "\n"))
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(Theme.text)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.frame(minHeight: 100, maxHeight: 180)
|
||||
.accessibilityIdentifier("printtargLog")
|
||||
}
|
||||
.foregroundStyle(Theme.text)
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier("printtargLogContainer")
|
||||
ProcessLogView(
|
||||
lines: workflow.printtargLog,
|
||||
minHeight: 100,
|
||||
maxHeight: 180,
|
||||
containerId: "printtargLogContainer",
|
||||
logId: "printtargLog"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - TIFF gallery (#tiffGallery) — host-side PNG only (#58)
|
||||
@@ -219,18 +224,19 @@ struct Stage2View: View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 12) {
|
||||
Text("Print").font(.headline).foregroundStyle(Theme.text)
|
||||
if let notice = workflow.printNotice {
|
||||
Image(systemName: workflow.printNoticeIsError
|
||||
if let notice = workflow.print.printNotice {
|
||||
Image(systemName: notice.kind == .error
|
||||
? "xmark.circle.fill" : "info.circle.fill")
|
||||
.foregroundStyle(workflow.printNoticeIsError
|
||||
.foregroundStyle(notice.kind == .error
|
||||
? .red : .blue)
|
||||
.accessibilityIdentifier("printNotificationIcon")
|
||||
Text(notice)
|
||||
.accessibilityValue(notice.kind.accessibilityValue)
|
||||
Text(notice.text)
|
||||
.font(.caption)
|
||||
.foregroundStyle(workflow.printNoticeIsError
|
||||
.foregroundStyle(notice.kind == .error
|
||||
? .red : .secondary)
|
||||
.accessibilityIdentifier("printNotificationText")
|
||||
.accessibilityValue(notice)
|
||||
.accessibilityValue(notice.text)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
@@ -238,21 +244,21 @@ struct Stage2View: View {
|
||||
|
||||
// Printer row: select + status + refresh + Preferences.
|
||||
HStack(spacing: 10) {
|
||||
Picker("Printer", selection: $workflow.selectedPrinter) {
|
||||
ForEach(workflow.printers, id: \.name) { printer in
|
||||
Picker("Printer", selection: $workflow.print.selectedPrinter) {
|
||||
ForEach(workflow.print.printers, id: \.name) { printer in
|
||||
Text(printer.displayName ?? printer.name)
|
||||
.tag(printer.name)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 320)
|
||||
.accessibilityIdentifier("printerSelect")
|
||||
.onChange(of: workflow.selectedPrinter) { _, _ in
|
||||
workflow.selectedTray = nil
|
||||
workflow.selectedMediaType = nil
|
||||
Task { @MainActor in await workflow.reloadSelectedCapabilities() }
|
||||
.onChange(of: workflow.print.selectedPrinter) { _ in
|
||||
workflow.print.selectedTray = nil
|
||||
workflow.print.selectedMediaType = nil
|
||||
Task { @MainActor in await workflow.print.reloadSelectedCapabilities() }
|
||||
}
|
||||
if let selected = workflow.printers
|
||||
.first(where: { $0.name == workflow.selectedPrinter }) {
|
||||
if let selected = workflow.print.printers
|
||||
.first(where: { $0.name == workflow.print.selectedPrinter }) {
|
||||
Text(selected.status.rawValue)
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 8).padding(.vertical, 3)
|
||||
@@ -260,33 +266,33 @@ struct Stage2View: View {
|
||||
.clipShape(Capsule())
|
||||
.accessibilityIdentifier("printerStatusBadge")
|
||||
}
|
||||
Button(action: workflow.refreshPrinters) {
|
||||
Button(action: workflow.print.refreshPrinters) {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
.help("Refresh printer list")
|
||||
.accessibilityIdentifier("btnRefreshPrinters")
|
||||
Button(action: workflow.openPrinterPreferences) {
|
||||
Button(action: workflow.print.openPrinterPreferences) {
|
||||
Image(systemName: "gearshape")
|
||||
}
|
||||
.help("Printer properties — bound NSPrintPanel")
|
||||
.disabled(workflow.selectedPrinter.isEmpty)
|
||||
.disabled(workflow.print.selectedPrinter.isEmpty)
|
||||
.accessibilityIdentifier("btnPrinterProperties")
|
||||
}
|
||||
|
||||
// Tray / media / orientation — from queue capabilities.
|
||||
HStack(spacing: 14) {
|
||||
if !workflow.printerCaps.trays.isEmpty {
|
||||
Picker("Tray", selection: $workflow.selectedTray) {
|
||||
ForEach(workflow.printerCaps.trays, id: \.id) {
|
||||
if !workflow.print.printerCaps.trays.isEmpty {
|
||||
Picker("Tray", selection: $workflow.print.selectedTray) {
|
||||
ForEach(workflow.print.printerCaps.trays, id: \.id) {
|
||||
Text($0.name).tag(Optional($0.id))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 200)
|
||||
.accessibilityIdentifier("printerTraySelect")
|
||||
}
|
||||
if !workflow.printerCaps.mediaTypes.isEmpty {
|
||||
Picker("Media", selection: $workflow.selectedMediaType) {
|
||||
ForEach(workflow.printerCaps.mediaTypes, id: \.id) {
|
||||
if !workflow.print.printerCaps.mediaTypes.isEmpty {
|
||||
Picker("Media", selection: $workflow.print.selectedMediaType) {
|
||||
ForEach(workflow.print.printerCaps.mediaTypes, id: \.id) {
|
||||
Text($0.name).tag(Optional($0.id))
|
||||
}
|
||||
}
|
||||
@@ -296,27 +302,31 @@ struct Stage2View: View {
|
||||
.accessibilityIdentifier("printerMediaTypeSelect")
|
||||
}
|
||||
HStack(spacing: 0) {
|
||||
Button("Portrait") { workflow.printOrientation = "portrait" }
|
||||
Button("Portrait") { workflow.print.printOrientation = "portrait" }
|
||||
.buttonStyle(.bordered)
|
||||
.tint(workflow.printOrientation == "portrait" ? .accentColor : .gray)
|
||||
.tint(workflow.print.printOrientation == "portrait" ? .accentColor : .gray)
|
||||
.accessibilityIdentifier("btnOrientPortrait")
|
||||
Button("Landscape") { workflow.printOrientation = "landscape" }
|
||||
Button("Landscape") { workflow.print.printOrientation = "landscape" }
|
||||
.buttonStyle(.bordered)
|
||||
.tint(workflow.printOrientation == "landscape" ? .accentColor : .gray)
|
||||
.tint(workflow.print.printOrientation == "landscape" ? .accentColor : .gray)
|
||||
.accessibilityIdentifier("btnOrientLandscape")
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Button(action: workflow.printAllPages) {
|
||||
Label(workflow.isPrinting ? "Printing…" : "Print All",
|
||||
Button(action: {
|
||||
if let result = workflow.printtargResult {
|
||||
workflow.print.printAllPages(from: result, pageSize: workflow.pageSize)
|
||||
}
|
||||
}) {
|
||||
Label(workflow.print.isPrinting ? "Printing…" : "Print All",
|
||||
systemImage: "printer")
|
||||
}
|
||||
.controlSize(.large)
|
||||
.disabled(workflow.isPrinting
|
||||
.disabled(workflow.print.isPrinting
|
||||
|| workflow.printtargResult == nil
|
||||
|| workflow.selectedPrinter.isEmpty)
|
||||
|| workflow.print.selectedPrinter.isEmpty)
|
||||
.accessibilityIdentifier("btnPrintAll")
|
||||
Spacer()
|
||||
Button("Advance to Stage 3") { workflow.advanceToStage3() }
|
||||
@@ -330,11 +340,20 @@ struct Stage2View: View {
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium))
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier("rawPrintPanel")
|
||||
.task(id: workflow.printtargResult?.pages.count) {
|
||||
// Auto-enumerate once a manifest exists and whenever it
|
||||
// changes (e.g. resume from .ti2).
|
||||
if workflow.printers.isEmpty, workflow.printtargResult != nil {
|
||||
workflow.refreshPrinters()
|
||||
.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
|
||||
if workflow.print.printers.isEmpty, workflow.printtargResult != nil {
|
||||
workflow.print.refreshPrinters()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -343,7 +362,16 @@ struct Stage2View: View {
|
||||
/// One gallery cell: PNG preview + per-page Print button.
|
||||
private struct GalleryPageView: View {
|
||||
let page: GalleryPage
|
||||
let workflow: TargetWorkflowViewModel
|
||||
@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)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 6) {
|
||||
@@ -364,9 +392,9 @@ private struct GalleryPageView: View {
|
||||
Text("\(page.page.patches) patches · " +
|
||||
"\(Int(page.page.widthMm))×\(Int(page.page.heightMm)) mm")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
Button("Print") { workflow.printPage(page) }
|
||||
.disabled(workflow.isPrinting
|
||||
|| workflow.selectedPrinter.isEmpty)
|
||||
Button("Print") { workflow.print.printPage(page, pageSize: workflow.pageSize) }
|
||||
.disabled(workflow.print.isPrinting
|
||||
|| workflow.print.selectedPrinter.isEmpty)
|
||||
.accessibilityIdentifier("btnPrintPage-\(page.index)")
|
||||
}
|
||||
.padding(8)
|
||||
|
||||
@@ -4,7 +4,15 @@ import ICCeryCore
|
||||
|
||||
/// Stage 3 — measurement, live swatches, and multi-pass averaging.
|
||||
struct Stage3View: View {
|
||||
@Bindable var model: MeasurementWorkflowViewModel
|
||||
@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)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -164,28 +172,22 @@ struct Stage3View: View {
|
||||
.foregroundStyle(Theme.accent)
|
||||
}
|
||||
|
||||
if let lastError = model.lastError {
|
||||
Text(lastError)
|
||||
if let notice = model.chartreadNotice {
|
||||
Text(notice.text)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
.foregroundStyle(notice.kind.tint)
|
||||
.accessibilityIdentifier("chartreadLastError")
|
||||
.accessibilityValue(lastError)
|
||||
.accessibilityValue(notice.text)
|
||||
}
|
||||
|
||||
controlButtons
|
||||
|
||||
if !model.chartreadLog.isEmpty {
|
||||
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")
|
||||
ProcessLogView(
|
||||
lines: model.chartreadLog,
|
||||
containerId: "chartreadLogContainer",
|
||||
logId: "chartreadLog"
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
@@ -371,9 +373,11 @@ struct Stage3View: View {
|
||||
}
|
||||
|
||||
if let notice = model.finishNotice {
|
||||
Text(notice)
|
||||
Text(notice.text)
|
||||
.font(.caption)
|
||||
.foregroundStyle(model.finishNoticeIsError ? .red : .green)
|
||||
.foregroundStyle(notice.kind == .error ? .red : .green)
|
||||
.accessibilityIdentifier("chartreadFinishNotice")
|
||||
.accessibilityValue(notice.kind.accessibilityValue)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
|
||||
@@ -3,7 +3,15 @@ import ICCeryCore
|
||||
|
||||
/// Stage 4 — build an ICC/ICM profile from the canonical `.ti3`.
|
||||
struct Stage4View: View {
|
||||
@Bindable var model: ProfileWorkflowViewModel
|
||||
@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)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -130,6 +138,9 @@ 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")
|
||||
|
||||
@@ -143,6 +154,7 @@ struct Stage4View: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(Theme.panel)
|
||||
}
|
||||
@@ -166,27 +178,14 @@ struct Stage4View: View {
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if let lastError = model.lastError {
|
||||
Text(lastError)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
.accessibilityIdentifier("colprofLastError")
|
||||
}
|
||||
}
|
||||
|
||||
if !model.colprofLog.isEmpty {
|
||||
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")
|
||||
ProcessLogView(
|
||||
lines: model.colprofLog,
|
||||
containerId: "colprofLogContainer",
|
||||
logId: "colprofLog"
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
|
||||
@@ -3,7 +3,15 @@ import ICCeryCore
|
||||
|
||||
/// Stage 5 — verify the generated profile, track drift, and install.
|
||||
struct Stage5View: View {
|
||||
@Bindable var model: ProfileWorkflowViewModel
|
||||
@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)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import SwiftUI
|
||||
import ICCeryCore
|
||||
|
||||
/// Placeholder stage surface for M1. Real stage UIs arrive in M2–M5
|
||||
/// (issues #7–#31); Stage 0 lands in M6 (issue #29).
|
||||
struct StagePlaceholderView: View {
|
||||
let stage: WizardStage
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: stage.symbolName)
|
||||
.font(.system(size: 44))
|
||||
.foregroundStyle(Theme.accent)
|
||||
Text(stage.title)
|
||||
.font(.title2)
|
||||
.foregroundStyle(Theme.text)
|
||||
Text("This stage is not implemented yet — see the milestone plan.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Theme.background)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
import Observation
|
||||
import ICCeryCore
|
||||
|
||||
/// Stage 1/2 form state, runner orchestration, resume flow, and preset
|
||||
@@ -10,8 +10,7 @@ import ICCeryCore
|
||||
/// All process work runs through `ArgyllRunner` off `@MainActor`; only
|
||||
/// coalesced log batches and completion hop back.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class TargetWorkflowViewModel {
|
||||
final class TargetWorkflowViewModel: ObservableObject {
|
||||
|
||||
let wizard: WizardViewModel
|
||||
let environment: AppEnvironment
|
||||
@@ -19,117 +18,95 @@ final class TargetWorkflowViewModel {
|
||||
|
||||
// MARK: - Stage 1 form (targen)
|
||||
|
||||
var colourSpace: ColourSpace = .rgb {
|
||||
@Published 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
|
||||
}
|
||||
}
|
||||
var patchPreset: PatchCountPreset = .standard800
|
||||
@Published var patchPreset: PatchCountPreset = .standard800
|
||||
/// `#patchCountCustom` — used when `patchPreset == .custom`.
|
||||
var customPatchCount = 2500
|
||||
var whitePatches = 4
|
||||
var blackPatches = 4
|
||||
@Published var customPatchCount = 2500
|
||||
@Published var whitePatches = 4
|
||||
@Published var blackPatches = 4
|
||||
|
||||
// Advanced — each optional flag is enabled + value, so an untouched
|
||||
// control emits nothing (#advanced fields are opt-in).
|
||||
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
|
||||
@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
|
||||
|
||||
/// `#targetBasename` — no placeholder is ever invented (#60).
|
||||
var targetBasename = ""
|
||||
@Published var targetBasename = ""
|
||||
/// `#selectedPathDisplay` / resolved cwd.
|
||||
var targetDirectory: URL?
|
||||
@Published var targetDirectory: URL?
|
||||
|
||||
// MARK: - Stage 2 form (printtarg)
|
||||
|
||||
var instrument: PrintInstrument = .i1
|
||||
var pageSize: PageSize = .a4
|
||||
var customPageW = 210.0
|
||||
var customPageH = 297.0
|
||||
var bitDepth: TiffBitDepth = .eight
|
||||
@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
|
||||
/// `#tiffDpi` — two-way bound; presets can change it (150-DPI draft
|
||||
/// regression must be visible here).
|
||||
var tiffDpi = 300
|
||||
var layoutOrder: LayoutOrder = .deterministic
|
||||
var customSeed = 1
|
||||
var labelIsCustom = false
|
||||
var customLabel = ""
|
||||
var metaPrinter = ""
|
||||
var metaInkSet = ""
|
||||
var metaDriverPaper = ""
|
||||
var metaActualPaper = ""
|
||||
@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 = ""
|
||||
|
||||
// MARK: - Run state
|
||||
|
||||
var targenRunning = false
|
||||
var targenLog: [String] = []
|
||||
var printtargRunning = false
|
||||
var printtargLog: [String] = []
|
||||
var printtargResult: PrinttargResult?
|
||||
@Published var targenRunning = false
|
||||
@Published var targenLog: [String] = []
|
||||
@Published var printtargRunning = false
|
||||
@Published var printtargLog: [String] = []
|
||||
@Published var printtargResult: PrinttargResult?
|
||||
/// Sticky until the target changes: `.ti2` resume landed us on
|
||||
/// Stage 3 (`#stage3LoadedTargetBanner` data).
|
||||
var resumedFromTi2 = false
|
||||
|
||||
// MARK: - Print panel (issue 17)
|
||||
|
||||
/// CUPS destinations from `lpstat` (#printerSelect).
|
||||
var printers: [Printer] = []
|
||||
/// Selected queue name.
|
||||
var selectedPrinter = ""
|
||||
/// Capabilities of the selected queue (#printerTraySelect /
|
||||
/// #printerMediaTypeSelect / PageSize source).
|
||||
var printerCaps = PrinterCapabilities()
|
||||
var selectedTray: Int?
|
||||
var selectedMediaType: String?
|
||||
/// "portrait" | "landscape" (#btnOrientPortrait/#btnOrientLandscape).
|
||||
var printOrientation = "portrait"
|
||||
/// Per-queue captured `key=value` strings from Preferences — replayed
|
||||
/// on `lp` (session-only, docs/11 §capturedCupsOptions).
|
||||
var capturedCupsOptions: [String: String] = [:]
|
||||
/// In-panel notice (#printNotification) — cancel → info, not error.
|
||||
var printNotice: String?
|
||||
var printNoticeIsError = false
|
||||
var isPrinting = false
|
||||
/// Strong reference to the active print task so the unstructured
|
||||
/// `Task` is not dropped before it resumes.
|
||||
private var printTask: Task<Void, Never>?
|
||||
@Published var resumedFromTi2 = false
|
||||
|
||||
// MARK: - Presets
|
||||
|
||||
var presets: [ProfilingPreset] = []
|
||||
var selectedPresetID = "none"
|
||||
var showingSavePreset = false
|
||||
var showingManagePresets = false
|
||||
var savePresetName = ""
|
||||
var savePresetDesc = ""
|
||||
@Published var presets: [ProfilingPreset] = []
|
||||
@Published var selectedPresetID = "none"
|
||||
@Published var showingSavePreset = false
|
||||
@Published var showingManagePresets = false
|
||||
@Published var savePresetName = ""
|
||||
@Published var savePresetDesc = ""
|
||||
|
||||
/// Stage 3 measurement workflow, owned at the app level so it persists
|
||||
/// across stage switches and can observe settings changes.
|
||||
var measurement: MeasurementWorkflowViewModel
|
||||
@Published var measurement: MeasurementWorkflowViewModel
|
||||
/// Stage 4/5 profile workflow, owned at the app level so it persists
|
||||
/// across stage switches and can observe preset values.
|
||||
var profile: ProfileWorkflowViewModel
|
||||
@Published var profile: ProfileWorkflowViewModel
|
||||
/// Stage 0 calibration workflow.
|
||||
var calibration: CalibrationViewModel!
|
||||
@Published var calibration: CalibrationViewModel!
|
||||
/// Stage 2 unmanaged print session.
|
||||
@Published var print: PrintSessionViewModel!
|
||||
|
||||
init(environment: AppEnvironment = .live()) {
|
||||
self.environment = environment
|
||||
@@ -142,6 +119,7 @@ final class TargetWorkflowViewModel {
|
||||
wizard: wizard,
|
||||
environment: environment
|
||||
)
|
||||
self.print = PrintSessionViewModel(wizard: wizard, environment: environment)
|
||||
self.calibration = nil
|
||||
self.calibration = CalibrationViewModel(
|
||||
workflow: self,
|
||||
@@ -227,16 +205,16 @@ final class TargetWorkflowViewModel {
|
||||
func generateTarget() {
|
||||
guard canGenerate, !targenRunning else { return }
|
||||
let config = buildTargenConfig()
|
||||
targenRunning = true
|
||||
targenLog = []
|
||||
resumedFromTi2 = false
|
||||
let runner = environment.runner
|
||||
Task { @MainActor in
|
||||
do {
|
||||
let url = try await runner.runTargen(config: config) { [weak self] batch in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.targenLog.append(contentsOf: batch)
|
||||
}
|
||||
let url = try await ProcessRunSupport.runLogged(
|
||||
setRunning: { self.targenRunning = $0 },
|
||||
resetLog: { self.targenLog = [] },
|
||||
onLog: { self.targenLog.append(contentsOf: $0) }
|
||||
) { onLog in
|
||||
try await runner.runTargen(config: config, onLogBatch: onLog)
|
||||
}
|
||||
wizard.setTarget(
|
||||
basename: config.basename,
|
||||
@@ -248,7 +226,6 @@ final class TargetWorkflowViewModel {
|
||||
wizard.showNotice(
|
||||
"targen failed: \(error.localizedDescription)", kind: .error)
|
||||
}
|
||||
targenRunning = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,7 +238,12 @@ final class TargetWorkflowViewModel {
|
||||
? 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 {
|
||||
@@ -284,8 +266,6 @@ final class TargetWorkflowViewModel {
|
||||
} else {
|
||||
wizard.showNotice("Imported dataset is not ready for profiling.", kind: .warning)
|
||||
}
|
||||
} catch let error as CGATSParseError {
|
||||
wizard.showNotice("Import failed: \(error.localizedDescription)", kind: .error)
|
||||
} catch {
|
||||
wizard.showNotice("Import failed: \(error.localizedDescription)", kind: .error)
|
||||
}
|
||||
@@ -360,28 +340,25 @@ final class TargetWorkflowViewModel {
|
||||
func createLayout() {
|
||||
guard wizard.isUnlocked(.layOutPrint), !printtargRunning else { return }
|
||||
let config = buildPrinttargConfig()
|
||||
printtargRunning = true
|
||||
printtargLog = []
|
||||
printtargResult = nil
|
||||
let runner = environment.runner
|
||||
Task { @MainActor in
|
||||
do {
|
||||
let result = try await runner.runPrinttarg(config: config) { [weak self] batch in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.printtargLog.append(contentsOf: batch)
|
||||
}
|
||||
let result = try await ProcessRunSupport.runLogged(
|
||||
setRunning: { self.printtargRunning = $0 },
|
||||
resetLog: { self.printtargLog = [] },
|
||||
onLog: { self.printtargLog.append(contentsOf: $0) }
|
||||
) { onLog in
|
||||
try await runner.runPrinttarg(config: config, onLogBatch: onLog)
|
||||
}
|
||||
printtargResult = result
|
||||
wizard.refreshGating()
|
||||
wizard.showNotice(
|
||||
"Layout created — \(result.manifest.pages.count) page(s) ready.")
|
||||
} catch {
|
||||
// Stay on Stage 2: non-zero exit, malformed manifest, or
|
||||
// missing .ti2 must never advance the wizard (#156).
|
||||
wizard.showNotice(
|
||||
"printtarg failed: \(error.localizedDescription)", kind: .error)
|
||||
}
|
||||
printtargRunning = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,160 +368,6 @@ final class TargetWorkflowViewModel {
|
||||
wizard.go(to: .measure)
|
||||
}
|
||||
|
||||
// MARK: - Print panel actions (issue 17)
|
||||
|
||||
/// `#btnRefreshPrinters` — re-enumerate CUPS destinations and load
|
||||
/// capabilities for the selection. Auto-runs when the panel first
|
||||
/// appears with a manifest.
|
||||
func refreshPrinters() {
|
||||
let cups = environment.cupsService
|
||||
Task { @MainActor in
|
||||
do {
|
||||
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 reloadSelectedCapabilities()
|
||||
} catch {
|
||||
printNotice = "Could not list printers: \(error.localizedDescription)"
|
||||
printNoticeIsError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Capabilities for `selectedPrinter` — trays / media / sizes feed
|
||||
/// the selects.
|
||||
func reloadSelectedCapabilities() async {
|
||||
guard !selectedPrinter.isEmpty else {
|
||||
printerCaps = PrinterCapabilities()
|
||||
return
|
||||
}
|
||||
do {
|
||||
printerCaps = try await environment.cupsService
|
||||
.capabilities(for: selectedPrinter)
|
||||
// Default selections only when the captured options didn't
|
||||
// already pin them (Preferences round-trip wins).
|
||||
if selectedMediaType == nil {
|
||||
selectedMediaType = printerCaps.mediaTypes.first?.id
|
||||
}
|
||||
if selectedTray == nil {
|
||||
selectedTray = printerCaps.trays.first?.id
|
||||
}
|
||||
} catch {
|
||||
printerCaps = PrinterCapabilities()
|
||||
}
|
||||
}
|
||||
|
||||
/// `#btnPrinterProperties` — bound NSPrintPanel ("Use Settings").
|
||||
/// Cancel → info notice, never an error, cache untouched. On OK the
|
||||
/// captured options are stored per-queue; a panel-side queue switch
|
||||
/// updates `printerSelect` when the returned CUPS id is in the list.
|
||||
func openPrinterPreferences() {
|
||||
guard !selectedPrinter.isEmpty else { return }
|
||||
let queue = selectedPrinter
|
||||
let displayName = printers.first { $0.name == queue }?.displayName
|
||||
let cups = environment.cupsService
|
||||
Task { @MainActor in
|
||||
do {
|
||||
guard let result = try await PrintPanelService()
|
||||
.showProperties(
|
||||
queue: queue, displayName: displayName,
|
||||
cupsService: cups)
|
||||
else {
|
||||
printNotice = "Printer properties dialog cancelled."
|
||||
printNoticeIsError = false
|
||||
return
|
||||
}
|
||||
if let selected = result.selectedPrinter,
|
||||
printers.contains(where: { $0.name == selected }),
|
||||
selected != queue {
|
||||
selectedPrinter = selected
|
||||
await reloadSelectedCapabilities()
|
||||
}
|
||||
if let captured = result.options.cupsOptions {
|
||||
capturedCupsOptions[selectedPrinter] = captured
|
||||
}
|
||||
if let media = result.options.mediaType {
|
||||
selectedMediaType = media
|
||||
}
|
||||
printNotice = "Settings captured for \(selectedPrinter)."
|
||||
printNoticeIsError = false
|
||||
} catch {
|
||||
printNotice = error.localizedDescription
|
||||
printNoticeIsError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `#btnPrintAll` — spool every gallery TIFF, sequentially. Stops on
|
||||
/// the first failure so the user sees which page failed.
|
||||
func printAllPages() {
|
||||
guard let result = printtargResult, !isPrinting else { return }
|
||||
isPrinting = true
|
||||
let task = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
defer { self.printTask = nil }
|
||||
var printed = 0
|
||||
for page in result.pages {
|
||||
do {
|
||||
try await spool(page, index: page.index)
|
||||
printed += 1
|
||||
} catch {
|
||||
printNotice = "Print failed on \(page.page.filename): "
|
||||
+ error.localizedDescription
|
||||
printNoticeIsError = true
|
||||
isPrinting = false
|
||||
return
|
||||
}
|
||||
}
|
||||
printNotice = "Sent \(printed) page(s) to \(selectedPrinter)."
|
||||
printNoticeIsError = false
|
||||
isPrinting = false
|
||||
}
|
||||
printTask = task
|
||||
}
|
||||
|
||||
/// `#btnPrintPage-N` — one TIFF.
|
||||
func printPage(_ page: GalleryPage) {
|
||||
guard !isPrinting else { return }
|
||||
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)
|
||||
printNotice = "Sent \(page.page.filename) to \(selectedPrinter)."
|
||||
printNoticeIsError = false
|
||||
} catch {
|
||||
printNotice = "Print failed: \(error.localizedDescription)"
|
||||
printNoticeIsError = true
|
||||
}
|
||||
isPrinting = false
|
||||
}
|
||||
printTask = task
|
||||
}
|
||||
|
||||
private func spool(_ page: GalleryPage, index: Int) async throws {
|
||||
guard !selectedPrinter.isEmpty else {
|
||||
throw CupsError.noPrinterSelected
|
||||
}
|
||||
let options = PrintOptions(
|
||||
orientation: printOrientation,
|
||||
paperSize: pageSize == .custom ? nil : pageSize.rawValue,
|
||||
mediaType: selectedMediaType,
|
||||
ppdUncorrectedPassthrough: true,
|
||||
cupsOptions: capturedCupsOptions[selectedPrinter])
|
||||
try await environment.cupsService.printTarget(
|
||||
queue: selectedPrinter,
|
||||
tiffPath: page.fileURL.path,
|
||||
options: options,
|
||||
page: index)
|
||||
// For Stage 5 history (#95): record which queue printed.
|
||||
wizard.printerName = selectedPrinter
|
||||
}
|
||||
|
||||
// MARK: - Presets
|
||||
|
||||
func reloadPresets() {
|
||||
@@ -554,55 +377,61 @@ final class TargetWorkflowViewModel {
|
||||
/// Applies every Stage 1/2 field of the preset to the live form
|
||||
/// (bidirectional — the draft preset's dpi=150 must be visible).
|
||||
func applyPreset(_ preset: ProfilingPreset) {
|
||||
colourSpace = preset.colourSpace == "cmyk" ? .cmyk : .rgb
|
||||
patchPreset = PatchCountPreset(rawValue: "\(preset.patchCount)") ?? .custom
|
||||
customPatchCount = preset.patchCount
|
||||
whitePatches = preset.whitePatches
|
||||
blackPatches = preset.blackPatches
|
||||
greySteps = preset.greySteps ?? 5; greyStepsEnabled = preset.greySteps != nil
|
||||
singleChannelSteps = preset.singleChannelSteps ?? 5
|
||||
singleChannelEnabled = preset.singleChannelSteps != nil
|
||||
neutralSteps = preset.neutralSteps ?? 3
|
||||
neutralStepsEnabled = preset.neutralSteps != nil
|
||||
neutralConcentration = preset.neutralConcentration ?? 0.50
|
||||
neutralConcEnabled = preset.neutralConcentration != nil
|
||||
preconditioningProfile = preset.preconditioningProfile
|
||||
highQuality = preset.ofpsHighQuality == true
|
||||
adaptation = preset.ofpsAdaptation ?? 0.10
|
||||
adaptationEnabled = preset.ofpsAdaptation != nil
|
||||
algorithm = preset.fullSpreadAlgorithm
|
||||
.flatMap { FullSpreadAlgorithm(presetValue: $0) } ?? .ofps
|
||||
totalInkLimit = preset.totalInkLimit ?? 320
|
||||
inkLimitEnabled = preset.totalInkLimit != nil
|
||||
darkEmphasis = preset.darkEmphasis ?? 1.0
|
||||
darkEmphasisEnabled = preset.darkEmphasis != nil
|
||||
devicePower = preset.devicePower ?? 1.0
|
||||
devicePowerEnabled = preset.devicePower != nil
|
||||
|
||||
instrument = PrintInstrument(rawValue: preset.instrument) ?? .i1
|
||||
if let size = PageSize(rawValue: preset.pageSize) {
|
||||
pageSize = size
|
||||
} else if let (w, h) = Self.parseCustomPage(preset.pageSize) {
|
||||
pageSize = .custom; customPageW = w; customPageH = h
|
||||
} else {
|
||||
pageSize = .a4
|
||||
}
|
||||
bitDepth = preset.bitDepth == 16 ? .sixteen : .eight
|
||||
tiffDpi = preset.dpi
|
||||
if preset.noRandomize == true {
|
||||
layoutOrder = .raster
|
||||
} else if (preset.randomSeed ?? 1) == 1 {
|
||||
layoutOrder = .deterministic
|
||||
} else {
|
||||
layoutOrder = .customSeed
|
||||
}
|
||||
customSeed = preset.randomSeed ?? 1
|
||||
|
||||
let targen = TargenConfig(preset: preset, basename: targetBasename, workingDirectory: targetDirectory)
|
||||
applyTargenForm(targen)
|
||||
// Stage 4 state (incl. calibration) is applied before Stage 2 so
|
||||
// the layout config receives the preset's calibration path, not
|
||||
// stale live state (#82).
|
||||
profile.applyPreset(preset)
|
||||
|
||||
let printtarg = PrinttargConfig(
|
||||
preset: preset,
|
||||
basename: wizard.basename,
|
||||
workingDirectory: wizard.effectiveWorkingDirectory,
|
||||
calibrationFile: profile.applyCalibration && !profile.calibrationFile.isEmpty
|
||||
? profile.calibrationFile : nil
|
||||
)
|
||||
applyPrinttargForm(printtarg)
|
||||
selectedPresetID = preset.id
|
||||
}
|
||||
|
||||
private func applyTargenForm(_ config: TargenConfig) {
|
||||
colourSpace = config.colourSpace
|
||||
patchPreset = PatchCountPreset(rawValue: "\(config.patchCount)") ?? .custom
|
||||
customPatchCount = config.patchCount
|
||||
whitePatches = config.whitePatches
|
||||
blackPatches = config.blackPatches
|
||||
greySteps = config.greySteps ?? 5
|
||||
greyStepsEnabled = config.greySteps != nil
|
||||
singleChannelSteps = config.singleChannelSteps ?? 5
|
||||
singleChannelEnabled = config.singleChannelSteps != nil
|
||||
neutralSteps = config.neutralSteps ?? 3
|
||||
neutralStepsEnabled = config.neutralSteps != nil
|
||||
neutralConcentration = config.neutralConcentration ?? 0.50
|
||||
neutralConcEnabled = config.neutralConcentration != nil
|
||||
preconditioningProfile = config.preconditioningProfile
|
||||
highQuality = config.ofpsHighQuality == true
|
||||
adaptation = config.ofpsAdaptation ?? 0.10
|
||||
adaptationEnabled = config.ofpsAdaptation != nil
|
||||
algorithm = config.fullSpreadAlgorithm ?? .ofps
|
||||
totalInkLimit = config.totalInkLimit ?? 320
|
||||
inkLimitEnabled = config.totalInkLimit != nil
|
||||
darkEmphasis = config.darkEmphasis ?? 1.0
|
||||
darkEmphasisEnabled = config.darkEmphasis != nil
|
||||
devicePower = config.devicePower ?? 1.0
|
||||
devicePowerEnabled = config.devicePower != nil
|
||||
}
|
||||
|
||||
private func applyPrinttargForm(_ config: PrinttargConfig) {
|
||||
instrument = config.instrument
|
||||
pageSize = config.pageSize
|
||||
customPageW = config.customPageWidth
|
||||
customPageH = config.customPageHeight
|
||||
bitDepth = config.bitDepth
|
||||
tiffDpi = config.dpi
|
||||
layoutOrder = config.layoutOrder
|
||||
customSeed = config.customSeed
|
||||
}
|
||||
|
||||
/// Snapshot of the live Stage 1/2 form as a custom preset.
|
||||
func saveCurrentAsPreset() {
|
||||
let name = savePresetName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -614,39 +443,11 @@ final class TargetWorkflowViewModel {
|
||||
id: "custom-\(UUID().uuidString.lowercased())",
|
||||
name: name,
|
||||
description: savePresetDesc.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
colourSpace: colourSpace == .cmyk ? "cmyk" : "rgb",
|
||||
patchCount: effectivePatchCount,
|
||||
whitePatches: whitePatches,
|
||||
blackPatches: blackPatches,
|
||||
greySteps: greyStepsEnabled ? greySteps : nil,
|
||||
singleChannelSteps: singleChannelEnabled ? singleChannelSteps : nil,
|
||||
neutralSteps: neutralStepsEnabled ? neutralSteps : nil,
|
||||
neutralConcentration: neutralConcEnabled ? neutralConcentration : nil,
|
||||
preconditioningProfile: preconditioningProfile,
|
||||
ofpsHighQuality: highQuality ? true : nil,
|
||||
ofpsAdaptation: adaptationEnabled ? adaptation : nil,
|
||||
fullSpreadAlgorithm: algorithm.presetValue,
|
||||
totalInkLimit: inkLimitEnabled ? totalInkLimit : nil,
|
||||
darkEmphasis: darkEmphasisEnabled ? darkEmphasis : nil,
|
||||
devicePower: devicePowerEnabled ? devicePower : nil,
|
||||
instrument: instrument.rawValue,
|
||||
pageSize: pageSize == .custom
|
||||
? "\(Int(customPageW))x\(Int(customPageH))"
|
||||
: pageSize.rawValue,
|
||||
bitDepth: bitDepth.rawValue,
|
||||
dpi: tiffDpi,
|
||||
randomSeed: layoutOrder == .deterministic ? 1 : customSeed,
|
||||
noRandomize: layoutOrder == .raster,
|
||||
targen: buildTargenConfig(),
|
||||
printtarg: buildPrinttargConfig(),
|
||||
colprof: profile.buildColprofConfig(),
|
||||
calibrationFile: profile.calibrationFile.isEmpty ? nil : profile.calibrationFile,
|
||||
applyCalibration: profile.applyCalibration ? true : nil,
|
||||
colprofAlgorithm: profile.algorithm,
|
||||
colprofQuality: profile.quality,
|
||||
colprofIntent: profile.intent.isEmpty ? nil : profile.intent,
|
||||
colprofFwa: profile.fwaValue,
|
||||
colprofIlluminant: profile.illuminant.isEmpty ? nil : profile.illuminant,
|
||||
colprofObserver: profile.observer.isEmpty ? nil : profile.observer,
|
||||
colprofInputViewingCond: profile.inputViewingCond.isEmpty ? nil : profile.inputViewingCond,
|
||||
colprofOutputViewingCond: profile.outputViewingCond.isEmpty ? nil : profile.outputViewingCond
|
||||
applyCalibration: profile.applyCalibration ? true : nil
|
||||
)
|
||||
do {
|
||||
try environment.presetStore.saveCustom(preset)
|
||||
@@ -710,10 +511,6 @@ final class TargetWorkflowViewModel {
|
||||
}
|
||||
|
||||
static func parseCustomPage(_ raw: String) -> (Double, Double)? {
|
||||
let parts = raw.lowercased().split(separator: "x")
|
||||
guard parts.count == 2,
|
||||
let w = Double(parts[0]), let h = Double(parts[1]),
|
||||
w >= 50, h >= 50 else { return nil }
|
||||
return (w, h)
|
||||
PageSize.parseCustom(raw)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
import Observation
|
||||
import ICCeryCore
|
||||
|
||||
/// Wizard state machine + artefact gating (issue #4, docs/06).
|
||||
@@ -10,47 +10,46 @@ import ICCeryCore
|
||||
/// `wizard_state.json`; unlocks come from `ArtefactProbe.verify` —
|
||||
/// navigation is disk, not buttons.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class WizardViewModel {
|
||||
final class WizardViewModel: ObservableObject {
|
||||
|
||||
// MARK: - wizardState fields (persisted)
|
||||
|
||||
var stage: WizardStage {
|
||||
@Published var stage: WizardStage {
|
||||
didSet { if stage != oldValue { persist() } }
|
||||
}
|
||||
/// `wizardState.basename` — empty until a real artefact names it (#60).
|
||||
var basename: String {
|
||||
@Published var basename: String {
|
||||
didSet { if basename != oldValue { refreshGating(); persist() } }
|
||||
}
|
||||
/// `wizardState.cwd` — resolved via `resolveSafeCwd` (#59).
|
||||
var workingDirectory: URL? {
|
||||
@Published var workingDirectory: URL? {
|
||||
didSet { if workingDirectory != oldValue { refreshGating(); persist() } }
|
||||
}
|
||||
var printerName: String? {
|
||||
@Published var printerName: String? {
|
||||
didSet { if printerName != oldValue { persist() } }
|
||||
}
|
||||
var sessionMode: SessionMode {
|
||||
@Published var sessionMode: SessionMode {
|
||||
didSet { if sessionMode != oldValue { persist() } }
|
||||
}
|
||||
/// `profileBasename` may differ after a `.ti3` import (#94).
|
||||
var profileBasename: String? {
|
||||
@Published var profileBasename: String? {
|
||||
didSet { if profileBasename != oldValue { persist() } }
|
||||
}
|
||||
/// Pre-`CAL_` basename, persisted so relaunch/Force Quit can restore it (#29).
|
||||
var calibrationOriginalBasename: String {
|
||||
@Published var calibrationOriginalBasename: String {
|
||||
didSet { if calibrationOriginalBasename != oldValue { persist() } }
|
||||
}
|
||||
|
||||
// MARK: - Ephemeral
|
||||
|
||||
/// Banner notice currently displayed (`#wizardNotification`).
|
||||
var notice: Notice?
|
||||
@Published var notice: Notice?
|
||||
/// Current artefact probe result; recomputed on `refreshGating()`.
|
||||
private(set) var artefacts = StageArtefacts()
|
||||
@Published private(set) var artefacts = StageArtefacts()
|
||||
/// Whether the 3D gamut viewer sheet is open (issue #28).
|
||||
var showingGamutViewer = false
|
||||
@Published var showingGamutViewer = false
|
||||
/// Optional `.gam` URL to show alongside the sRGB reference.
|
||||
var gamutProfileURL: URL?
|
||||
@Published var gamutProfileURL: URL?
|
||||
|
||||
private let stateStore: WizardStateStore
|
||||
private var noticeDismissTask: Task<Void, Never>?
|
||||
@@ -67,8 +66,12 @@ final class WizardViewModel {
|
||||
self.calibrationOriginalBasename = s.calibrationOriginalBasename
|
||||
// A Force Quit mid-calibration leaves a CAL_ basename behind; restore
|
||||
// the original before the UI can do anything with it (#29).
|
||||
if basename.hasPrefix("CAL_"), !calibrationOriginalBasename.isEmpty {
|
||||
basename = calibrationOriginalBasename
|
||||
if CalibrationIdentity.isCalibration(basename), !calibrationOriginalBasename.isEmpty {
|
||||
let identity = CalibrationIdentity.parse(
|
||||
liveBasename: basename,
|
||||
persistedOriginal: calibrationOriginalBasename
|
||||
)
|
||||
basename = identity.originalBasename
|
||||
calibrationOriginalBasename = ""
|
||||
sessionMode = .profile
|
||||
stage = .generate
|
||||
@@ -128,7 +131,7 @@ final class WizardViewModel {
|
||||
/// refused and the original basename is restored (#29).
|
||||
func go(to target: WizardStage) {
|
||||
guard target != .calibrate else { enterCalibration(); return }
|
||||
if basename.hasPrefix("CAL_") {
|
||||
if CalibrationIdentity.isCalibration(basename) {
|
||||
guard !calibrationOriginalBasename.isEmpty else {
|
||||
showNotice(
|
||||
"Cannot leave calibration — the original target name is missing.",
|
||||
@@ -152,7 +155,7 @@ final class WizardViewModel {
|
||||
}
|
||||
|
||||
func enterCalibration() {
|
||||
if !basename.isEmpty, !basename.hasPrefix("CAL_"), calibrationOriginalBasename.isEmpty {
|
||||
if !basename.isEmpty, !CalibrationIdentity.isCalibration(basename), calibrationOriginalBasename.isEmpty {
|
||||
calibrationOriginalBasename = basename
|
||||
}
|
||||
sessionMode = .calibration
|
||||
@@ -197,7 +200,7 @@ final class WizardViewModel {
|
||||
self.notice = notice
|
||||
if let delay = notice.autoHideAfter {
|
||||
noticeDismissTask = Task { [weak self] in
|
||||
try? await Task.sleep(for: .seconds(delay))
|
||||
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
||||
guard !Task.isCancelled else { return }
|
||||
if self?.notice?.id == notice.id {
|
||||
self?.notice = nil
|
||||
|
||||
@@ -1,27 +1,25 @@
|
||||
import Testing
|
||||
import XCTest
|
||||
import Foundation
|
||||
@testable import ICCeryCore
|
||||
|
||||
@Suite("AppPaths")
|
||||
struct AppPathsTests {
|
||||
@Test func appDataDirUsesBundleID() {
|
||||
#expect(AppPaths.appDataDir.path.contains("Library/Application Support/com.gronod.iccery2"))
|
||||
final class AppPathsTests: XCTestCase {
|
||||
func testAppDataDirUsesBundleID() {
|
||||
XCTAssertTrue(AppPaths.appDataDir.path.contains("Library/Application Support/com.gronod.iccery2"))
|
||||
}
|
||||
|
||||
@Test func logFileIsUnderLibraryLogs() {
|
||||
#expect(AppPaths.logFile.lastPathComponent == "iccery.log")
|
||||
#expect(AppPaths.logFile.path.contains("Library/Logs/com.gronod.iccery2"))
|
||||
func testLogFileIsUnderLibraryLogs() {
|
||||
XCTAssertEqual(AppPaths.logFile.lastPathComponent, "iccery.log")
|
||||
XCTAssertTrue(AppPaths.logFile.path.contains("Library/Logs/com.gronod.iccery2"))
|
||||
}
|
||||
|
||||
@Test func bundledArgyllDirIsInsideResources() {
|
||||
#expect(AppPaths.bundledArgyllDir.lastPathComponent == "Argyll")
|
||||
func testBundledArgyllDirIsInsideResources() {
|
||||
XCTAssertEqual(AppPaths.bundledArgyllDir.lastPathComponent, "Argyll")
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("WizardStage")
|
||||
struct WizardStageTests {
|
||||
@Test func stepperOrderIsOneThroughFive() {
|
||||
#expect(WizardStage.stepperStages.map(\.stepperIndex) == [1, 2, 3, 4, 5])
|
||||
#expect(WizardStage.calibrate.stepperIndex == nil)
|
||||
final class WizardStageTests: XCTestCase {
|
||||
func testStepperOrderIsOneThroughFive() {
|
||||
XCTAssertEqual(WizardStage.stepperStages.map(\.stepperIndex), [1, 2, 3, 4, 5])
|
||||
XCTAssertNil(WizardStage.calibrate.stepperIndex)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,19 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import XCTest
|
||||
@testable import ICCeryCore
|
||||
|
||||
@Suite("ApplycalArgs")
|
||||
struct ApplycalArgsTests {
|
||||
final class ApplycalArgsTests: XCTestCase {
|
||||
|
||||
@Test("Apply argv")
|
||||
func applyArgv() throws {
|
||||
func testApplyArgv() throws {
|
||||
let config = ApplycalConfig(
|
||||
calibrationPath: "/tmp/cal.cal",
|
||||
inputProfileURL: URL(fileURLWithPath: "/tmp/profile.icc")
|
||||
)
|
||||
let args = try ApplycalArgs.build(config: config)
|
||||
#expect(args == ["-v", "-a", "/tmp/cal.cal", "/tmp/profile.icc"])
|
||||
XCTAssertEqual(args, ["-v", "-a", "/tmp/cal.cal", "/tmp/profile.icc"])
|
||||
}
|
||||
|
||||
@Test("Unapply is emitted when the caller explicitly sets it")
|
||||
func unapplyEmittedWhenConfigSet() throws {
|
||||
func testUnapplyEmittedWhenConfigSet() throws {
|
||||
let config = ApplycalConfig(
|
||||
calibrationPath: "/tmp/cal.cal",
|
||||
inputProfileURL: URL(fileURLWithPath: "/tmp/profile.icc"),
|
||||
@@ -25,6 +22,6 @@ struct ApplycalArgsTests {
|
||||
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.
|
||||
#expect(args == ["-v", "-u", "/tmp/cal.cal", "/tmp/profile.icc"])
|
||||
XCTAssertEqual(args, ["-v", "-u", "/tmp/cal.cal", "/tmp/profile.icc"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
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,17 +1,16 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import XCTest
|
||||
@testable import ICCeryCore
|
||||
|
||||
@Suite("ArgyllRunner Calibration")
|
||||
struct ArgyllRunnerCalibrationTests {
|
||||
final class ArgyllRunnerCalibrationTests: XCTestCase {
|
||||
|
||||
private func makeRunner() -> ArgyllRunner {
|
||||
private func makeRunner(processManager: ProcessManager = ProcessManager()) -> ArgyllRunner {
|
||||
let binDir = URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("ICCeryUITests/Fixtures/bin")
|
||||
return ArgyllRunner(
|
||||
processManager: .shared,
|
||||
processManager: processManager,
|
||||
binaryResolver: BinaryResolver(overrideDir: binDir)
|
||||
)
|
||||
}
|
||||
@@ -23,8 +22,7 @@ struct ArgyllRunnerCalibrationTests {
|
||||
return root
|
||||
}
|
||||
|
||||
@Test("Calibration targen produces CAL_*.ti1")
|
||||
func calibrationTargenProducesTi1() async throws {
|
||||
func testCalibrationTargenProducesTi1() async throws {
|
||||
let testRoot = try makeTestDir()
|
||||
let runner = makeRunner()
|
||||
let config = CalibrationTargenConfig(
|
||||
@@ -36,13 +34,41 @@ struct ArgyllRunnerCalibrationTests {
|
||||
|
||||
let url = try await runner.runCalibrationTargen(config: config)
|
||||
|
||||
#expect(url.lastPathComponent == "CAL_demo.ti1")
|
||||
#expect(FileManager.default.fileExists(atPath: url.path))
|
||||
XCTAssertEqual(url.lastPathComponent, "CAL_demo.ti1")
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
|
||||
try? FileManager.default.removeItem(at: testRoot)
|
||||
}
|
||||
|
||||
@Test("printcal captured run creates .cal")
|
||||
func printcalProducesCal() async throws {
|
||||
func testCalibrationTargenProcessId() async throws {
|
||||
let testRoot = try makeTestDir()
|
||||
let pm = ProcessManager()
|
||||
let runner = makeRunner(processManager: pm)
|
||||
let events = pm.events()
|
||||
// Subscribed before spawn; the exit event is emitted before
|
||||
// runCalibrationTargen returns, so this always terminates.
|
||||
let sawExit = Task {
|
||||
for await event in events {
|
||||
guard event.id == "targen_CAL_foo" else { continue }
|
||||
if case .exit = event { return true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
let config = CalibrationTargenConfig(
|
||||
colourSpace: .rgb,
|
||||
steps: 21,
|
||||
basename: "foo",
|
||||
workingDirectory: testRoot
|
||||
)
|
||||
|
||||
let url = try await runner.runCalibrationTargen(config: config)
|
||||
|
||||
XCTAssertEqual(url.lastPathComponent, "CAL_foo.ti1")
|
||||
let sawExitEvent = await sawExit.value
|
||||
XCTAssertTrue(sawExitEvent)
|
||||
try? FileManager.default.removeItem(at: testRoot)
|
||||
}
|
||||
|
||||
func testPrintcalProducesCal() async throws {
|
||||
let testRoot = try makeTestDir()
|
||||
let runner = makeRunner()
|
||||
let output = testRoot.appendingPathComponent("CAL_demo.cal")
|
||||
@@ -54,15 +80,32 @@ struct ArgyllRunnerCalibrationTests {
|
||||
|
||||
let url = try await runner.runPrintcal(config: config)
|
||||
|
||||
#expect(url.lastPathComponent == "CAL_demo.cal")
|
||||
#expect(FileManager.default.fileExists(atPath: url.path))
|
||||
XCTAssertEqual(url.lastPathComponent, "CAL_demo.cal")
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
|
||||
try? FileManager.default.removeItem(at: testRoot)
|
||||
}
|
||||
|
||||
@Test("printcal failure throws printcalFailed")
|
||||
func printcalFailureThrows() async throws {
|
||||
func testPrintcalFailureThrows() async throws {
|
||||
let testRoot = try makeTestDir()
|
||||
let runner = makeRunner()
|
||||
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 output = testRoot.appendingPathComponent("CAL_demo.cal")
|
||||
let config = PrintcalConfig(
|
||||
ti3Basename: "CAL_demo",
|
||||
@@ -70,12 +113,11 @@ struct ArgyllRunnerCalibrationTests {
|
||||
outputURL: output
|
||||
)
|
||||
|
||||
setenv("ICCERY_MOCK_PRINTCAL_EXIT", "1", 1)
|
||||
defer { unsetenv("ICCERY_MOCK_PRINTCAL_EXIT") }
|
||||
|
||||
await #expect(throws: (any Error).self) {
|
||||
await assertAsyncThrows(expectedType: ArgyllRunnerError.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 Testing
|
||||
import XCTest
|
||||
@testable import ICCeryCore
|
||||
|
||||
final class LogHolder: @unchecked Sendable {
|
||||
@@ -19,11 +19,9 @@ final class LogHolder: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("ArgyllRunner colprof")
|
||||
struct ArgyllRunnerColprofTests {
|
||||
final class ArgyllRunnerColprofTests: XCTestCase {
|
||||
|
||||
@Test("Mock colprof produces .icc")
|
||||
func colprofProducesIcc() async throws {
|
||||
func testColprofProducesIcc() async throws {
|
||||
let binDir = URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
@@ -33,7 +31,7 @@ struct ArgyllRunnerColprofTests {
|
||||
try FileManager.default.createDirectory(at: testRoot, withIntermediateDirectories: true)
|
||||
|
||||
let runner = ArgyllRunner(
|
||||
processManager: .shared,
|
||||
processManager: ProcessManager(),
|
||||
binaryResolver: BinaryResolver(overrideDir: binDir)
|
||||
)
|
||||
|
||||
@@ -43,10 +41,39 @@ struct ArgyllRunnerColprofTests {
|
||||
holder.append(batch)
|
||||
}
|
||||
|
||||
#expect(url.lastPathComponent == "testrun.icc")
|
||||
#expect(FileManager.default.fileExists(atPath: url.path))
|
||||
#expect(holder.lines.contains { $0.contains("Gamut mapping") })
|
||||
XCTAssertEqual(url.lastPathComponent, "testrun.icc")
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
|
||||
XCTAssertTrue(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"]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import Testing
|
||||
import XCTest
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import UniformTypeIdentifiers
|
||||
@@ -10,9 +10,8 @@ private func tempURL(_ name: String) -> URL {
|
||||
.appendingPathComponent(name)
|
||||
}
|
||||
|
||||
@Suite("Ti2Header")
|
||||
struct Ti2HeaderTests {
|
||||
@Test func parsesKeywordsAndSibling() throws {
|
||||
final class Ti2HeaderTests: XCTestCase {
|
||||
func testParsesKeywordsAndSibling() throws {
|
||||
let dir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("iccery-ti2-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
@@ -31,18 +30,18 @@ struct Ti2HeaderTests {
|
||||
)
|
||||
|
||||
let h = Ti2Header.parse(dir.appendingPathComponent("job.ti2"))
|
||||
#expect(h.instrument == "i1iO")
|
||||
#expect(h.patchCount == 800)
|
||||
#expect(h.pageCount == 3)
|
||||
#expect(h.hasSiblingTi1)
|
||||
XCTAssertEqual(h.instrument, "i1iO")
|
||||
XCTAssertEqual(h.patchCount, 800)
|
||||
XCTAssertEqual(h.pageCount, 3)
|
||||
XCTAssertTrue(h.hasSiblingTi1)
|
||||
}
|
||||
|
||||
@Test func missingFileYieldsEmptyHeader() {
|
||||
func testMissingFileYieldsEmptyHeader() {
|
||||
let h = Ti2Header.parse(URL(fileURLWithPath: "/nonexistent/x.ti2"))
|
||||
#expect(h.instrument == nil && h.patchCount == nil && !h.hasSiblingTi1)
|
||||
XCTAssertTrue(h.instrument == nil && h.patchCount == nil && !h.hasSiblingTi1)
|
||||
}
|
||||
|
||||
@Test func numberOfFieldsIsNotPatchCount() throws {
|
||||
func testNumberOfFieldsIsNotPatchCount() throws {
|
||||
let url = tempURL("t.ti2")
|
||||
try FileManager.default.createDirectory(
|
||||
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||
@@ -50,12 +49,11 @@ struct Ti2HeaderTests {
|
||||
try "NUMBER_OF_FIELDS 9\nNUMBER_OF_SETS 52\nBEGIN_DATA\n".write(
|
||||
to: url, atomically: true, encoding: .utf8
|
||||
)
|
||||
#expect(Ti2Header.parse(url).patchCount == 52)
|
||||
XCTAssertEqual(Ti2Header.parse(url).patchCount, 52)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("TiffPreview")
|
||||
struct TiffPreviewTests {
|
||||
final class TiffPreviewTests: XCTestCase {
|
||||
/// 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")
|
||||
@@ -81,44 +79,134 @@ struct TiffPreviewTests {
|
||||
return url
|
||||
}
|
||||
|
||||
@Test func producesCappedPNG() throws {
|
||||
func testProducesCappedPNG() throws {
|
||||
let tiff = try makeTiff()
|
||||
let png = TiffPreview.previewPNG(tiff: tiff)
|
||||
#expect(png != nil)
|
||||
XCTAssertNotNil(png)
|
||||
// PNG magic
|
||||
#expect(png!.prefix(8) == Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]))
|
||||
XCTAssertEqual(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)!
|
||||
#expect(max(img.width, img.height) <= TiffPreview.maxEdge)
|
||||
#expect(img.width == 1200)
|
||||
XCTAssertTrue(max(img.width, img.height) <= TiffPreview.maxEdge)
|
||||
XCTAssertEqual(img.width, 1200)
|
||||
}
|
||||
|
||||
@Test func nonTiffReturnsNil() throws {
|
||||
func testNonTiffReturnsNil() 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)
|
||||
#expect(TiffPreview.previewPNG(tiff: url) == nil)
|
||||
XCTAssertNil(TiffPreview.previewPNG(tiff: url))
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("ArtefactFiles")
|
||||
struct ArtefactFilesTests {
|
||||
@Test func base64RoundTrip() throws {
|
||||
final class ArtefactFilesTests: XCTestCase {
|
||||
func testBase64RoundTrip() 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)
|
||||
#expect(Data(base64Encoded: b64) == Data("hello".utf8))
|
||||
XCTAssertEqual(Data(base64Encoded: b64), Data("hello".utf8))
|
||||
}
|
||||
|
||||
@Test func defaultWorkingDirExists() {
|
||||
#expect(FileManager.default.fileExists(
|
||||
func testDefaultWorkingDirExists() {
|
||||
XCTAssertTrue(FileManager.default.fileExists(
|
||||
atPath: ArtefactFiles.defaultWorkingDirectory().path
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
final class ArtefactProbeProfileTests: XCTestCase {
|
||||
private func makeDir() throws -> URL {
|
||||
let dir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("probe-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
return dir
|
||||
}
|
||||
|
||||
// MARK: Basename probe matrix (#69)
|
||||
|
||||
func testOnlyIcc() 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)
|
||||
}
|
||||
|
||||
func testOnlyIcm() 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)
|
||||
}
|
||||
|
||||
func testIcmWins() 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)
|
||||
}
|
||||
|
||||
func testNeitherExists() throws {
|
||||
let dir = try makeDir()
|
||||
XCTAssertNil(ArtefactProbe.resolveProfile(basename: "job", cwd: dir))
|
||||
}
|
||||
|
||||
// MARK: Explicit URL matrix (#69 / #83)
|
||||
|
||||
func testExplicitIccWins() 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)
|
||||
}
|
||||
|
||||
func testExplicitIcmWins() 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)
|
||||
}
|
||||
|
||||
func testFlipExtension() 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)
|
||||
}
|
||||
|
||||
func testFlipToIcc() 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)
|
||||
}
|
||||
|
||||
func testMissingBoth() throws {
|
||||
let dir = try makeDir()
|
||||
let icc = dir.appendingPathComponent("job.icc")
|
||||
XCTAssertEqual(ArtefactProbe.resolveProfile(icc).path, icc.path)
|
||||
}
|
||||
|
||||
func testUnrelatedExtension() 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)
|
||||
let txt = dir.appendingPathComponent("job.txt")
|
||||
XCTAssertEqual(ArtefactProbe.resolveProfile(txt).path, txt.path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import Testing
|
||||
import XCTest
|
||||
import Foundation
|
||||
@testable import ICCeryCore
|
||||
|
||||
@Suite("BinaryResolver")
|
||||
struct BinaryResolverTests {
|
||||
final class BinaryResolverTests: XCTestCase {
|
||||
|
||||
private func makeTree(_ body: (URL) throws -> Void) throws -> URL {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
@@ -22,16 +21,16 @@ struct BinaryResolverTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test func overrideDirWinsWhenFileExists() throws {
|
||||
func testOverrideDirWinsWhenFileExists() throws {
|
||||
let override = try makeTree { root in
|
||||
try touch(root.appendingPathComponent("targen"))
|
||||
}
|
||||
let bundled = try makeTree { _ in }
|
||||
let r = BinaryResolver(bundledRoot: bundled, overrideDir: override)
|
||||
#expect(r.resolve("targen") == override.appendingPathComponent("targen"))
|
||||
XCTAssertEqual(r.resolve("targen"), override.appendingPathComponent("targen"))
|
||||
}
|
||||
|
||||
@Test func overrideFallsThroughWhenMissing() throws {
|
||||
func testOverrideFallsThroughWhenMissing() throws {
|
||||
let override = try makeTree { _ in }
|
||||
let bundled = try makeTree { root in
|
||||
let dir = root.appendingPathComponent("macos-universal")
|
||||
@@ -39,10 +38,10 @@ struct BinaryResolverTests {
|
||||
try touch(dir.appendingPathComponent("instlist"))
|
||||
}
|
||||
let r = BinaryResolver(bundledRoot: bundled, overrideDir: override)
|
||||
#expect(r.resolve("targen").path.contains("macos-universal/targen"))
|
||||
XCTAssertTrue(r.resolve("targen").path.contains("macos-universal/targen"))
|
||||
}
|
||||
|
||||
@Test func universalPreferredWhenMarkerPresent() throws {
|
||||
func testUniversalPreferredWhenMarkerPresent() throws {
|
||||
let bundled = try makeTree { root in
|
||||
for dir in ["macos-universal", "macos-x86_64"] {
|
||||
let d = root.appendingPathComponent(dir)
|
||||
@@ -51,10 +50,10 @@ struct BinaryResolverTests {
|
||||
}
|
||||
}
|
||||
let r = BinaryResolver(bundledRoot: bundled)
|
||||
#expect(r.platformDir() == "macos-universal")
|
||||
XCTAssertEqual(r.platformDir(), "macos-universal")
|
||||
}
|
||||
|
||||
@Test func fallsBackToArchDir() throws {
|
||||
func testFallsBackToArchDir() throws {
|
||||
let bundled = try makeTree { root in
|
||||
let d = root.appendingPathComponent("macos-x86_64")
|
||||
try FileManager.default.createDirectory(at: d, withIntermediateDirectories: true)
|
||||
@@ -64,20 +63,20 @@ struct BinaryResolverTests {
|
||||
bundledRoot: bundled,
|
||||
archDirs: ["macos-universal", "macos-x86_64"]
|
||||
)
|
||||
#expect(r.platformDir() == "macos-x86_64")
|
||||
XCTAssertEqual(r.platformDir(), "macos-x86_64")
|
||||
}
|
||||
|
||||
@Test func missingEverythingReturnsConstructedPath() throws {
|
||||
func testMissingEverythingReturnsConstructedPath() throws {
|
||||
let bundled = try makeTree { _ in }
|
||||
let r = BinaryResolver(bundledRoot: bundled)
|
||||
// v1 semantic: path is returned; spawn surfaces the error.
|
||||
#expect(r.resolve("targen").path.hasSuffix("macos-universal/targen"))
|
||||
#expect(!r.exists(r.resolve("targen")))
|
||||
XCTAssertTrue(r.resolve("targen").path.hasSuffix("macos-universal/targen"))
|
||||
XCTAssertFalse(r.exists(r.resolve("targen")))
|
||||
}
|
||||
|
||||
@Test func mockAndGamutPaths() throws {
|
||||
func testMockAndGamutPaths() throws {
|
||||
let r = BinaryResolver(bundledRoot: URL(fileURLWithPath: "/x"))
|
||||
#expect(r.mock("chartread").path == "/x/mocks/chartread.mock")
|
||||
#expect(r.referenceGamut("sRGB.gam").path == "/x/reference_gamuts/sRGB.gam")
|
||||
XCTAssertEqual(r.mock("chartread").path, "/x/mocks/chartread.mock")
|
||||
XCTAssertEqual(r.referenceGamut("sRGB.gam").path, "/x/reference_gamuts/sRGB.gam")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import XCTest
|
||||
@testable import ICCeryCore
|
||||
|
||||
@Suite("CGATS Parser & Writer")
|
||||
struct CGATSParserTests {
|
||||
final class CGATSParserTests: XCTestCase {
|
||||
|
||||
private static let canonicalCTI3 = """
|
||||
CTI3
|
||||
@@ -21,44 +20,40 @@ struct CGATSParserTests {
|
||||
END_DATA
|
||||
"""
|
||||
|
||||
@Test("Parses CTI3 with canonical field names")
|
||||
func parseCTI3() throws {
|
||||
func testParseCTI3() throws {
|
||||
let dataset = try CGATSParser.parse(Self.canonicalCTI3)
|
||||
#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")
|
||||
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")
|
||||
}
|
||||
|
||||
@Test("Round-trips parse, write, reparse")
|
||||
func roundTrip() throws {
|
||||
func testRoundTrip() throws {
|
||||
let first = try CGATSParser.parse(Self.canonicalCTI3)
|
||||
let text = try CGATSWriter.write(first)
|
||||
let second = try CGATSParser.parse(text)
|
||||
#expect(second.format == first.format)
|
||||
#expect(second.samples.count == first.samples.count)
|
||||
#expect(second.colorRep == first.colorRep)
|
||||
#expect(second.deviceClass == first.deviceClass)
|
||||
XCTAssertEqual(second.format, first.format)
|
||||
XCTAssertEqual(second.samples.count, first.samples.count)
|
||||
XCTAssertEqual(second.colorRep, first.colorRep)
|
||||
XCTAssertEqual(second.deviceClass, first.deviceClass)
|
||||
}
|
||||
|
||||
@Test("Parses CSV with comma delimiters")
|
||||
func parseCSV() throws {
|
||||
func testParseCSV() 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"))
|
||||
#expect(dataset.format == .csv)
|
||||
#expect(dataset.samples.count == 2)
|
||||
#expect(dataset.samples[0].values["RGB_R"] == "50.0000")
|
||||
XCTAssertEqual(dataset.format, .csv)
|
||||
XCTAssertEqual(dataset.samples.count, 2)
|
||||
XCTAssertEqual(dataset.samples[0].values["RGB_R"], "50.0000")
|
||||
}
|
||||
|
||||
@Test("Converts 0-255 device values to 0-100")
|
||||
func converts255To100() throws {
|
||||
func testConverts255To100() throws {
|
||||
let rgb = """
|
||||
CTI3
|
||||
COLOR_REP RGB
|
||||
@@ -72,12 +67,11 @@ struct CGATSParserTests {
|
||||
END_DATA
|
||||
"""
|
||||
let dataset = try CGATSParser.parse(rgb)
|
||||
#expect(dataset.samples[0].values["RGB_R"] == "100.0000")
|
||||
#expect(dataset.samples[0].values["RGB_G"] == "50.1961")
|
||||
XCTAssertEqual(dataset.samples[0].values["RGB_R"], "100.0000")
|
||||
XCTAssertEqual(dataset.samples[0].values["RGB_G"], "50.1961")
|
||||
}
|
||||
|
||||
@Test("Synthesizes COLOR_REP and DEVICE_CLASS when missing")
|
||||
func synthesizesMetadata() throws {
|
||||
func testSynthesizesMetadata() throws {
|
||||
let cmyk = """
|
||||
CTI3
|
||||
NUMBER_OF_FIELDS 6
|
||||
@@ -90,19 +84,15 @@ struct CGATSParserTests {
|
||||
END_DATA
|
||||
"""
|
||||
let dataset = try CGATSParser.parse(cmyk)
|
||||
#expect(dataset.colorRep == "CMYK")
|
||||
#expect(dataset.deviceClass == "PRINTER")
|
||||
XCTAssertEqual(dataset.colorRep, "CMYK")
|
||||
XCTAssertEqual(dataset.deviceClass, "PRINTER")
|
||||
}
|
||||
|
||||
@Test("Rejects empty file")
|
||||
func rejectsEmpty() {
|
||||
#expect(throws: (any Error).self) {
|
||||
_ = try CGATSParser.parse("")
|
||||
}
|
||||
func testRejectsEmpty() {
|
||||
XCTAssertThrowsError(try CGATSParser.parse(""))
|
||||
}
|
||||
|
||||
@Test("Rejects malformed arity")
|
||||
func rejectsArity() {
|
||||
func testRejectsArity() {
|
||||
let bad = """
|
||||
CTI3
|
||||
NUMBER_OF_FIELDS 2
|
||||
@@ -114,21 +104,18 @@ struct CGATSParserTests {
|
||||
1
|
||||
END_DATA
|
||||
"""
|
||||
#expect(throws: (any Error).self) {
|
||||
_ = try CGATSParser.parse(bad)
|
||||
}
|
||||
XCTAssertThrowsError(try CGATSParser.parse(bad))
|
||||
}
|
||||
|
||||
@Test("Writer emits valid .ti3 with tabs and required keywords")
|
||||
func writerFormat() throws {
|
||||
func testWriterFormat() throws {
|
||||
let dataset = try CGATSParser.parse(Self.canonicalCTI3)
|
||||
let text = try CGATSWriter.write(dataset)
|
||||
#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"))
|
||||
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"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import ICCeryCore
|
||||
|
||||
/// Issue #83 — canonical `CAL_` / original-stem pairing.
|
||||
final class CalibrationIdentityTests: XCTestCase {
|
||||
func testLivePlain() {
|
||||
let id = CalibrationIdentity.parse(liveBasename: "foo", persistedOriginal: "")
|
||||
XCTAssertEqual(id.originalBasename, "foo")
|
||||
XCTAssertEqual(id.calibrationBasename, "CAL_foo")
|
||||
}
|
||||
|
||||
func testLivePlainIgnoresPersisted() {
|
||||
let id = CalibrationIdentity.parse(liveBasename: "foo", persistedOriginal: "bar")
|
||||
XCTAssertEqual(id.originalBasename, "foo")
|
||||
XCTAssertEqual(id.calibrationBasename, "CAL_foo")
|
||||
}
|
||||
|
||||
func testLiveCalPersisted() {
|
||||
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "foo")
|
||||
XCTAssertEqual(id.originalBasename, "foo")
|
||||
XCTAssertEqual(id.calibrationBasename, "CAL_foo")
|
||||
}
|
||||
|
||||
func testLiveCalNoPersist() {
|
||||
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "")
|
||||
XCTAssertEqual(id.originalBasename, "foo")
|
||||
XCTAssertEqual(id.calibrationBasename, "CAL_foo")
|
||||
}
|
||||
|
||||
func testPersistedWins() {
|
||||
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "bar")
|
||||
XCTAssertEqual(id.originalBasename, "bar")
|
||||
XCTAssertEqual(id.calibrationBasename, "CAL_bar")
|
||||
}
|
||||
|
||||
func testEmptyLiveWithPersisted() {
|
||||
let id = CalibrationIdentity.parse(liveBasename: "", persistedOriginal: "foo")
|
||||
XCTAssertTrue(id.originalBasename.isEmpty)
|
||||
XCTAssertTrue(id.calibrationBasename.isEmpty)
|
||||
}
|
||||
|
||||
func testEmptyLive() {
|
||||
let id = CalibrationIdentity.parse(liveBasename: "", persistedOriginal: "")
|
||||
XCTAssertTrue(id.originalBasename.isEmpty)
|
||||
XCTAssertTrue(id.calibrationBasename.isEmpty)
|
||||
}
|
||||
|
||||
func testAlreadyPrefixed() {
|
||||
XCTAssertEqual(CalibrationIdentity.prefix("CAL_foo"), "CAL_foo")
|
||||
XCTAssertEqual(CalibrationIdentity.prefix("foo"), "CAL_foo")
|
||||
let id = CalibrationIdentity.parse(liveBasename: "CAL_CAL_foo", persistedOriginal: "")
|
||||
XCTAssertEqual(id.originalBasename, "CAL_foo")
|
||||
XCTAssertEqual(id.calibrationBasename, "CAL_foo")
|
||||
}
|
||||
|
||||
func testPrefixEmpty() {
|
||||
XCTAssertTrue(CalibrationIdentity.prefix("").isEmpty)
|
||||
XCTAssertEqual(CalibrationIdentity.strip("foo"), "foo")
|
||||
XCTAssertEqual(CalibrationIdentity.strip("CAL_foo"), "foo")
|
||||
}
|
||||
|
||||
func testProcessIdMatches() {
|
||||
let cal = CalibrationIdentity.prefix("foo")
|
||||
XCTAssertEqual(ProcessID.targen(cal), "targen_CAL_foo")
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import XCTest
|
||||
@testable import ICCeryCore
|
||||
|
||||
@Suite("CalibrationStore")
|
||||
struct CalibrationStoreTests {
|
||||
final class CalibrationStoreTests: XCTestCase {
|
||||
|
||||
private static let sampleCal = """
|
||||
CTI3
|
||||
@@ -23,8 +22,7 @@ struct CalibrationStoreTests {
|
||||
END_DATA
|
||||
"""
|
||||
|
||||
@Test("Loads metadata and curves from .cal")
|
||||
func parseCal() async throws {
|
||||
func testParseCal() async throws {
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("test_\(UUID().uuidString).cal")
|
||||
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
|
||||
@@ -33,17 +31,16 @@ struct CalibrationStoreTests {
|
||||
try await store.load(url: url)
|
||||
|
||||
let data = await store.data
|
||||
#expect(data?.colorRep == "RGB")
|
||||
#expect(data?.descriptor == "Test printer")
|
||||
#expect(data?.maxTac == 300)
|
||||
#expect(data?.curves.count == 3)
|
||||
XCTAssertEqual(data?.colorRep, "RGB")
|
||||
XCTAssertEqual(data?.descriptor, "Test printer")
|
||||
XCTAssertEqual(data?.maxTac, 300)
|
||||
XCTAssertEqual(data?.curves.count, 3)
|
||||
|
||||
let r = data?.curves.first { $0.channel == "R" }
|
||||
#expect(r?.output == [0, 64, 255])
|
||||
XCTAssertEqual(r?.output, [0, 64, 255])
|
||||
}
|
||||
|
||||
@Test("Staleness is true for a very old calibration")
|
||||
func staleCalibration() async throws {
|
||||
func testStaleCalibration() async throws {
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("stale_\(UUID().uuidString).cal")
|
||||
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
|
||||
@@ -51,11 +48,10 @@ struct CalibrationStoreTests {
|
||||
let store = CalibrationStore(staleDays: 0)
|
||||
try await store.load(url: url)
|
||||
let stale = await store.isStale(comparedTo: "Other")
|
||||
#expect(stale == true)
|
||||
XCTAssertEqual(stale, true)
|
||||
}
|
||||
|
||||
@Test("Printer mismatch is flagged as stale")
|
||||
func printerMismatch() async throws {
|
||||
func testPrinterMismatch() async throws {
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("mismatch_\(UUID().uuidString).cal")
|
||||
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
|
||||
@@ -64,6 +60,6 @@ struct CalibrationStoreTests {
|
||||
try await store.load(url: url)
|
||||
await store.setPrinterName("Printer A")
|
||||
let stale = await store.isStale(comparedTo: "Printer B")
|
||||
#expect(stale == true)
|
||||
XCTAssertEqual(stale, true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import XCTest
|
||||
@testable import ICCeryCore
|
||||
|
||||
@Suite("CalibrationTargenArgs")
|
||||
struct CalibrationTargenArgsTests {
|
||||
final class CalibrationTargenArgsTests: XCTestCase {
|
||||
|
||||
@Test("RGB baseline")
|
||||
func rgbBaseline() throws {
|
||||
func testRgbBaseline() throws {
|
||||
let config = CalibrationTargenConfig(
|
||||
colourSpace: .rgb,
|
||||
steps: 21,
|
||||
@@ -15,11 +13,10 @@ struct CalibrationTargenArgsTests {
|
||||
workingDirectory: URL(fileURLWithPath: "/tmp")
|
||||
)
|
||||
let args = try CalibrationTargenArgs.build(config: config)
|
||||
#expect(args == ["-v", "-d", "2", "-s", "21", "-g", "21", "-e", "4", "-f", "0", "CAL_demo"])
|
||||
XCTAssertEqual(args, ["-v", "-d", "2", "-s", "21", "-g", "21", "-e", "4", "-f", "0", "CAL_demo"])
|
||||
}
|
||||
|
||||
@Test("CMYK baseline with ink limit and neutral emphasis")
|
||||
func cmykWithOptions() throws {
|
||||
func testCmykWithOptions() throws {
|
||||
let config = CalibrationTargenConfig(
|
||||
colourSpace: .cmyk,
|
||||
steps: 25,
|
||||
@@ -30,33 +27,26 @@ struct CalibrationTargenArgsTests {
|
||||
workingDirectory: URL(fileURLWithPath: "/tmp")
|
||||
)
|
||||
let args = try CalibrationTargenArgs.build(config: config)
|
||||
#expect(args == ["-v", "-d", "4", "-s", "25", "-g", "25", "-e", "4", "-f", "0", "-n", "25", "-l", "320", "CAL_printer"])
|
||||
XCTAssertEqual(args, ["-v", "-d", "4", "-s", "25", "-g", "25", "-e", "4", "-f", "0", "-n", "25", "-l", "320", "CAL_printer"])
|
||||
}
|
||||
|
||||
@Test("Rejects out-of-range steps")
|
||||
func rejectsBadSteps() {
|
||||
func testRejectsBadSteps() {
|
||||
let config = CalibrationTargenConfig(steps: 5, basename: "demo")
|
||||
#expect(throws: (any Error).self) {
|
||||
_ = try CalibrationTargenArgs.build(config: config)
|
||||
}
|
||||
XCTAssertThrowsError(try CalibrationTargenArgs.build(config: config))
|
||||
}
|
||||
|
||||
@Test("Rejects bad CMYK ink limit")
|
||||
func rejectsBadInkLimit() {
|
||||
func testRejectsBadInkLimit() {
|
||||
let config = CalibrationTargenConfig(
|
||||
colourSpace: .cmyk,
|
||||
inkLimit: 500,
|
||||
basename: "demo"
|
||||
)
|
||||
#expect(throws: (any Error).self) {
|
||||
_ = try CalibrationTargenArgs.build(config: config)
|
||||
}
|
||||
XCTAssertThrowsError(try CalibrationTargenArgs.build(config: config))
|
||||
}
|
||||
|
||||
@Test("Does not double-prefix an existing CAL_ basename")
|
||||
func noDoublePrefix() throws {
|
||||
func testNoDoublePrefix() throws {
|
||||
let config = CalibrationTargenConfig(basename: "CAL_test")
|
||||
let args = try CalibrationTargenArgs.build(config: config)
|
||||
#expect(args.last == "CAL_test")
|
||||
XCTAssertEqual(args.last, "CAL_test")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,72 +1,63 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import XCTest
|
||||
@testable import ICCeryCore
|
||||
|
||||
@Suite("ColprofArgs")
|
||||
struct ColprofArgsTests {
|
||||
final class ColprofArgsTests: XCTestCase {
|
||||
|
||||
@Test("Default algorithm and quality")
|
||||
func defaults() throws {
|
||||
func testDefaults() throws {
|
||||
let config = ColprofConfig(basename: "target")
|
||||
let args = try ColprofArgs.build(config: config)
|
||||
#expect(args == ["-v", "-a", "l", "-q", "m", "target"])
|
||||
XCTAssertEqual(args, ["-v", "-a", "l", "-q", "m", "target"])
|
||||
}
|
||||
|
||||
@Test("FWA bare -f when empty string")
|
||||
func fwaBareFlag() throws {
|
||||
func testFwaBareFlag() throws {
|
||||
let config = ColprofConfig(fwa: "", basename: "target")
|
||||
let args = try ColprofArgs.build(config: config)
|
||||
#expect(args == ["-v", "-a", "l", "-q", "m", "-f", "target"])
|
||||
XCTAssertEqual(args, ["-v", "-a", "l", "-q", "m", "-f", "target"])
|
||||
}
|
||||
|
||||
@Test("FWA D50 and D65 emit -f value")
|
||||
func fwaD50() throws {
|
||||
func testFwaD50() throws {
|
||||
let config = ColprofConfig(fwa: "D50", basename: "target")
|
||||
let args = try ColprofArgs.build(config: config)
|
||||
#expect(args.contains("-f"))
|
||||
#expect(args.contains("D50"))
|
||||
#expect(args.last == "target")
|
||||
XCTAssertTrue(args.contains("-f"))
|
||||
XCTAssertTrue(args.contains("D50"))
|
||||
XCTAssertEqual(args.last, "target")
|
||||
}
|
||||
|
||||
@Test("FWA none is omitted")
|
||||
func fwaNoneOmitted() throws {
|
||||
func testFwaNoneOmitted() throws {
|
||||
let config = ColprofConfig(fwa: "none", basename: "target")
|
||||
let args = try ColprofArgs.build(config: config)
|
||||
#expect(!args.contains("-f"))
|
||||
XCTAssertFalse(args.contains("-f"))
|
||||
}
|
||||
|
||||
@Test("Viewing conditions skip none")
|
||||
func viewingCondNoneSkipped() throws {
|
||||
func testViewingCondNoneSkipped() throws {
|
||||
let config = ColprofConfig(
|
||||
inputViewingCond: "none",
|
||||
outputViewingCond: "mt",
|
||||
basename: "target"
|
||||
)
|
||||
let args = try ColprofArgs.build(config: config)
|
||||
#expect(!args.contains("-c"))
|
||||
#expect(args.contains("-d"))
|
||||
#expect(args.contains("mt"))
|
||||
XCTAssertFalse(args.contains("-c"))
|
||||
XCTAssertTrue(args.contains("-d"))
|
||||
XCTAssertTrue(args.contains("mt"))
|
||||
}
|
||||
|
||||
@Test("Description falls back to basename when empty")
|
||||
func descriptionFallback() throws {
|
||||
func testDescriptionFallback() throws {
|
||||
let config = ColprofConfig(description: "", basename: "target")
|
||||
let args = try ColprofArgs.build(config: config)
|
||||
#expect(!args.contains("-D"))
|
||||
XCTAssertFalse(args.contains("-D"))
|
||||
}
|
||||
|
||||
@Test("Copyright only when non-empty")
|
||||
func copyright() throws {
|
||||
func testCopyright() throws {
|
||||
let config = ColprofConfig(copyright: "Gronod 2026", basename: "target")
|
||||
let args = try ColprofArgs.build(config: config)
|
||||
#expect(args.contains("-C"))
|
||||
#expect(args.contains("Gronod 2026"))
|
||||
XCTAssertTrue(args.contains("-C"))
|
||||
XCTAssertTrue(args.contains("Gronod 2026"))
|
||||
}
|
||||
|
||||
@Test("No -u passed")
|
||||
func noProgressJsonFlag() throws {
|
||||
func testNoProgressJsonFlag() throws {
|
||||
let config = ColprofConfig(basename: "target")
|
||||
let args = try ColprofArgs.build(config: config)
|
||||
#expect(!args.contains("-u"))
|
||||
XCTAssertFalse(args.contains("-u"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import XCTest
|
||||
@testable import ICCeryCore
|
||||
|
||||
@Suite("ColprofProgress")
|
||||
struct ColprofProgressTests {
|
||||
final class ColprofProgressTests: XCTestCase {
|
||||
|
||||
@Test("Classifies gamut mapping")
|
||||
func gamutMapping() {
|
||||
#expect(ColprofProgressClassifier.classify(line: "Gamut mapping calculation in progress") == .gamutMapping)
|
||||
func testGamutMapping() {
|
||||
XCTAssertEqual(ColprofProgressClassifier.classify(line: "Gamut mapping calculation in progress"), .gamutMapping)
|
||||
}
|
||||
|
||||
@Test("Classifies fitting or clut")
|
||||
func fitting() {
|
||||
#expect(ColprofProgressClassifier.classify(line: "Fitting cLUT grid points") == .fittingClut)
|
||||
#expect(ColprofProgressClassifier.classify(line: "clut table") == .fittingClut)
|
||||
func testFitting() {
|
||||
XCTAssertEqual(ColprofProgressClassifier.classify(line: "Fitting cLUT grid points"), .fittingClut)
|
||||
XCTAssertEqual(ColprofProgressClassifier.classify(line: "clut table"), .fittingClut)
|
||||
}
|
||||
|
||||
@Test("Classifies writing")
|
||||
func writing() {
|
||||
#expect(ColprofProgressClassifier.classify(line: "Writing ICC profile header") == .writingIcc)
|
||||
#expect(ColprofProgressClassifier.classify(line: "icc profile written") == .writingIcc)
|
||||
func testWriting() {
|
||||
XCTAssertEqual(ColprofProgressClassifier.classify(line: "Writing ICC profile header"), .writingIcc)
|
||||
XCTAssertEqual(ColprofProgressClassifier.classify(line: "icc profile written"), .writingIcc)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Testing
|
||||
import XCTest
|
||||
import Foundation
|
||||
@testable import ICCeryCore
|
||||
@testable import ICCery
|
||||
@@ -6,48 +6,42 @@ import AppKit
|
||||
import ApplicationServices
|
||||
|
||||
/// Issue 14 — PMPrintSettingsToOptions capture filter (docs/11 layer ⑥).
|
||||
@Suite("CupsOptionsFilter")
|
||||
struct CupsOptionsFilterTests {
|
||||
final class CupsOptionsFilterTests: XCTestCase {
|
||||
|
||||
@Test("Drops com.apple.*, collate, copies, job-sheets, AP_* keys")
|
||||
func dropsReserved() {
|
||||
func testDropsReserved() {
|
||||
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"
|
||||
#expect(CupsOptionsFilter.filter(raw) == "MediaType=PhotographicGlossy")
|
||||
XCTAssertEqual(CupsOptionsFilter.filter(raw), "MediaType=PhotographicGlossy")
|
||||
}
|
||||
|
||||
@Test("Keeps relevant driver keys, order preserved")
|
||||
func keepsRelevant() {
|
||||
func testKeepsRelevant() {
|
||||
let raw = "InputSlot=Rear PageSize=A4 CNIJIntent2=4 "
|
||||
+ "Resolution=600x600dpi Duplex=None"
|
||||
#expect(CupsOptionsFilter.filter(raw) == raw)
|
||||
XCTAssertEqual(CupsOptionsFilter.filter(raw), raw)
|
||||
}
|
||||
|
||||
@Test("Permissive: unknown non-com.* keys survive")
|
||||
func keepsUnknown() {
|
||||
func testKeepsUnknown() {
|
||||
let raw = "VendorFooBar=baz MediaType=Plain"
|
||||
#expect(CupsOptionsFilter.filter(raw) == raw)
|
||||
XCTAssertEqual(CupsOptionsFilter.filter(raw), raw)
|
||||
}
|
||||
|
||||
@Test("Drops empty keys and values")
|
||||
func dropsEmpty() {
|
||||
func testDropsEmpty() {
|
||||
let raw = "=noval MediaType= InputSlot=Rear"
|
||||
// "MediaType=" has an empty value → dropped; "=noval" empty key.
|
||||
#expect(CupsOptionsFilter.filter(raw) == "InputSlot=Rear")
|
||||
XCTAssertEqual(CupsOptionsFilter.filter(raw), "InputSlot=Rear")
|
||||
}
|
||||
|
||||
@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)
|
||||
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"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,9 +49,8 @@ struct CupsOptionsFilterTests {
|
||||
/// `@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
|
||||
struct ColorSyncSuppressorTests {
|
||||
final class ColorSyncSuppressorTests: XCTestCase {
|
||||
|
||||
/// Fake PMPrintSession — the injected resolver never dereferences it.
|
||||
private var fakeSession: PMPrintSession {
|
||||
@@ -78,10 +71,14 @@ struct ColorSyncSuppressorTests {
|
||||
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
|
||||
Self.recorded.append((Self.currentSymbol, modeArg as String))
|
||||
if let ok = Self.succeeding,
|
||||
Self.currentSymbol == ok.0, (modeArg as String) == ok.1 {
|
||||
ColorSyncSuppressorTests.recorded.append(
|
||||
(ColorSyncSuppressorTests.currentSymbol, modeArg as String))
|
||||
if let ok = ColorSyncSuppressorTests.succeeding,
|
||||
ColorSyncSuppressorTests.currentSymbol == ok.0,
|
||||
(modeArg as String) == ok.1 {
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
@@ -90,55 +87,50 @@ struct ColorSyncSuppressorTests {
|
||||
return s
|
||||
}
|
||||
|
||||
@Test("Attempt order: Lock → Mode → NoLock, AP_ prefix first")
|
||||
func attemptOrder() {
|
||||
func testAttemptOrder() {
|
||||
Self.recorded = []
|
||||
Self.succeeding = nil
|
||||
Self.missing = ["PMSessionSetColorMatchingModeLock"]
|
||||
let s = makeSuppressor()
|
||||
#expect(s.applySPIMode(to: fakeSession) == false)
|
||||
XCTAssertEqual(s.applySPIMode(to: fakeSession), false)
|
||||
// Lock is unresolvable → skipped; the rest plays out in order.
|
||||
#expect(Self.recorded.map { "\($0.0)|\($0.1)" }
|
||||
== ColorMatchingAttempts.attempts
|
||||
XCTAssertEqual(Self.recorded.map { "\($0.0)|\($0.1)" }, ColorMatchingAttempts.attempts
|
||||
.filter { $0.symbol != "PMSessionSetColorMatchingModeLock" }
|
||||
.map { "\($0.symbol)|\($0.mode)" })
|
||||
}
|
||||
|
||||
@Test("First zero wins — later symbols/modes not called")
|
||||
func firstZeroWins() {
|
||||
func testFirstZeroWins() {
|
||||
Self.recorded = []
|
||||
Self.succeeding = ("PMSessionSetColorMatchingModeLock",
|
||||
"AP_ApplicationColorMatching")
|
||||
Self.missing = []
|
||||
let s = makeSuppressor()
|
||||
#expect(s.applySPIMode(to: fakeSession))
|
||||
#expect(Self.recorded.map { "\($0.0)|\($0.1)" } == [
|
||||
XCTAssertTrue(s.applySPIMode(to: fakeSession))
|
||||
XCTAssertEqual(Self.recorded.map { "\($0.0)|\($0.1)" }, [
|
||||
"PMSessionSetColorMatchingModeLock|AP_ApplicationColorMatching",
|
||||
])
|
||||
}
|
||||
|
||||
@Test("Mode fallback: AP_ rejected → ApplicationColorMatching tried")
|
||||
func modeFallback() {
|
||||
func testModeFallback() {
|
||||
Self.recorded = []
|
||||
Self.succeeding = ("PMSessionSetColorMatchingModeLock",
|
||||
"ApplicationColorMatching")
|
||||
Self.missing = []
|
||||
let s = makeSuppressor()
|
||||
#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)
|
||||
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)
|
||||
}
|
||||
|
||||
@Test("All symbols missing → false, no calls")
|
||||
func allMissing() {
|
||||
func testAllMissing() {
|
||||
Self.recorded = []
|
||||
Self.succeeding = nil
|
||||
Self.missing = Set(ColorMatchingAttempts.symbols)
|
||||
let s = makeSuppressor()
|
||||
#expect(s.applySPIMode(to: fakeSession) == false)
|
||||
#expect(Self.recorded.isEmpty)
|
||||
XCTAssertEqual(s.applySPIMode(to: fakeSession), false)
|
||||
XCTAssertTrue(Self.recorded.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import Testing
|
||||
import XCTest
|
||||
import Foundation
|
||||
@testable import ICCeryCore
|
||||
|
||||
/// Issue 12 — CUPS enumeration parsers on recorded fixtures
|
||||
/// (docs/10–11). No live `lpstat`/`lpoptions` is spawned here.
|
||||
@Suite("CupsParsers")
|
||||
struct CupsParsersTests {
|
||||
final class CupsParsersTests: XCTestCase {
|
||||
|
||||
// Recorded on an Epson XP-55 + Canon Pro9500 host.
|
||||
private let lpstatE = """
|
||||
@@ -34,112 +33,102 @@ struct CupsParsersTests {
|
||||
cupsPrintQuality/cupsPrintQuality: Draft *Normal High
|
||||
"""
|
||||
|
||||
@Test("lpstat -e: one destination per line; empty = success")
|
||||
func destinations() {
|
||||
#expect(CupsParsers.lpstatDestinations(lpstatE) == [
|
||||
func testDestinations() {
|
||||
XCTAssertEqual(CupsParsers.lpstatDestinations(lpstatE), [
|
||||
"Canon_Pro9500_II_series_XPS",
|
||||
"Epson_XP_55_LPD",
|
||||
"EPSON_XP_55_Series",
|
||||
])
|
||||
#expect(CupsParsers.lpstatDestinations("") == [])
|
||||
XCTAssertEqual(CupsParsers.lpstatDestinations(""), [])
|
||||
}
|
||||
|
||||
@Test("lpstat -p: idle / now-printing / disabled statuses")
|
||||
func statuses() {
|
||||
func testStatuses() {
|
||||
let s = CupsParsers.lpstatStatuses(lpstatP)
|
||||
#expect(s["Canon_Pro9500_II_series_XPS"] == .idle)
|
||||
#expect(s["Epson_XP_55_LPD"] == .printing)
|
||||
#expect(s["EPSON_XP_55_Series"] == .stopped)
|
||||
XCTAssertEqual(s["Canon_Pro9500_II_series_XPS"], .idle)
|
||||
XCTAssertEqual(s["Epson_XP_55_LPD"], .printing)
|
||||
XCTAssertEqual(s["EPSON_XP_55_Series"], .stopped)
|
||||
}
|
||||
|
||||
@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 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("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 testDisplayName() {
|
||||
XCTAssertEqual(CupsParsers.lpoptionsDisplayName(lpoptionsP), "EPSON XP-55 Series")
|
||||
XCTAssertNil(CupsParsers.lpoptionsDisplayName("printer-type=42\n"))
|
||||
}
|
||||
|
||||
@Test("lpoptions -l: key/label split, * marks the default")
|
||||
func optionListings() {
|
||||
func testOptionListings() {
|
||||
let listings = CupsParsers.lpoptionsList(lpoptionsL)
|
||||
#expect(listings.count == 6)
|
||||
XCTAssertEqual(listings.count, 6)
|
||||
|
||||
let page = listings[0]
|
||||
#expect(page.key == "PageSize")
|
||||
#expect(page.label == "Media Size")
|
||||
#expect(page.defaultChoice == "A4")
|
||||
#expect(page.choices.contains("Custom.WIDTHxHEIGHT"))
|
||||
#expect(!page.choices.contains("*A4"))
|
||||
XCTAssertEqual(page.key, "PageSize")
|
||||
XCTAssertEqual(page.label, "Media Size")
|
||||
XCTAssertEqual(page.defaultChoice, "A4")
|
||||
XCTAssertTrue(page.choices.contains("Custom.WIDTHxHEIGHT"))
|
||||
XCTAssertFalse(page.choices.contains("*A4"))
|
||||
|
||||
let slot = listings[1]
|
||||
#expect(slot.key == "InputSlot")
|
||||
#expect(slot.choices == ["Auto", "Main", "Photo", "Rear"])
|
||||
#expect(slot.defaultChoice == "Main")
|
||||
XCTAssertEqual(slot.key, "InputSlot")
|
||||
XCTAssertEqual(slot.choices, ["Auto", "Main", "Photo", "Rear"])
|
||||
XCTAssertEqual(slot.defaultChoice, "Main")
|
||||
}
|
||||
|
||||
@Test("capabilities: trays/sizes index 1-based, media uses detected key")
|
||||
func capabilities() {
|
||||
func testCapabilities() {
|
||||
let service = CupsService()
|
||||
let listings = CupsParsers.lpoptionsList(lpoptionsL)
|
||||
let caps = service.capabilities(from: listings, ppd: nil)
|
||||
|
||||
#expect(caps.trays == [
|
||||
XCTAssertEqual(caps.trays, [
|
||||
PrinterTray(id: 1, name: "Auto"),
|
||||
PrinterTray(id: 2, name: "Main"),
|
||||
PrinterTray(id: 3, name: "Photo"),
|
||||
PrinterTray(id: 4, name: "Rear"),
|
||||
])
|
||||
#expect(caps.paperSizes.first == PrinterPaperSize(id: 1, name: "3.5x5"))
|
||||
#expect(caps.paperSizes.count == 10)
|
||||
#expect(caps.mediaTypes.map(\.id) == [
|
||||
XCTAssertEqual(caps.paperSizes.first, PrinterPaperSize(id: 1, name: "3.5x5"))
|
||||
XCTAssertEqual(caps.paperSizes.count, 10)
|
||||
XCTAssertEqual(caps.mediaTypes.map(\.id), [
|
||||
"Stationery", "PhotographicHighGloss", "Photographic",
|
||||
"PhotographicMatte", "Envelope",
|
||||
])
|
||||
#expect(caps.supportsOrientation)
|
||||
XCTAssertTrue(caps.supportsOrientation)
|
||||
}
|
||||
|
||||
@Test("PPD enrichment maps id → human label")
|
||||
func ppdLabels() {
|
||||
func testPpdLabels() {
|
||||
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")
|
||||
#expect(labels["42"] == "Photo Paper Plus Semi-gloss")
|
||||
#expect(labels["0"] == "Plain Paper")
|
||||
#expect(labels["13"] == "Envelope")
|
||||
XCTAssertEqual(labels["42"], "Photo Paper Plus Semi-gloss")
|
||||
XCTAssertEqual(labels["0"], "Plain Paper")
|
||||
XCTAssertEqual(labels["13"], "Envelope")
|
||||
}
|
||||
|
||||
@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 testMediaTypeKey() {
|
||||
XCTAssertEqual(CupsParsers.detectMediaTypeKey(
|
||||
optionKeys: ["MediaType", "CNIJMediaType"]), "CNIJMediaType")
|
||||
XCTAssertEqual(CupsParsers.detectMediaTypeKey(
|
||||
optionKeys: ["PageSize", "MediaType"]), "MediaType")
|
||||
XCTAssertNil(CupsParsers.detectMediaTypeKey(optionKeys: ["PageSize"]))
|
||||
}
|
||||
|
||||
@Test("Driver bypass: Canon Intent2 > Intent; Epson CCor > CMat")
|
||||
func driverBypass() {
|
||||
func testDriverBypass() {
|
||||
func pair(_ keys: Set<String>) -> String? {
|
||||
CupsParsers.detectDriverColorBypass(optionKeys: keys)
|
||||
.map { "\($0.key)=\($0.value)" }
|
||||
}
|
||||
#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)
|
||||
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"]))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,65 +1,57 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import XCTest
|
||||
@testable import ICCeryCore
|
||||
|
||||
@Suite("DriftAlert")
|
||||
struct DriftAlertTests {
|
||||
final class DriftAlertTests: XCTestCase {
|
||||
|
||||
@Test("No alert with fewer than two poor results")
|
||||
func notEnough() {
|
||||
func testNotEnough() {
|
||||
let records = [
|
||||
record(avg: 4.0, at: 1000)
|
||||
]
|
||||
#expect(DriftAlert.compute(from: records) == nil)
|
||||
XCTAssertNil(DriftAlert.compute(from: records))
|
||||
}
|
||||
|
||||
@Test("Alert on two poor results one hour apart")
|
||||
func oneHourApart() {
|
||||
func testOneHourApart() {
|
||||
let records = [
|
||||
record(avg: 4.0, at: 1000),
|
||||
record(avg: 5.0, at: 4600)
|
||||
]
|
||||
#expect(DriftAlert.compute(from: records) != nil)
|
||||
XCTAssertNotNil(DriftAlert.compute(from: records))
|
||||
}
|
||||
|
||||
@Test("No alert if same day and under one hour")
|
||||
func sameDayUnderHour() {
|
||||
func testSameDayUnderHour() {
|
||||
let records = [
|
||||
record(avg: 4.0, at: 1000),
|
||||
record(avg: 5.0, at: 2000)
|
||||
]
|
||||
#expect(DriftAlert.compute(from: records) == nil)
|
||||
XCTAssertNil(DriftAlert.compute(from: records))
|
||||
}
|
||||
|
||||
@Test("Alert on distinct days")
|
||||
func distinctDays() {
|
||||
func testDistinctDays() {
|
||||
let day1 = record(avg: 4.0, at: 0)
|
||||
let day2 = record(avg: 5.0, at: 86400 + 1000)
|
||||
#expect(DriftAlert.compute(from: [day1, day2]) != nil)
|
||||
XCTAssertNotNil(DriftAlert.compute(from: [day1, day2]))
|
||||
}
|
||||
|
||||
@Test("Non-poor records do not trigger")
|
||||
func nonPoor() {
|
||||
func testNonPoor() {
|
||||
let records = [
|
||||
record(avg: 1.0, at: 0),
|
||||
record(avg: 1.5, at: 86400)
|
||||
]
|
||||
#expect(DriftAlert.compute(from: records) == nil)
|
||||
XCTAssertNil(DriftAlert.compute(from: records))
|
||||
}
|
||||
|
||||
@Test("Non-poor records break the consecutive poor run")
|
||||
func nonPoorBreaksRun() {
|
||||
func testNonPoorBreaksRun() {
|
||||
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
|
||||
]
|
||||
#expect(DriftAlert.compute(from: records) == nil)
|
||||
XCTAssertNil(DriftAlert.compute(from: records))
|
||||
}
|
||||
|
||||
@Test("Only the final consecutive poor run is considered")
|
||||
func onlySuffixRun() {
|
||||
func testOnlySuffixRun() {
|
||||
let records = [
|
||||
record(avg: 4.0, at: 0), // poor
|
||||
record(avg: 4.5, at: 18000), // poor, > 1h from first
|
||||
@@ -67,26 +59,24 @@ struct DriftAlertTests {
|
||||
record(avg: 4.0, at: 25000), // poor
|
||||
record(avg: 4.5, at: 26000) // poor, < 1h and same day
|
||||
]
|
||||
#expect(DriftAlert.compute(from: records) == nil)
|
||||
XCTAssertNil(DriftAlert.compute(from: records))
|
||||
}
|
||||
|
||||
@Test("Final consecutive poor run alerts when far apart")
|
||||
func suffixRunAlerts() {
|
||||
func testSuffixRunAlerts() {
|
||||
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
|
||||
]
|
||||
#expect(DriftAlert.compute(from: records) != nil)
|
||||
XCTAssertNotNil(DriftAlert.compute(from: records))
|
||||
}
|
||||
|
||||
@Test("A single final poor record after good records does not alert")
|
||||
func singleFinalPoor() {
|
||||
func testSingleFinalPoor() {
|
||||
let records = [
|
||||
record(avg: 1.0, at: 0),
|
||||
record(avg: 4.0, at: 86400)
|
||||
]
|
||||
#expect(DriftAlert.compute(from: records) == nil)
|
||||
XCTAssertNil(DriftAlert.compute(from: records))
|
||||
}
|
||||
|
||||
private func record(avg: Double, at offset: TimeInterval) -> VerificationRecord {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Testing
|
||||
import XCTest
|
||||
import Foundation
|
||||
@testable import ICCeryCore
|
||||
|
||||
@@ -13,91 +13,88 @@ private func touch(_ url: URL, _ contents: String = "x") throws {
|
||||
try contents.write(to: url, atomically: true, encoding: .utf8)
|
||||
}
|
||||
|
||||
@Suite("PathSecurity")
|
||||
struct PathSecurityTests {
|
||||
@Test func rejectsTraversalAndSeparators() {
|
||||
final class PathSecurityTests: XCTestCase {
|
||||
func testRejectsTraversalAndSeparators() {
|
||||
for bad in ["a/b", "a\\b", "..", "a/../b", "", "..x"] {
|
||||
#expect(!PathSecurity.isValidBasename(bad))
|
||||
#expect(throws: PathSecurity.Error.self) {
|
||||
try PathSecurity.sanitizeBasename(bad)
|
||||
XCTAssertFalse(PathSecurity.isValidBasename(bad))
|
||||
XCTAssertThrowsError(try PathSecurity.sanitizeBasename(bad)) { error in
|
||||
XCTAssertTrue(error is PathSecurity.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test func acceptsNormalNames() {
|
||||
func testAcceptsNormalNames() {
|
||||
for good in ["target", "My Target 01", "écheneau-ümläut", "a.b"] {
|
||||
#expect(PathSecurity.isValidBasename(good))
|
||||
XCTAssertTrue(PathSecurity.isValidBasename(good))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func resolveSafeCwdPrefersExplicit() throws {
|
||||
func testResolveSafeCwdPrefersExplicit() throws {
|
||||
let dir = try tempDir()
|
||||
#expect(PathSecurity.resolveSafeCwd(dir) == dir)
|
||||
XCTAssertEqual(PathSecurity.resolveSafeCwd(dir), dir)
|
||||
}
|
||||
|
||||
@Test func resolveSafeCwdNeverReturnsNil() {
|
||||
func testResolveSafeCwdNeverReturnsNil() {
|
||||
let missing = URL(fileURLWithPath: "/nonexistent-\(UUID().uuidString)")
|
||||
let resolved = PathSecurity.resolveSafeCwd(missing)
|
||||
#expect(FileManager.default.fileExists(atPath: resolved.path))
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: resolved.path))
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("AtomicFileWriter")
|
||||
struct AtomicFileWriterTests {
|
||||
@Test func writesAndLeavesNoTmp() throws {
|
||||
final class AtomicFileWriterTests: XCTestCase {
|
||||
func testWritesAndLeavesNoTmp() throws {
|
||||
let dir = try tempDir()
|
||||
let url = dir.appendingPathComponent("state.json")
|
||||
try AtomicFileWriter.write(Data("{\"a\":1}".utf8), to: url)
|
||||
#expect(try String(contentsOf: url, encoding: .utf8) == "{\"a\":1}")
|
||||
#expect(!FileManager.default.fileExists(atPath: url.appendingPathExtension("tmp").path))
|
||||
XCTAssertEqual(try String(contentsOf: url, encoding: .utf8), "{\"a\":1}")
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: url.appendingPathExtension("tmp").path))
|
||||
}
|
||||
|
||||
@Test func overwritesExistingAtomically() throws {
|
||||
func testOverwritesExistingAtomically() throws {
|
||||
let dir = try tempDir()
|
||||
let url = dir.appendingPathComponent("f.txt")
|
||||
try AtomicFileWriter.write("one", to: url)
|
||||
try AtomicFileWriter.write("two-longer", to: url)
|
||||
#expect(try String(contentsOf: url, encoding: .utf8) == "two-longer")
|
||||
XCTAssertEqual(try String(contentsOf: url, encoding: .utf8), "two-longer")
|
||||
}
|
||||
|
||||
@Test func createsParentDirs() throws {
|
||||
func testCreatesParentDirs() throws {
|
||||
let dir = try tempDir()
|
||||
let url = dir.appendingPathComponent("a/b/c/deep.json")
|
||||
try AtomicFileWriter.write("{}", to: url)
|
||||
#expect(FileManager.default.fileExists(atPath: url.path))
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("ArtefactProbe")
|
||||
struct ArtefactProbeTests {
|
||||
@Test func verifyProgression() throws {
|
||||
final class ArtefactProbeTests: XCTestCase {
|
||||
func testVerifyProgression() throws {
|
||||
let dir = try tempDir()
|
||||
var v = ArtefactProbe.verify(basename: "t", cwd: dir)
|
||||
#expect(v == StageArtefacts())
|
||||
XCTAssertEqual(v, StageArtefacts())
|
||||
|
||||
try touch(dir.appendingPathComponent("t.ti1"))
|
||||
v = ArtefactProbe.verify(basename: "t", cwd: dir)
|
||||
#expect(v.stage1Complete && !v.stage2Complete && !v.stage3Complete)
|
||||
XCTAssertTrue(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)
|
||||
#expect(v.stage2Complete && v.stage3Complete && !v.stage4Complete)
|
||||
XCTAssertTrue(v.stage2Complete && v.stage3Complete && !v.stage4Complete)
|
||||
|
||||
try touch(dir.appendingPathComponent("t.icc"))
|
||||
v = ArtefactProbe.verify(basename: "t", cwd: dir)
|
||||
#expect(v.stage4Complete && v.profilePath?.pathExtension == "icc")
|
||||
XCTAssertTrue(v.stage4Complete && v.profilePath?.pathExtension == "icc")
|
||||
}
|
||||
|
||||
@Test func icmWinsOverIcc() throws {
|
||||
func testIcmWinsOverIcc() 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)
|
||||
#expect(profile?.pathExtension == "icm")
|
||||
XCTAssertEqual(profile?.pathExtension, "icm")
|
||||
}
|
||||
|
||||
@Test func enumeratesPassesPagesAndCAL() throws {
|
||||
func testEnumeratesPassesPagesAndCAL() throws {
|
||||
let dir = try tempDir()
|
||||
for name in [
|
||||
"t.ti1", "t.ti2", "t.tif", "t.2.tif", "t_03.tif",
|
||||
@@ -115,15 +112,15 @@ struct ArtefactProbeTests {
|
||||
"t.ti3", "t_pass1.ti3", "t_pass2.ti3",
|
||||
"t.icc", "t.gam", "CAL_t.ti1", "CAL_t.cal",
|
||||
] {
|
||||
#expect(names.contains(expected), "missing \(expected)")
|
||||
XCTAssertTrue(names.contains(expected), "missing \(expected)")
|
||||
}
|
||||
#expect(!names.contains("other.ti1"))
|
||||
#expect(!names.contains("t.txt"))
|
||||
#expect(!names.contains("CAL_other.ti1"))
|
||||
XCTAssertFalse(names.contains("other.ti1"))
|
||||
XCTAssertFalse(names.contains("t.txt"))
|
||||
XCTAssertFalse(names.contains("CAL_other.ti1"))
|
||||
}
|
||||
|
||||
@Test func emptyDirReturnsEmpty() throws {
|
||||
func testEmptyDirReturnsEmpty() throws {
|
||||
let dir = try tempDir()
|
||||
#expect(ArtefactProbe.existingArtefacts(basename: "x", cwd: dir).isEmpty)
|
||||
XCTAssertTrue(ArtefactProbe.existingArtefacts(basename: "x", cwd: dir).isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import Testing
|
||||
import XCTest
|
||||
import SceneKit
|
||||
import ICCeryCore
|
||||
@testable import ICCery
|
||||
|
||||
/// ``GamutSceneGeometryBuilder`` edge-case tests.
|
||||
@Suite("Gamut scene geometry builder")
|
||||
@MainActor
|
||||
struct GamutGeometryBuilderTests {
|
||||
final class GamutGeometryBuilderTests: XCTestCase {
|
||||
|
||||
@Test("Drops out-of-bounds faces from the element without crashing")
|
||||
func dropsOutOfBoundsFaces() {
|
||||
func testDropsOutOfBoundsFaces() {
|
||||
let white = GamutVertex(
|
||||
lab: LabColor(l: 100, a: 0, b: 0),
|
||||
rgb: DisplayRGB(r: 1, g: 1, b: 1)
|
||||
@@ -28,6 +26,6 @@ struct GamutGeometryBuilderTests {
|
||||
|
||||
let (_, element) = GamutSceneGeometryBuilder.geometry(for: mesh)
|
||||
|
||||
#expect(element.primitiveCount == 1, "Only the in-bounds face should be in the index buffer")
|
||||
XCTAssertEqual(element.primitiveCount, 1, "Only the in-bounds face should be in the index buffer")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import XCTest
|
||||
@testable import ICCeryCore
|
||||
|
||||
/// ``GamutMeshParser`` acceptance + edge-case tests.
|
||||
@Suite("Gamut mesh parser")
|
||||
struct GamutMeshParserTests {
|
||||
final class GamutMeshParserTests: XCTestCase {
|
||||
|
||||
/// Returns the bundled real `sRGB.gam` in `Resources/Argyll/reference_gamuts`.
|
||||
private var bundledSRGBGamURL: URL {
|
||||
@@ -13,16 +12,14 @@ struct GamutMeshParserTests {
|
||||
return resource.appendingPathComponent("Argyll/reference_gamuts/sRGB.gam")
|
||||
}
|
||||
|
||||
@Test("Parses bundled sRGB.gam")
|
||||
func parsesBundledSRGB() throws {
|
||||
func testParsesBundledSRGB() throws {
|
||||
let mesh = try GamutMeshParser.parse(url: bundledSRGBGamURL)
|
||||
|
||||
#expect(mesh.vertices.count == 448, "sRGB.gam has 448 vertices")
|
||||
#expect(mesh.faces.count == 892, "sRGB.gam has 892 faces")
|
||||
XCTAssertEqual(mesh.vertices.count, 448, "sRGB.gam has 448 vertices")
|
||||
XCTAssertEqual(mesh.faces.count, 892, "sRGB.gam has 892 faces")
|
||||
}
|
||||
|
||||
@Test("Discards VERTEX_NO and uses push-order indices")
|
||||
func discardsVertexNo() throws {
|
||||
func testDiscardsVertexNo() throws {
|
||||
let text = """
|
||||
GAMUT
|
||||
NUMBER_OF_FIELDS 4
|
||||
@@ -49,14 +46,13 @@ struct GamutMeshParserTests {
|
||||
|
||||
let mesh = try GamutMeshParser.parse(text: text)
|
||||
|
||||
#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))
|
||||
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))
|
||||
}
|
||||
|
||||
@Test("Ignores comments and blank lines")
|
||||
func ignoresComments() throws {
|
||||
func testIgnoresComments() throws {
|
||||
let text = """
|
||||
# Header comment
|
||||
NUMBER_OF_FIELDS 4
|
||||
@@ -81,12 +77,11 @@ struct GamutMeshParserTests {
|
||||
"""
|
||||
|
||||
let mesh = try GamutMeshParser.parse(text: text)
|
||||
#expect(mesh.vertices.count == 2)
|
||||
#expect(mesh.faces.count == 1)
|
||||
XCTAssertEqual(mesh.vertices.count, 2)
|
||||
XCTAssertEqual(mesh.faces.count, 1)
|
||||
}
|
||||
|
||||
@Test("Remaps coordinates to x=a*, y=L*, z=b*")
|
||||
func remapsCoordinates() throws {
|
||||
func testRemapsCoordinates() throws {
|
||||
let text = """
|
||||
NUMBER_OF_FIELDS 4
|
||||
BEGIN_DATA_FORMAT
|
||||
@@ -99,11 +94,10 @@ struct GamutMeshParserTests {
|
||||
"""
|
||||
|
||||
let mesh = try GamutMeshParser.parse(text: text)
|
||||
#expect(mesh.vertices.first?.position == SIMD3<Float>(-20, 50, 80))
|
||||
XCTAssertEqual(mesh.vertices.first?.position, SIMD3<Float>(-20, 50, 80))
|
||||
}
|
||||
|
||||
@Test("Computes per-vertex sRGB colour")
|
||||
func computesVertexColor() throws {
|
||||
func testComputesVertexColor() throws {
|
||||
let text = """
|
||||
NUMBER_OF_FIELDS 4
|
||||
BEGIN_DATA_FORMAT
|
||||
@@ -116,14 +110,13 @@ struct GamutMeshParserTests {
|
||||
"""
|
||||
|
||||
let mesh = try GamutMeshParser.parse(text: text)
|
||||
let white = try #require(mesh.vertices.first).rgb
|
||||
#expect(white.r > 0.95)
|
||||
#expect(white.g > 0.95)
|
||||
#expect(white.b > 0.95)
|
||||
let white = try XCTUnwrap(mesh.vertices.first).rgb
|
||||
XCTAssertTrue(white.r > 0.95)
|
||||
XCTAssertTrue(white.g > 0.95)
|
||||
XCTAssertTrue(white.b > 0.95)
|
||||
}
|
||||
|
||||
@Test("Drops out-of-bounds face indices")
|
||||
func dropsOutOfBoundsFaces() throws {
|
||||
func testDropsOutOfBoundsFaces() throws {
|
||||
let text = """
|
||||
NUMBER_OF_FIELDS 4
|
||||
BEGIN_DATA_FORMAT
|
||||
@@ -146,21 +139,23 @@ struct GamutMeshParserTests {
|
||||
"""
|
||||
|
||||
let mesh = try GamutMeshParser.parse(text: text)
|
||||
#expect(mesh.faces.count == 1)
|
||||
XCTAssertEqual(mesh.faces.count, 1)
|
||||
}
|
||||
|
||||
@Test("Throws on empty file")
|
||||
func throwsOnEmptyFile() {
|
||||
#expect(throws: GamutMeshParseError.noDataBlock) {
|
||||
_ = try GamutMeshParser.parse(text: "")
|
||||
func testThrowsOnEmptyFile() {
|
||||
XCTAssertThrowsError(try GamutMeshParser.parse(text: "")) { error in
|
||||
guard case GamutMeshParseError.noDataBlock = error else {
|
||||
return XCTFail("Expected GamutMeshParseError.noDataBlock, got \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Throws when file is missing")
|
||||
func throwsWhenMissing() {
|
||||
func testThrowsWhenMissing() {
|
||||
let url = URL(fileURLWithPath: "/nonexistent/path/to/mesh.gam")
|
||||
#expect(throws: GamutMeshParseError.missingFile) {
|
||||
_ = try GamutMeshParser.parse(url: url)
|
||||
XCTAssertThrowsError(try GamutMeshParser.parse(url: url)) { error in
|
||||
guard case GamutMeshParseError.missingFile = error else {
|
||||
return XCTFail("Expected GamutMeshParseError.missingFile, got \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import XCTest
|
||||
@testable import ICCeryCore
|
||||
|
||||
@Suite("IccgamutArgs")
|
||||
struct IccgamutArgsTests {
|
||||
final class IccgamutArgsTests: XCTestCase {
|
||||
|
||||
@Test("Density is 10 and not a directory")
|
||||
func densityNotDirectory() throws {
|
||||
func testDensityNotDirectory() throws {
|
||||
let config = IccgamutConfig(
|
||||
profileURL: URL(fileURLWithPath: "/tmp/MyProfile.icc")
|
||||
)
|
||||
let args = try IccgamutArgs.build(config: config)
|
||||
#expect(args == ["-v", "-d", "10", "/tmp/MyProfile.icc"])
|
||||
XCTAssertEqual(args, ["-v", "-d", "10", "/tmp/MyProfile.icc"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import ICCeryCore
|
||||
|
||||
final class JSONFileStoreTests: XCTestCase {
|
||||
private func tempURL() -> URL {
|
||||
FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("json-store-\(UUID().uuidString).json")
|
||||
}
|
||||
|
||||
func testMissingFileDefaults() throws {
|
||||
let store = JSONFileStore<AppSettings>(
|
||||
fileURL: tempURL(),
|
||||
corrupt: .throwCorrupt,
|
||||
defaultValue: { .default }
|
||||
)
|
||||
XCTAssertEqual(try store.load(), .default)
|
||||
}
|
||||
|
||||
func testCorruptDefaults() throws {
|
||||
let url = tempURL()
|
||||
try "{ not json".write(to: url, atomically: true, encoding: .utf8)
|
||||
let store = JSONFileStore<AppSettings>(
|
||||
fileURL: url,
|
||||
corrupt: .replaceWithDefault,
|
||||
defaultValue: { .default }
|
||||
)
|
||||
XCTAssertEqual(try store.load(), .default)
|
||||
let kept = try String(contentsOf: url, encoding: .utf8)
|
||||
XCTAssertEqual(kept, "{ not json")
|
||||
}
|
||||
|
||||
func testCorruptThrows() throws {
|
||||
let url = tempURL()
|
||||
try "not json".write(to: url, atomically: true, encoding: .utf8)
|
||||
let store = JSONFileStore<[Int]>(
|
||||
fileURL: url,
|
||||
corrupt: .throwCorrupt,
|
||||
defaultValue: { [] }
|
||||
)
|
||||
XCTAssertThrowsError(try store.load()) { error in XCTAssertTrue(error is DecodingError) }
|
||||
let kept = try String(contentsOf: url, encoding: .utf8)
|
||||
XCTAssertEqual(kept, "not json")
|
||||
}
|
||||
|
||||
func testPrettySorted() throws {
|
||||
let url = tempURL()
|
||||
let store = JSONFileStore<AppSettings>(
|
||||
fileURL: url,
|
||||
corrupt: .replaceWithDefault,
|
||||
defaultValue: { .default }
|
||||
)
|
||||
try store.save(.default)
|
||||
let text = try String(contentsOf: url, encoding: .utf8)
|
||||
XCTAssertTrue(text.contains("\n"))
|
||||
XCTAssertTrue(text.contains("\"delta_e_good_max\""))
|
||||
// Lexical key sorting: ascending order of top-level keys.
|
||||
let keys = [
|
||||
"ask_before_overwrite_profile",
|
||||
"calibration_stale_days",
|
||||
"custom_presets",
|
||||
"default_install_location",
|
||||
"delta_e_good_max",
|
||||
"delta_e_warning_max",
|
||||
"enable_i1pro2_leds",
|
||||
"open_color_panel_after_install",
|
||||
]
|
||||
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)")
|
||||
return
|
||||
}
|
||||
lastIndex = range.upperBound
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
import Testing
|
||||
import XCTest
|
||||
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.
|
||||
@Suite("LpArgs")
|
||||
struct LpArgsTests {
|
||||
final class LpArgsTests: XCTestCase {
|
||||
|
||||
private let tiff = "/tmp/work/target_001.tif"
|
||||
private let queue = "EPSON_XP_55_Series"
|
||||
@@ -20,112 +19,100 @@ struct LpArgsTests {
|
||||
options: options, optionKeys: optionKeys)
|
||||
}
|
||||
|
||||
@Test("Header: -d queue -t title, both AP_* first, TIFF last")
|
||||
func header() throws {
|
||||
func testHeader() throws {
|
||||
let argv = try build()
|
||||
#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" })
|
||||
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" })
|
||||
}
|
||||
|
||||
@Test("Never emits -o raw; captured raw= is dropped")
|
||||
func neverRaw() throws {
|
||||
func testNeverRaw() throws {
|
||||
let argv = try build(options: PrintOptions(
|
||||
cupsOptions: "raw=true MediaType=Photo"))
|
||||
for (i, arg) in argv.enumerated() where arg == "-o" {
|
||||
#expect(argv[i + 1] != "raw")
|
||||
#expect(argv[i + 1] != "raw=true")
|
||||
XCTAssertNotEqual(argv[i + 1], "raw")
|
||||
XCTAssertNotEqual(argv[i + 1], "raw=true")
|
||||
}
|
||||
#expect(!argv.contains { $0.hasPrefix("raw=") })
|
||||
#expect(argv.contains("MediaType=Photo"))
|
||||
XCTAssertFalse(argv.contains { $0.hasPrefix("raw=") })
|
||||
XCTAssertTrue(argv.contains("MediaType=Photo"))
|
||||
}
|
||||
|
||||
@Test("Captured options replayed after AP_* headers")
|
||||
func capturedReplay() throws {
|
||||
func testCapturedReplay() 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")!
|
||||
#expect(rear > apFirst)
|
||||
XCTAssertTrue(rear > apFirst)
|
||||
}
|
||||
|
||||
@Test("Captured wins: media key present → derived media skipped")
|
||||
func capturedWinsMedia() throws {
|
||||
func testCapturedWinsMedia() throws {
|
||||
let argv = try build(
|
||||
options: PrintOptions(
|
||||
mediaType: "Plain",
|
||||
cupsOptions: "MediaType=Glossy"),
|
||||
optionKeys: ["MediaType"])
|
||||
#expect(argv.contains("MediaType=Glossy"))
|
||||
#expect(!argv.contains("MediaType=Plain"))
|
||||
XCTAssertTrue(argv.contains("MediaType=Glossy"))
|
||||
XCTAssertFalse(argv.contains("MediaType=Plain"))
|
||||
}
|
||||
|
||||
@Test("Media emitted via detected key when not captured")
|
||||
func mediaDerived() throws {
|
||||
func testMediaDerived() throws {
|
||||
let argv = try build(
|
||||
options: PrintOptions(mediaType: "SemiGloss"),
|
||||
optionKeys: ["CNIJMediaType", "MediaType"])
|
||||
// CNIJMediaType wins over MediaType in detection order.
|
||||
#expect(argv.contains("CNIJMediaType=SemiGloss"))
|
||||
#expect(!argv.contains("MediaType=SemiGloss"))
|
||||
XCTAssertTrue(argv.contains("CNIJMediaType=SemiGloss"))
|
||||
XCTAssertFalse(argv.contains("MediaType=SemiGloss"))
|
||||
}
|
||||
|
||||
@Test("Driver bypass emitted when absent, skipped when captured")
|
||||
func bypassRules() throws {
|
||||
func testBypassRules() throws {
|
||||
let withBypass = try build(
|
||||
optionKeys: ["EPIJ_CMat"])
|
||||
#expect(withBypass.contains("EPIJ_CMat=3"))
|
||||
XCTAssertTrue(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.
|
||||
#expect(captured.filter { $0.hasPrefix("EPIJ_CMat") }
|
||||
== ["EPIJ_CMat=1"])
|
||||
XCTAssertEqual(captured.filter { $0.hasPrefix("EPIJ_CMat") }, ["EPIJ_CMat=1"])
|
||||
}
|
||||
|
||||
@Test("Orientation: portrait=3 landscape=4; captured wins")
|
||||
func orientation() throws {
|
||||
#expect(try build(options: PrintOptions(orientation: "portrait"))
|
||||
func testOrientation() throws {
|
||||
XCTAssertTrue(try build(options: PrintOptions(orientation: "portrait"))
|
||||
.contains("orientation-requested=3"))
|
||||
#expect(try build(options: PrintOptions(orientation: "landscape"))
|
||||
XCTAssertTrue(try build(options: PrintOptions(orientation: "landscape"))
|
||||
.contains("orientation-requested=4"))
|
||||
let capturedOrients = try build(options: PrintOptions(
|
||||
orientation: "landscape",
|
||||
cupsOptions: "orientation-requested=5"))
|
||||
#expect(!capturedOrients.contains("orientation-requested=4"))
|
||||
#expect(capturedOrients.contains("orientation-requested=5"))
|
||||
XCTAssertFalse(capturedOrients.contains("orientation-requested=4"))
|
||||
XCTAssertTrue(capturedOrients.contains("orientation-requested=5"))
|
||||
}
|
||||
|
||||
@Test("PageSize emitted unless captured")
|
||||
func pageSize() throws {
|
||||
#expect(try build(options: PrintOptions(paperSize: "A4"))
|
||||
func testPageSize() throws {
|
||||
XCTAssertTrue(try build(options: PrintOptions(paperSize: "A4"))
|
||||
.contains("PageSize=A4"))
|
||||
let capturedSize = try build(options: PrintOptions(
|
||||
paperSize: "A4", cupsOptions: "PageSize=Letter"))
|
||||
#expect(!capturedSize.contains("PageSize=A4"))
|
||||
#expect(capturedSize.contains("PageSize=Letter"))
|
||||
XCTAssertFalse(capturedSize.contains("PageSize=A4"))
|
||||
XCTAssertTrue(capturedSize.contains("PageSize=Letter"))
|
||||
}
|
||||
|
||||
@Test("Sanitise rejects `;`, newline, and shell metachars")
|
||||
func sanitise() throws {
|
||||
#expect(throws: LpArgsError.self) {
|
||||
_ = try build(options: PrintOptions(
|
||||
cupsOptions: "InputSlot=Rear;rm -rf /"))
|
||||
func testSanitise() throws {
|
||||
XCTAssertThrowsError(try build(options: PrintOptions(
|
||||
cupsOptions: "InputSlot=Rear;rm -rf /"))) { 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=Rear\nMediaType=Photo"))) { error in
|
||||
XCTAssertTrue(error is LpArgsError)
|
||||
}
|
||||
#expect(throws: LpArgsError.self) {
|
||||
_ = try build(options: PrintOptions(
|
||||
cupsOptions: "InputSlot=$(whoami)"))
|
||||
XCTAssertThrowsError(try build(options: PrintOptions(
|
||||
cupsOptions: "InputSlot=$(whoami)"))) { error in
|
||||
XCTAssertTrue(error is LpArgsError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import XCTest
|
||||
@testable import ICCeryCore
|
||||
|
||||
@Suite("InstrumentParser")
|
||||
struct InstrumentParserTests {
|
||||
final class InstrumentParserTests: XCTestCase {
|
||||
|
||||
@Test("Parses pretty-printed instlist JSON")
|
||||
func json() throws {
|
||||
func testJson() throws {
|
||||
let json = """
|
||||
{
|
||||
"event": "instruments",
|
||||
@@ -18,134 +16,118 @@ struct InstrumentParserTests {
|
||||
}
|
||||
"""
|
||||
let devices = try InstrumentParser.parse(json)
|
||||
#expect(devices.count == 3)
|
||||
#expect(devices[0].port == 1)
|
||||
#expect(devices[0].name == "X-Rite i1Pro")
|
||||
#expect(devices[2].port == 3)
|
||||
XCTAssertEqual(devices.count, 3)
|
||||
XCTAssertEqual(devices[0].port, 1)
|
||||
XCTAssertEqual(devices[0].name, "X-Rite i1Pro")
|
||||
XCTAssertEqual(devices[2].port, 3)
|
||||
}
|
||||
|
||||
@Test("Falls back to regex for legacy instlist text")
|
||||
func regexFallback() throws {
|
||||
func testRegexFallback() throws {
|
||||
let text = """
|
||||
1: 'X-Rite i1Pro' on usb
|
||||
2: 'ColorMunki Smile'
|
||||
""" + "\n"
|
||||
let devices = try InstrumentParser.parse(text)
|
||||
#expect(devices.count == 2)
|
||||
#expect(devices[0].port == 1)
|
||||
#expect(devices[1].name == "ColorMunki Smile")
|
||||
XCTAssertEqual(devices.count, 2)
|
||||
XCTAssertEqual(devices[0].port, 1)
|
||||
XCTAssertEqual(devices[1].name, "ColorMunki Smile")
|
||||
}
|
||||
|
||||
@Test("Empty output returns no devices")
|
||||
func empty() throws {
|
||||
#expect(try InstrumentParser.parse("").isEmpty)
|
||||
func testEmpty() throws {
|
||||
XCTAssertTrue(try InstrumentParser.parse("").isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("ChartreadArgs")
|
||||
struct ChartreadArgsTests {
|
||||
final class ChartreadArgsTests: XCTestCase {
|
||||
|
||||
@Test("Baseline argv and port 1 omits -c")
|
||||
func baseline() throws {
|
||||
func testBaseline() throws {
|
||||
let config = ChartreadConfig(basename: "target", selectedPort: 1)
|
||||
let args = try ChartreadArgs.build(config: config)
|
||||
#expect(args == ["-v", "-u", "target"])
|
||||
XCTAssertEqual(args, ["-v", "-u", "target"])
|
||||
}
|
||||
|
||||
@Test("Port > 1 emits -c")
|
||||
func portArgument() throws {
|
||||
func testPortArgument() throws {
|
||||
let config = ChartreadConfig(basename: "target", selectedPort: 3)
|
||||
let args = try ChartreadArgs.build(config: config)
|
||||
#expect(args == ["-v", "-u", "-c", "3", "target"])
|
||||
XCTAssertEqual(args, ["-v", "-u", "-c", "3", "target"])
|
||||
}
|
||||
|
||||
@Test("LEDs emit -Y l")
|
||||
func leds() throws {
|
||||
func testLeds() throws {
|
||||
let config = ChartreadConfig(
|
||||
basename: "target",
|
||||
selectedPort: 2,
|
||||
enableLEDs: true
|
||||
)
|
||||
let args = try ChartreadArgs.build(config: config)
|
||||
#expect(args.contains("-Y"))
|
||||
#expect(args.contains("l"))
|
||||
XCTAssertTrue(args.contains("-Y"))
|
||||
XCTAssertTrue(args.contains("l"))
|
||||
}
|
||||
|
||||
@Test("Auto omits -c")
|
||||
func autoPort() throws {
|
||||
func testAutoPort() throws {
|
||||
let config = ChartreadConfig(basename: "target")
|
||||
let args = try ChartreadArgs.build(config: config)
|
||||
#expect(!args.contains("-c"))
|
||||
XCTAssertFalse(args.contains("-c"))
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("ChartreadClassifier")
|
||||
struct ChartreadClassifierTests {
|
||||
final class ChartreadClassifierTests: XCTestCase {
|
||||
|
||||
@Test("Calibration prompt")
|
||||
func calibration() {
|
||||
func testCalibration() {
|
||||
let r = ChartreadClassifier.classify(
|
||||
line: "Place instrument on calibration tile and hit [Space] to calibrate.",
|
||||
previousState: .idle
|
||||
)
|
||||
#expect(r.state == .calibrating)
|
||||
XCTAssertEqual(r.state, .calibrating)
|
||||
}
|
||||
|
||||
@Test("Strip awaiting")
|
||||
func awaitingStrip() {
|
||||
func testAwaitingStrip() {
|
||||
let r = ChartreadClassifier.classify(
|
||||
line: "Hit [Space] to read strip A",
|
||||
previousState: .calibrating
|
||||
)
|
||||
#expect(r.state == .awaitingStrip)
|
||||
XCTAssertEqual(r.state, .awaitingStrip)
|
||||
}
|
||||
|
||||
@Test("Done prompt")
|
||||
func done() {
|
||||
func testDone() {
|
||||
let r = ChartreadClassifier.classify(
|
||||
line: "'d' if/when done",
|
||||
previousState: .awaitingStrip
|
||||
)
|
||||
#expect(r.state == .allStripsRead)
|
||||
XCTAssertEqual(r.state, .allStripsRead)
|
||||
}
|
||||
|
||||
@Test("XY place sheet")
|
||||
func placeSheet() {
|
||||
func testPlaceSheet() {
|
||||
let r = ChartreadClassifier.classify(
|
||||
line: "Please place sheet 1 of 2 on the table",
|
||||
previousState: .idle
|
||||
)
|
||||
#expect(r.state == .tablePlaceSheet)
|
||||
#expect(r.sheetNumber == 1)
|
||||
#expect(r.sheetTotal == 2)
|
||||
XCTAssertEqual(r.state, .tablePlaceSheet)
|
||||
XCTAssertEqual(r.sheetNumber, 1)
|
||||
XCTAssertEqual(r.sheetTotal, 2)
|
||||
}
|
||||
|
||||
@Test("XY locate patch")
|
||||
func locatePatch() {
|
||||
func testLocatePatch() {
|
||||
let r = ChartreadClassifier.classify(
|
||||
line: "locate patch A1 with the sight,",
|
||||
previousState: .tablePlaceSheet
|
||||
)
|
||||
#expect(r.state == .tableAlign)
|
||||
#expect(r.alignmentPatch == "A1")
|
||||
XCTAssertEqual(r.state, .tableAlign)
|
||||
XCTAssertEqual(r.alignmentPatch, "A1")
|
||||
}
|
||||
|
||||
@Test("Remove sheet notice preserves state")
|
||||
func removeNotice() {
|
||||
func testRemoveNotice() {
|
||||
let r = ChartreadClassifier.classify(
|
||||
line: "Please remove last sheet from table",
|
||||
previousState: .tablePlaceSheet
|
||||
)
|
||||
#expect(r.state == .tablePlaceSheet)
|
||||
#expect(r.isRemoveSheetNotice == true)
|
||||
XCTAssertEqual(r.state, .tablePlaceSheet)
|
||||
XCTAssertEqual(r.isRemoveSheetNotice, true)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("ChartreadRow")
|
||||
struct ChartreadRowTests {
|
||||
final class ChartreadRowTests: XCTestCase {
|
||||
|
||||
@Test("Decodes row JSON")
|
||||
func decode() throws {
|
||||
func testDecode() throws {
|
||||
let json = """
|
||||
{"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2,
|
||||
"patch_count": 1, "patches": [
|
||||
@@ -155,59 +137,93 @@ struct ChartreadRowTests {
|
||||
]}
|
||||
"""
|
||||
let row = try JSONDecoder().decode(ChartreadRow.self, from: Data(json.utf8))
|
||||
#expect(row.rowId == "A")
|
||||
#expect(row.patchCount == 1)
|
||||
#expect(row.patches[0].measured.lab?.l == 51)
|
||||
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"])
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("ColourMath")
|
||||
struct ColourMathTests {
|
||||
final class ColourMathTests: XCTestCase {
|
||||
|
||||
@Test("White XYZ to Lab")
|
||||
func whiteLab() {
|
||||
func testWhiteLab() {
|
||||
let white = XYZColor(x: 96.4212, y: 100.0, z: 82.5188)
|
||||
let lab = LabColorMath.xyzToLab(white)
|
||||
#expect(abs(lab.l - 100) < 0.5)
|
||||
#expect(abs(lab.a) < 0.5)
|
||||
#expect(abs(lab.b) < 0.5)
|
||||
XCTAssertTrue(abs(lab.l - 100) < 0.5)
|
||||
XCTAssertTrue(abs(lab.a) < 0.5)
|
||||
XCTAssertTrue(abs(lab.b) < 0.5)
|
||||
}
|
||||
|
||||
@Test("Lab to sRGB roundtrip is clamped")
|
||||
func labToSRGB() {
|
||||
func testLabToSRGB() {
|
||||
let red = LabColor(l: 55, a: 80, b: 70)
|
||||
let rgb = LabColorMath.labToSRGB(red)
|
||||
#expect(rgb.r > 0.8)
|
||||
#expect(rgb.g < 0.2)
|
||||
#expect(rgb.b < 0.2)
|
||||
XCTAssertTrue(rgb.r > 0.8)
|
||||
XCTAssertTrue(rgb.g < 0.2)
|
||||
XCTAssertTrue(rgb.b < 0.2)
|
||||
}
|
||||
|
||||
@Test("Pad white returns DisplayRGB")
|
||||
func padWhite() {
|
||||
func testPadWhite() {
|
||||
let white = LabColor(l: 95, a: 0, b: 0)
|
||||
let rgb = LabColorMath.labToSRGB(white)
|
||||
#expect(rgb.r > 0.9)
|
||||
#expect(rgb.g > 0.9)
|
||||
#expect(rgb.b > 0.9)
|
||||
XCTAssertTrue(rgb.r > 0.9)
|
||||
XCTAssertTrue(rgb.g > 0.9)
|
||||
XCTAssertTrue(rgb.b > 0.9)
|
||||
}
|
||||
|
||||
@Test("Standard CIEDE2000 vector (Sharma)")
|
||||
func ciede2000() {
|
||||
func testCiede2000() {
|
||||
let a = LabColor(l: 50, a: -1.3802, b: -84.2814)
|
||||
let b = LabColor(l: 50, a: 0.0000, b: -82.7485)
|
||||
#expect(abs(ColorDifference.deltaE00(a, b) - 1.00) < 0.001)
|
||||
XCTAssertTrue(abs(ColorDifference.deltaE00(a, b) - 1.00) < 0.001)
|
||||
}
|
||||
|
||||
@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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("MeasurementArtefacts")
|
||||
struct MeasurementArtefactTests {
|
||||
final class MeasurementArtefactTests: XCTestCase {
|
||||
|
||||
private func makeCwd() throws -> URL {
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
@@ -216,8 +232,7 @@ struct MeasurementArtefactTests {
|
||||
return url
|
||||
}
|
||||
|
||||
@Test("Discovers passes in order")
|
||||
func discovery() throws {
|
||||
func testDiscovery() throws {
|
||||
let cwd = try makeCwd()
|
||||
defer { try? FileManager.default.removeItem(at: cwd) }
|
||||
|
||||
@@ -226,11 +241,10 @@ struct MeasurementArtefactTests {
|
||||
try "C".write(to: cwd.appendingPathComponent("target_pass10.ti3"), atomically: true, encoding: .utf8)
|
||||
|
||||
let passes = MeasurementArtefacts.passSnapshots(basename: "target", cwd: cwd)
|
||||
#expect(passes.map(\.lastPathComponent) == ["target_pass1.ti3", "target_pass3.ti3", "target_pass10.ti3"])
|
||||
XCTAssertEqual(passes.map(\.lastPathComponent), ["target_pass1.ti3", "target_pass3.ti3", "target_pass10.ti3"])
|
||||
}
|
||||
|
||||
@Test("Snapshot and promote are atomic")
|
||||
func snapshotPromote() throws {
|
||||
func testSnapshotPromote() throws {
|
||||
let cwd = try makeCwd()
|
||||
defer { try? FileManager.default.removeItem(at: cwd) }
|
||||
|
||||
@@ -238,16 +252,15 @@ struct MeasurementArtefactTests {
|
||||
try "canonical".write(to: canonical, atomically: true, encoding: .utf8)
|
||||
|
||||
let pass = try MeasurementArtefacts.snapshotPass(basename: "target", cwd: cwd)
|
||||
#expect(pass.lastPathComponent == "target_pass1.ti3")
|
||||
#expect(!FileManager.default.fileExists(atPath: canonical.path))
|
||||
XCTAssertEqual(pass.lastPathComponent, "target_pass1.ti3")
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: canonical.path))
|
||||
|
||||
let promoted = try MeasurementArtefacts.promotePass(pass: pass, basename: "target", cwd: cwd)
|
||||
#expect(promoted.lastPathComponent == "target.ti3")
|
||||
#expect(FileManager.default.fileExists(atPath: promoted.path))
|
||||
XCTAssertEqual(promoted.lastPathComponent, "target.ti3")
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: promoted.path))
|
||||
}
|
||||
|
||||
@Test("Pass collisions handled")
|
||||
func collision() throws {
|
||||
func testCollision() throws {
|
||||
let cwd = try makeCwd()
|
||||
defer { try? FileManager.default.removeItem(at: cwd) }
|
||||
|
||||
@@ -257,28 +270,25 @@ struct MeasurementArtefactTests {
|
||||
|
||||
try "v2".write(to: canonical, atomically: true, encoding: .utf8)
|
||||
let pass2 = try MeasurementArtefacts.snapshotPass(basename: "target", cwd: cwd)
|
||||
#expect(pass2.lastPathComponent == "target_pass2.ti3")
|
||||
XCTAssertEqual(pass2.lastPathComponent, "target_pass2.ti3")
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("AverageArgs")
|
||||
struct AverageArgsTests {
|
||||
final class AverageArgsTests: XCTestCase {
|
||||
|
||||
@Test("Requires at least two pass files")
|
||||
func passCount() {
|
||||
func testPassCount() {
|
||||
let cwd = URL(fileURLWithPath: "/tmp")
|
||||
let config = AverageConfig(
|
||||
workingDirectory: cwd,
|
||||
basename: "target",
|
||||
passFiles: [URL(fileURLWithPath: "target_pass1.ti3")]
|
||||
)
|
||||
#expect(throws: AverageArgError.self) {
|
||||
_ = try AverageArgs.build(config: config)
|
||||
XCTAssertThrowsError(try AverageArgs.build(config: config)) { error in
|
||||
XCTAssertTrue(error is AverageArgError)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Output is last and inputs are relative")
|
||||
func ordering() throws {
|
||||
func testOrdering() throws {
|
||||
let cwd = URL(fileURLWithPath: "/tmp")
|
||||
let config = AverageConfig(
|
||||
workingDirectory: cwd,
|
||||
@@ -289,8 +299,8 @@ struct AverageArgsTests {
|
||||
]
|
||||
)
|
||||
let args = try AverageArgs.build(config: config)
|
||||
#expect(args.first == "-v")
|
||||
#expect(args.last == "target.ti3")
|
||||
#expect(args == ["-v", "target_pass1.ti3", "target_pass2.ti3", "target.ti3"])
|
||||
XCTAssertEqual(args.first, "-v")
|
||||
XCTAssertEqual(args.last, "target.ti3")
|
||||
XCTAssertEqual(args, ["-v", "target_pass1.ti3", "target_pass2.ti3", "target.ti3"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,116 +1,98 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import ICCeryCore
|
||||
|
||||
@Suite("ProfilingPreset")
|
||||
struct ProfilingPresetTests {
|
||||
final class ProfilingPresetTests: XCTestCase {
|
||||
|
||||
@Test("snake_case keys round-trip through Codable")
|
||||
func roundTrip() throws {
|
||||
func testRoundTrip() throws {
|
||||
var p = PresetCatalog.highQualityCMYK
|
||||
p.colprofInputViewingCond = "D50_2"
|
||||
let data = try JSONEncoder().encode(p)
|
||||
let decoded = try JSONDecoder().decode(ProfilingPreset.self, from: data)
|
||||
#expect(decoded == p)
|
||||
XCTAssertEqual(decoded, p)
|
||||
// Spot-check the wire format.
|
||||
let obj = try JSONSerialization.jsonObject(with: data) as! [String: Any]
|
||||
#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")
|
||||
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")
|
||||
}
|
||||
|
||||
@Test("Unknown keys ignored; missing required field fails")
|
||||
func schemaTolerance() throws {
|
||||
func testSchemaTolerance() 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)
|
||||
#expect(ok.id == "x")
|
||||
XCTAssertEqual(ok.id, "x")
|
||||
|
||||
let missing = """
|
||||
{"id":"x","name":"N","colour_space":"rgb"}
|
||||
""".data(using: .utf8)!
|
||||
#expect(throws: DecodingError.self) {
|
||||
try JSONDecoder().decode(ProfilingPreset.self, from: missing)
|
||||
}
|
||||
XCTAssertThrowsError(try JSONDecoder().decode(ProfilingPreset.self, from: missing)) { error in XCTAssertTrue(error is DecodingError) }
|
||||
}
|
||||
|
||||
@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()
|
||||
}
|
||||
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) }
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("PresetCatalog")
|
||||
struct PresetCatalogTests {
|
||||
final class PresetCatalogTests: XCTestCase {
|
||||
|
||||
@Test("Four built-ins with the documented values")
|
||||
func builtIns() {
|
||||
#expect(PresetCatalog.builtIns.count == 4)
|
||||
func testBuiltIns() {
|
||||
XCTAssertEqual(PresetCatalog.builtIns.count, 4)
|
||||
let byID = Dictionary(uniqueKeysWithValues: PresetCatalog.builtIns.map { ($0.id, $0) })
|
||||
|
||||
let std = byID["preset-std-rgb"]!
|
||||
#expect(std.colourSpace == "rgb" && std.patchCount == 800
|
||||
XCTAssertTrue(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"]!
|
||||
#expect(hq.colourSpace == "cmyk" && hq.patchCount == 1500
|
||||
XCTAssertTrue(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"]!
|
||||
#expect(draft.colourSpace == "rgb" && draft.patchCount == 400
|
||||
XCTAssertTrue(draft.colourSpace == "rgb" && draft.patchCount == 400
|
||||
&& draft.pageSize == "A4" && draft.bitDepth == 8
|
||||
&& draft.dpi == 150 && draft.colprofQuality == "l")
|
||||
|
||||
let ultra = byID["preset-ultra-rgb"]!
|
||||
#expect(ultra.colourSpace == "rgb" && ultra.patchCount == 2500
|
||||
XCTAssertTrue(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 {
|
||||
#expect(p.instrument == "i1")
|
||||
#expect(p.colprofFwa == "D50")
|
||||
#expect(p.randomSeed == 1)
|
||||
#expect(p.noRandomize == false)
|
||||
#expect(p.colprofAlgorithm == "l")
|
||||
XCTAssertEqual(p.instrument, "i1")
|
||||
XCTAssertEqual(p.colprofFwa, "D50")
|
||||
XCTAssertEqual(p.randomSeed, 1)
|
||||
XCTAssertEqual(p.noRandomize, false)
|
||||
XCTAssertEqual(p.colprofAlgorithm, "l")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Custom presets overlay by id; built-ins are not deletable")
|
||||
func overlay() {
|
||||
func testOverlay() {
|
||||
let custom = ProfilingPreset(
|
||||
id: "preset-std-rgb", name: "Shadowed", patchCount: 42)
|
||||
let all = PresetCatalog.all(custom: [custom])
|
||||
#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"))
|
||||
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"))
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("PresetStore")
|
||||
struct PresetStoreTests {
|
||||
final class PresetStoreTests: XCTestCase {
|
||||
|
||||
private func tempSettingsURL() throws -> URL {
|
||||
let dir = FileManager.default.temporaryDirectory
|
||||
@@ -119,58 +101,52 @@ struct PresetStoreTests {
|
||||
return dir.appendingPathComponent("settings.json")
|
||||
}
|
||||
|
||||
@Test("CRUD + export/import round-trip")
|
||||
func crud() throws {
|
||||
func testCrud() 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)
|
||||
#expect(store.customs().count == 1)
|
||||
#expect(store.all().count == 5)
|
||||
XCTAssertEqual(store.customs().count, 1)
|
||||
XCTAssertEqual(store.all().count, 5)
|
||||
|
||||
p.name = "Renamed"
|
||||
try store.saveCustom(p)
|
||||
#expect(store.customs().count == 1)
|
||||
#expect(store.customs()[0].name == "Renamed")
|
||||
XCTAssertEqual(store.customs().count, 1)
|
||||
XCTAssertEqual(store.customs()[0].name, "Renamed")
|
||||
|
||||
let data = try store.export(p)
|
||||
let imported = try store.import(data)
|
||||
#expect(imported.name == "Renamed")
|
||||
#expect(imported.dpi == 150)
|
||||
XCTAssertEqual(imported.name, "Renamed")
|
||||
XCTAssertEqual(imported.dpi, 150)
|
||||
|
||||
#expect(try store.deleteCustom(id: "custom-x"))
|
||||
#expect(store.customs().isEmpty)
|
||||
#expect(try !store.deleteCustom(id: "preset-std-rgb"))
|
||||
XCTAssertTrue(try store.deleteCustom(id: "custom-x"))
|
||||
XCTAssertTrue(store.customs().isEmpty)
|
||||
XCTAssertFalse(try store.deleteCustom(id: "preset-std-rgb"))
|
||||
}
|
||||
|
||||
@Test("Import rewrites a built-in id to a fresh custom id")
|
||||
func importBuiltinCollision() throws {
|
||||
func testImportBuiltinCollision() 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)
|
||||
#expect(imported.id.hasPrefix("custom-"))
|
||||
#expect(!PresetCatalog.isBuiltIn(imported.id))
|
||||
XCTAssertTrue(imported.id.hasPrefix("custom-"))
|
||||
XCTAssertFalse(PresetCatalog.isBuiltIn(imported.id))
|
||||
}
|
||||
|
||||
@Test("Built-ins are immutable through saveCustom")
|
||||
func builtInImmutable() throws {
|
||||
func testBuiltInImmutable() 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"
|
||||
#expect(throws: PresetStore.PresetStoreError.self) {
|
||||
try store.saveCustom(shadowed)
|
||||
}
|
||||
XCTAssertThrowsError(try store.saveCustom(shadowed)) { error in XCTAssertTrue(error is PresetStore.PresetStoreError) }
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("AppSettings preset migration")
|
||||
struct PresetMigrationTests {
|
||||
final class PresetMigrationTests: XCTestCase {
|
||||
|
||||
private func tempSettingsURL() throws -> URL {
|
||||
let dir = FileManager.default.temporaryDirectory
|
||||
@@ -179,8 +155,7 @@ struct PresetMigrationTests {
|
||||
return dir.appendingPathComponent("settings.json")
|
||||
}
|
||||
|
||||
@Test("Legacy M1 custom_presets migrate to typed schema")
|
||||
func legacyMigration() throws {
|
||||
func testLegacyMigration() throws {
|
||||
let url = try tempSettingsURL()
|
||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||
let legacy = """
|
||||
@@ -194,20 +169,19 @@ struct PresetMigrationTests {
|
||||
try legacy.write(to: url)
|
||||
|
||||
let settings = SettingsStore(fileURL: url).load()
|
||||
#expect(settings.customPresets.count == 1)
|
||||
XCTAssertEqual(settings.customPresets.count, 1)
|
||||
let p = settings.customPresets[0]
|
||||
#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")
|
||||
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")
|
||||
}
|
||||
|
||||
@Test("Typed presets load and re-save as the typed schema")
|
||||
func typedRoundTrip() throws {
|
||||
func testTypedRoundTrip() throws {
|
||||
let url = try tempSettingsURL()
|
||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||
let store = SettingsStore(fileURL: url)
|
||||
@@ -215,18 +189,285 @@ struct PresetMigrationTests {
|
||||
s.customPresets = [ProfilingPreset(id: "c1", name: "C1", patchCount: 700)]
|
||||
try store.save(s)
|
||||
let loaded = store.load()
|
||||
#expect(loaded.customPresets.first?.patchCount == 700)
|
||||
XCTAssertEqual(loaded.customPresets.first?.patchCount, 700)
|
||||
}
|
||||
|
||||
@Test("Draft preset dpi=150 survives Codable + settings round-trip")
|
||||
func draftDPI() throws {
|
||||
func testDraftDPI() 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]
|
||||
#expect(obj["dpi"] as? Int == 150)
|
||||
XCTAssertEqual(obj["dpi"] as? Int, 150)
|
||||
let back = try store.import(data)
|
||||
#expect(back.dpi == 150)
|
||||
XCTAssertEqual(back.dpi, 150)
|
||||
}
|
||||
}
|
||||
|
||||
final class PresetMappingTests: XCTestCase {
|
||||
func testDraftDpi() {
|
||||
let cfg = PrinttargConfig(
|
||||
preset: PresetCatalog.draftRGB,
|
||||
basename: "t",
|
||||
workingDirectory: nil,
|
||||
calibrationFile: nil
|
||||
)
|
||||
XCTAssertEqual(cfg.dpi, 150)
|
||||
XCTAssertEqual(cfg.layoutOrder, .deterministic)
|
||||
}
|
||||
|
||||
func testOptionalNil() {
|
||||
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)
|
||||
}
|
||||
|
||||
func testRoundTripConfigs() {
|
||||
var preset = PresetCatalog.highQualityCMYK
|
||||
preset.pageSize = "210x297"
|
||||
preset.colprofFwa = "D50"
|
||||
preset.greySteps = nil
|
||||
let targen = TargenConfig(preset: preset, basename: "job", workingDirectory: nil)
|
||||
let printtarg = PrinttargConfig(
|
||||
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")
|
||||
let back = ProfilingPreset(
|
||||
id: preset.id,
|
||||
name: preset.name,
|
||||
description: preset.description,
|
||||
targen: targen,
|
||||
printtarg: printtarg,
|
||||
colprof: colprof,
|
||||
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)
|
||||
}
|
||||
|
||||
func testFullRoundTrip() {
|
||||
let preset = ProfilingPreset(
|
||||
id: "custom-full",
|
||||
name: "Full",
|
||||
description: "All fields",
|
||||
colourSpace: "cmyk",
|
||||
patchCount: 1500,
|
||||
whitePatches: 6,
|
||||
blackPatches: 8,
|
||||
greySteps: 9,
|
||||
singleChannelSteps: 7,
|
||||
neutralSteps: 4,
|
||||
neutralConcentration: 0.7,
|
||||
preconditioningProfile: "/tmp/pre.icm",
|
||||
ofpsHighQuality: true,
|
||||
ofpsAdaptation: 0.2,
|
||||
fullSpreadAlgorithm: "R",
|
||||
totalInkLimit: 280,
|
||||
darkEmphasis: 1.3,
|
||||
devicePower: 1.2,
|
||||
instrument: "p3",
|
||||
pageSize: "250x300",
|
||||
bitDepth: 16,
|
||||
dpi: 360,
|
||||
randomSeed: 42,
|
||||
noRandomize: false,
|
||||
calibrationFile: "/tmp/a.cal",
|
||||
applyCalibration: true,
|
||||
colprofAlgorithm: "x",
|
||||
colprofQuality: "u",
|
||||
colprofIntent: "p",
|
||||
colprofFwa: "D65",
|
||||
colprofIlluminant: "D65",
|
||||
colprofObserver: "1931_2",
|
||||
colprofInputViewingCond: "D50_2",
|
||||
colprofOutputViewingCond: "D65_2"
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
let printtarg = PrinttargConfig(
|
||||
preset: preset,
|
||||
basename: "j",
|
||||
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")
|
||||
|
||||
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")
|
||||
|
||||
let back = ProfilingPreset(
|
||||
id: preset.id,
|
||||
name: preset.name,
|
||||
description: preset.description,
|
||||
targen: targen,
|
||||
printtarg: printtarg,
|
||||
colprof: colprof,
|
||||
calibrationFile: preset.calibrationFile,
|
||||
applyCalibration: preset.applyCalibration
|
||||
)
|
||||
XCTAssertEqual(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)
|
||||
}
|
||||
}
|
||||
|
||||
func testOfpsHighQualityFalse() {
|
||||
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)
|
||||
|
||||
preset.ofpsHighQuality = nil
|
||||
let nilCfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
|
||||
XCTAssertNil(nilCfg.ofpsHighQuality)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
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).
|
||||
@MainActor
|
||||
final class PresetViewModelMappingTests: XCTestCase {
|
||||
|
||||
private func makeWorkflow() throws -> (TestAppEnvironment, TargetWorkflowViewModel) {
|
||||
let env = try TestAppEnvironment.make()
|
||||
return (env, TargetWorkflowViewModel(environment: env.environment))
|
||||
}
|
||||
|
||||
func testNilFwaClearsCustomPath() throws {
|
||||
let (env, vm) = try makeWorkflow()
|
||||
defer { env.cleanup() }
|
||||
|
||||
var customPreset = ProfilingPreset(
|
||||
id: "c-fwa", name: "FWA", patchCount: 800,
|
||||
colprofFwa: "/tmp/fwa.sp"
|
||||
)
|
||||
vm.applyPreset(customPreset)
|
||||
XCTAssertEqual(vm.profile.fwaSelection, .custom)
|
||||
XCTAssertEqual(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)
|
||||
}
|
||||
|
||||
func testCustomFwaRoundTrip() throws {
|
||||
let (env, vm) = try makeWorkflow()
|
||||
defer { env.cleanup() }
|
||||
|
||||
let preset = ProfilingPreset(
|
||||
id: "c-fwa2", name: "FWA2", patchCount: 800,
|
||||
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")
|
||||
}
|
||||
|
||||
func testPresetCalibrationReachesStage2() throws {
|
||||
let (env, vm) = try makeWorkflow()
|
||||
defer { env.cleanup() }
|
||||
|
||||
// Stale live state must not leak into the preset-applied layout.
|
||||
vm.profile.applyCalibration = true
|
||||
vm.profile.calibrationFile = "/tmp/stale.cal"
|
||||
|
||||
let preset = ProfilingPreset(
|
||||
id: "c-cal", name: "Cal", patchCount: 800,
|
||||
calibrationFile: "/tmp/preset.cal",
|
||||
applyCalibration: true
|
||||
)
|
||||
vm.applyPreset(preset)
|
||||
|
||||
XCTAssertTrue(vm.profile.applyCalibration)
|
||||
XCTAssertEqual(vm.profile.calibrationFile, "/tmp/preset.cal")
|
||||
XCTAssertEqual(vm.buildPrinttargConfig().calibrationFile, "/tmp/preset.cal")
|
||||
}
|
||||
|
||||
func testDisabledCalibrationClearsStage2() throws {
|
||||
let (env, vm) = try makeWorkflow()
|
||||
defer { env.cleanup() }
|
||||
|
||||
vm.profile.applyCalibration = true
|
||||
vm.profile.calibrationFile = "/tmp/stale.cal"
|
||||
|
||||
let preset = ProfilingPreset(
|
||||
id: "c-nocal", name: "NoCal", patchCount: 800,
|
||||
calibrationFile: "/tmp/preset.cal",
|
||||
applyCalibration: nil
|
||||
)
|
||||
vm.applyPreset(preset)
|
||||
|
||||
XCTAssertFalse(vm.profile.applyCalibration)
|
||||
XCTAssertNil(vm.buildPrinttargConfig().calibrationFile)
|
||||
}
|
||||
|
||||
func testFormFieldsApply() throws {
|
||||
let (env, vm) = try makeWorkflow()
|
||||
defer { env.cleanup() }
|
||||
|
||||
var preset = ProfilingPreset(
|
||||
id: "c-form", name: "Form",
|
||||
colourSpace: "cmyk", patchCount: 1500,
|
||||
whitePatches: 6,
|
||||
blackPatches: 8,
|
||||
greySteps: 9,
|
||||
fullSpreadAlgorithm: "r",
|
||||
pageSize: "250x300",
|
||||
dpi: 150
|
||||
)
|
||||
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")
|
||||
|
||||
// Disabled advanced controls stay nil in the snapshot, not
|
||||
// numeric sentinels.
|
||||
preset.greySteps = nil
|
||||
vm.applyPreset(preset)
|
||||
XCTAssertFalse(vm.greyStepsEnabled)
|
||||
XCTAssertNil(vm.buildTargenConfig().greySteps)
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
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.
|
||||
@Suite("PrintPanelStub")
|
||||
struct PrintPanelStubTests {
|
||||
final class PrintPanelStubTests: XCTestCase {
|
||||
|
||||
private func withEnv(
|
||||
_ vars: [String: String?],
|
||||
@@ -28,19 +27,17 @@ struct PrintPanelStubTests {
|
||||
try body()
|
||||
}
|
||||
|
||||
@Test("Cancel returns nil — not an error")
|
||||
func cancelIsNil() throws {
|
||||
func testCancelIsNil() throws {
|
||||
try withEnv([
|
||||
"ICCERY_UI_TESTING": "1",
|
||||
"ICCERY_TEST_PRINT_PANEL": "cancel",
|
||||
]) {
|
||||
#expect(UITestHooks.printPanelStubbed)
|
||||
#expect(UITestHooks.printPanelResult(forQueue: "q") == nil)
|
||||
XCTAssertTrue(UITestHooks.printPanelStubbed)
|
||||
XCTAssertNil(UITestHooks.printPanelResult(forQueue: "q"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("OK returns captured options + selected printer")
|
||||
func okResult() throws {
|
||||
func testOkResult() throws {
|
||||
try withEnv([
|
||||
"ICCERY_UI_TESTING": "1",
|
||||
"ICCERY_TEST_PRINT_PANEL": "ok",
|
||||
@@ -48,15 +45,14 @@ struct PrintPanelStubTests {
|
||||
"ICCERY_TEST_PANEL_PRINTER": "Other_Queue",
|
||||
]) {
|
||||
let result = UITestHooks.printPanelResult(forQueue: "q")
|
||||
#expect(result?.selectedPrinter == "Other_Queue")
|
||||
#expect(result?.options.cupsOptions == "MediaType=Photo InputSlot=Rear")
|
||||
#expect(result?.options.mediaType == "Photo")
|
||||
#expect(result?.options.ppdUncorrectedPassthrough == true)
|
||||
XCTAssertEqual(result?.selectedPrinter, "Other_Queue")
|
||||
XCTAssertEqual(result?.options.cupsOptions, "MediaType=Photo InputSlot=Rear")
|
||||
XCTAssertEqual(result?.options.mediaType, "Photo")
|
||||
XCTAssertEqual(result?.options.ppdUncorrectedPassthrough, true)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("OK defaults selected printer to the opened queue")
|
||||
func okDefaultsPrinter() throws {
|
||||
func testOkDefaultsPrinter() throws {
|
||||
try withEnv([
|
||||
"ICCERY_UI_TESTING": "1",
|
||||
"ICCERY_TEST_PRINT_PANEL": "ok",
|
||||
@@ -64,8 +60,8 @@ struct PrintPanelStubTests {
|
||||
"ICCERY_TEST_PANEL_PRINTER": nil,
|
||||
]) {
|
||||
let result = UITestHooks.printPanelResult(forQueue: "My_Queue")
|
||||
#expect(result?.selectedPrinter == "My_Queue")
|
||||
#expect(result?.options.cupsOptions == nil)
|
||||
XCTAssertEqual(result?.selectedPrinter, "My_Queue")
|
||||
XCTAssertNil(result?.options.cupsOptions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import XCTest
|
||||
@testable import ICCeryCore
|
||||
|
||||
@Suite("PrintcalArgs")
|
||||
struct PrintcalArgsTests {
|
||||
final class PrintcalArgsTests: XCTestCase {
|
||||
|
||||
private let tmp = URL(fileURLWithPath: "/tmp/out.cal")
|
||||
|
||||
@Test("Default printcal argv")
|
||||
func defaults() throws {
|
||||
func testDefaults() throws {
|
||||
let config = PrintcalConfig(
|
||||
ti3Basename: "CAL_demo",
|
||||
outputURL: tmp
|
||||
)
|
||||
let args = try PrintcalArgs.build(config: config)
|
||||
#expect(args == ["-v", "-e", "-o", "/tmp/out.cal", "CAL_demo"])
|
||||
XCTAssertEqual(args, ["-v", "-e", "-o", "/tmp/out.cal", "CAL_demo"])
|
||||
}
|
||||
|
||||
@Test("All options and channel limits")
|
||||
func allOptions() throws {
|
||||
func testAllOptions() throws {
|
||||
let config = PrintcalConfig(
|
||||
ti3Basename: "demo",
|
||||
outputURL: tmp,
|
||||
@@ -32,7 +29,7 @@ struct PrintcalArgsTests {
|
||||
]
|
||||
)
|
||||
let args = try PrintcalArgs.build(config: config)
|
||||
#expect(args == [
|
||||
XCTAssertEqual(args, [
|
||||
"-v", "-e",
|
||||
"-I", "-z",
|
||||
"-a", "/tmp/old.cal",
|
||||
@@ -44,16 +41,34 @@ struct PrintcalArgsTests {
|
||||
])
|
||||
}
|
||||
|
||||
@Test("Rejects invalid per-channel limit")
|
||||
func rejectsBadChannelLimit() {
|
||||
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() {
|
||||
let config = PrintcalConfig(
|
||||
ti3Basename: "demo",
|
||||
outputURL: tmp,
|
||||
channelLimits: [PrintcalChannelLimit(channel: "K", percent: 150)]
|
||||
)
|
||||
#expect(throws: (any Error).self) {
|
||||
_ = try PrintcalArgs.build(config: config)
|
||||
}
|
||||
XCTAssertThrowsError(try PrintcalArgs.build(config: config))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import Testing
|
||||
import XCTest
|
||||
import Foundation
|
||||
@testable import ICCeryCore
|
||||
|
||||
@Suite("PrinttargArgs")
|
||||
struct PrinttargArgsTests {
|
||||
final class PrinttargArgsTests: XCTestCase {
|
||||
|
||||
private func config(
|
||||
instrument: PrintInstrument = .i1,
|
||||
@@ -28,119 +27,121 @@ struct PrinttargArgsTests {
|
||||
)
|
||||
}
|
||||
|
||||
@Test("Baseline: -v -u -i i1 -p A4 -R 1 -t 300")
|
||||
func baseline() throws {
|
||||
func testBaseline() throws {
|
||||
let args = try PrinttargArgs.build(config: config())
|
||||
#expect(args == ["-v", "-u", "-i", "i1", "-p", "A4",
|
||||
XCTAssertEqual(args, ["-v", "-u", "-i", "i1", "-p", "A4",
|
||||
"-R", "1", "-t", "300", "target"])
|
||||
}
|
||||
|
||||
@Test("Default layout is deterministic -R 1, never bare")
|
||||
func deterministicDefault() throws {
|
||||
func testDeterministicDefault() throws {
|
||||
let args = try PrinttargArgs.build(config: config())
|
||||
#expect(args.contains("-R"))
|
||||
#expect(!args.contains("-r"))
|
||||
#expect(args[args.firstIndex(of: "-R")! + 1] == "1")
|
||||
XCTAssertTrue(args.contains("-R"))
|
||||
XCTAssertFalse(args.contains("-r"))
|
||||
XCTAssertEqual(args[args.firstIndex(of: "-R")! + 1], "1")
|
||||
}
|
||||
|
||||
@Test("Custom seed -R N; seed < 1 throws")
|
||||
func customSeed() throws {
|
||||
func testCustomSeed() throws {
|
||||
let args = try PrinttargArgs.build(config: config(layout: .customSeed, seed: 42))
|
||||
#expect(args[args.firstIndex(of: "-R")! + 1] == "42")
|
||||
#expect(throws: PrinttargArgError.self) {
|
||||
try PrinttargArgs.build(config: config(layout: .customSeed, seed: 0))
|
||||
XCTAssertEqual(args[args.firstIndex(of: "-R")! + 1], "42")
|
||||
XCTAssertThrowsError(try PrinttargArgs.build(config: config(layout: .customSeed, seed: 0))) { error in
|
||||
XCTAssertTrue(error is PrinttargArgError)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Raster emits -r and supersedes seed (printtarg -r, not targen -r)")
|
||||
func raster() throws {
|
||||
func testRaster() throws {
|
||||
let args = try PrinttargArgs.build(config: config(layout: .raster, seed: 9))
|
||||
#expect(args.contains("-r"))
|
||||
#expect(!args.contains("-R"))
|
||||
XCTAssertTrue(args.contains("-r"))
|
||||
XCTAssertFalse(args.contains("-R"))
|
||||
}
|
||||
|
||||
@Test("Label: -d emits the resolved string, not a colour space")
|
||||
func label() throws {
|
||||
func testLabel() 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")!
|
||||
#expect(args[i + 1].hasPrefix("ICCery - t"))
|
||||
XCTAssertTrue(args[i + 1].hasPrefix("ICCery - t"))
|
||||
}
|
||||
|
||||
@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))
|
||||
func testBitDepthAndDPI() throws {
|
||||
XCTAssertTrue(try PrinttargArgs.build(config: config(bitDepth: .sixteen, dpi: 600))
|
||||
.contains("-T"))
|
||||
#expect(try PrinttargArgs.build(config: config(bitDepth: .eight, dpi: 72))
|
||||
XCTAssertTrue(try PrinttargArgs.build(config: config(bitDepth: .eight, dpi: 72))
|
||||
.contains("-t"))
|
||||
#expect(throws: PrinttargArgError.self) {
|
||||
try PrinttargArgs.build(config: config(dpi: 71))
|
||||
XCTAssertThrowsError(try PrinttargArgs.build(config: config(dpi: 71))) { error in
|
||||
XCTAssertTrue(error is PrinttargArgError)
|
||||
}
|
||||
#expect(throws: PrinttargArgError.self) {
|
||||
try PrinttargArgs.build(config: config(dpi: 601))
|
||||
XCTAssertThrowsError(try PrinttargArgs.build(config: config(dpi: 601))) { error in
|
||||
XCTAssertTrue(error is PrinttargArgError)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("All instruments emit their Argyll code")
|
||||
func instruments() throws {
|
||||
func testInstruments() 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))
|
||||
#expect(args[args.firstIndex(of: "-i")! + 1] == code)
|
||||
XCTAssertEqual(args[args.firstIndex(of: "-i")! + 1], code)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("All fixed page sizes; custom emits WxH in mm")
|
||||
func pageSizes() throws {
|
||||
func testPageSizes() throws {
|
||||
for size in PageSize.allCases where size != .custom {
|
||||
let args = try PrinttargArgs.build(config: config(pageSize: size))
|
||||
#expect(args[args.firstIndex(of: "-p")! + 1] == size.rawValue)
|
||||
XCTAssertEqual(args[args.firstIndex(of: "-p")! + 1], size.rawValue)
|
||||
}
|
||||
let custom = try PrinttargArgs.build(config: config(
|
||||
pageSize: .custom, customW: 150, customH: 220))
|
||||
#expect(custom[custom.firstIndex(of: "-p")! + 1] == "150x220")
|
||||
XCTAssertEqual(custom[custom.firstIndex(of: "-p")! + 1], "150x220")
|
||||
}
|
||||
|
||||
@Test("Custom page below 50 mm throws")
|
||||
func customPageTooSmall() {
|
||||
#expect(throws: PrinttargArgError.self) {
|
||||
try PrinttargArgs.build(config: config(pageSize: .custom, customW: 49.9))
|
||||
func testCustomPageTooSmall() {
|
||||
XCTAssertThrowsError(try PrinttargArgs.build(config: config(pageSize: .custom, customW: 49.9))) { error in
|
||||
XCTAssertTrue(error is PrinttargArgError)
|
||||
}
|
||||
#expect(throws: PrinttargArgError.self) {
|
||||
try PrinttargArgs.build(config: config(pageSize: .custom, customH: 10))
|
||||
XCTAssertThrowsError(try PrinttargArgs.build(config: config(pageSize: .custom, customH: 10))) { error in
|
||||
XCTAssertTrue(error is PrinttargArgError)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Calibration: -K applies, -I embeds")
|
||||
func calibrationFlags() throws {
|
||||
func testCalibrationFlags() throws {
|
||||
let k = try PrinttargArgs.build(config: config(calFile: "/tmp/a.cal"))
|
||||
#expect(k[k.firstIndex(of: "-K")! + 1] == "/tmp/a.cal")
|
||||
XCTAssertEqual(k[k.firstIndex(of: "-K")! + 1], "/tmp/a.cal")
|
||||
let i = try PrinttargArgs.build(config: config(calFile: "/tmp/a.cal", calEmbed: true))
|
||||
#expect(i[i.firstIndex(of: "-I")! + 1] == "/tmp/a.cal")
|
||||
#expect(!i.contains("-K"))
|
||||
XCTAssertEqual(i[i.firstIndex(of: "-I")! + 1], "/tmp/a.cal")
|
||||
XCTAssertFalse(i.contains("-K"))
|
||||
}
|
||||
|
||||
@Test("CAL_ basename never gets -K or -I")
|
||||
func calProtection() throws {
|
||||
func testCalProtection() throws {
|
||||
let args = try PrinttargArgs.build(
|
||||
config: config(calFile: "/tmp/a.cal", basename: "CAL_test"))
|
||||
#expect(!args.contains("-K"))
|
||||
#expect(!args.contains("-I"))
|
||||
XCTAssertFalse(args.contains("-K"))
|
||||
XCTAssertFalse(args.contains("-I"))
|
||||
}
|
||||
|
||||
@Test("Unsafe basename throws")
|
||||
func unsafeBasename() {
|
||||
#expect(throws: PathSecurity.Error.self) {
|
||||
try PrinttargArgs.build(config: config(basename: "../x"))
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("PrinttargLabel")
|
||||
struct PrinttargLabelTests {
|
||||
final class PrinttargLabelTests: XCTestCase {
|
||||
|
||||
private var fixedDate: Date {
|
||||
var comps = DateComponents()
|
||||
@@ -149,37 +150,33 @@ struct PrinttargLabelTests {
|
||||
return Calendar(identifier: .gregorian).date(from: comps)!
|
||||
}
|
||||
|
||||
@Test("Automatic label: ICCery - basename - P - I - DP - AP - DD/MM/YYYY HH:MM")
|
||||
func automatic() {
|
||||
func testAutomatic() {
|
||||
let label = PrinttargLabel.automatic(
|
||||
basename: "tgt",
|
||||
metadata: TargetLabelMetadata(
|
||||
printer: "Epson", inkSet: "CMYK",
|
||||
driverPaper: "Photo", actualPaper: "Matte"),
|
||||
date: fixedDate, timeZone: .current)
|
||||
#expect(label.hasPrefix("ICCery - tgt - Epson - CMYK - Photo - Matte - "))
|
||||
#expect(label.hasSuffix("03/02/2026") || label.contains("/02/2026"))
|
||||
XCTAssertTrue(label.hasPrefix("ICCery - tgt - Epson - CMYK - Photo - Matte - "))
|
||||
XCTAssertTrue(label.hasSuffix("03/02/2026") || label.contains("/02/2026"))
|
||||
}
|
||||
|
||||
@Test("Missing metadata becomes Unspecified")
|
||||
func unspecified() {
|
||||
func testUnspecified() {
|
||||
let label = PrinttargLabel.automatic(
|
||||
basename: "tgt", metadata: TargetLabelMetadata(),
|
||||
date: fixedDate, timeZone: .current)
|
||||
#expect(label.contains(" - Unspecified - Unspecified - Unspecified - Unspecified - "))
|
||||
XCTAssertTrue(label.contains(" - Unspecified - Unspecified - Unspecified - Unspecified - "))
|
||||
}
|
||||
|
||||
@Test("Manual label wins over automatic")
|
||||
func manualWins() {
|
||||
func testManualWins() {
|
||||
let resolved = PrinttargLabel.resolved(
|
||||
customLabel: " My Label ", basename: "tgt",
|
||||
metadata: TargetLabelMetadata(), date: fixedDate)
|
||||
#expect(resolved == "My Label")
|
||||
XCTAssertEqual(resolved, "My Label")
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("PrinttargManifest")
|
||||
struct PrinttargManifestTests {
|
||||
final class PrinttargManifestTests: XCTestCase {
|
||||
|
||||
private let prettySingle = """
|
||||
Some log line
|
||||
@@ -208,66 +205,58 @@ struct PrinttargManifestTests {
|
||||
}
|
||||
"""
|
||||
|
||||
@Test("Decodes a single-page pretty manifest amid log noise")
|
||||
func singlePage() throws {
|
||||
func testSinglePage() throws {
|
||||
let m = try PrinttargManifestExtractor.manifest(from: prettySingle)
|
||||
#expect(m.event == "manifest")
|
||||
#expect(m.pages.count == 1)
|
||||
#expect(m.pages[0].filename == "target.tif")
|
||||
#expect(m.pages[0].patches == 800)
|
||||
XCTAssertEqual(m.event, "manifest")
|
||||
XCTAssertEqual(m.pages.count, 1)
|
||||
XCTAssertEqual(m.pages[0].filename, "target.tif")
|
||||
XCTAssertEqual(m.pages[0].patches, 800)
|
||||
}
|
||||
|
||||
@Test("Multi-page manifest preserves order")
|
||||
func multiPage() throws {
|
||||
func testMultiPage() throws {
|
||||
let m = try PrinttargManifestExtractor.manifest(from: prettyMulti)
|
||||
#expect(m.pages.map(\.filename) == ["p1.tif", "p2.tif"])
|
||||
XCTAssertEqual(m.pages.map(\.filename), ["p1.tif", "p2.tif"])
|
||||
}
|
||||
|
||||
@Test("No JSON document → noJSONDocument")
|
||||
func noJSON() {
|
||||
#expect(throws: ManifestError.self) {
|
||||
try PrinttargManifestExtractor.manifest(from: "plain text\nno json")
|
||||
func testNoJSON() {
|
||||
XCTAssertThrowsError(try PrinttargManifestExtractor.manifest(from: "plain text\nno json")) { error in
|
||||
XCTAssertTrue(error is ManifestError)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Wrong event → wrongEvent")
|
||||
func wrongEvent() {
|
||||
func testWrongEvent() {
|
||||
let stdout = "{\n \"event\": \"row\",\n \"row\": 1\n}\n"
|
||||
#expect(throws: ManifestError.self) {
|
||||
try PrinttargManifestExtractor.manifest(from: stdout)
|
||||
XCTAssertThrowsError(try PrinttargManifestExtractor.manifest(from: stdout)) { error in
|
||||
XCTAssertTrue(error is ManifestError)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("ROW_COLORS_JSON line is never treated as the manifest")
|
||||
func rowColorsNotManifest() {
|
||||
func testRowColorsNotManifest() {
|
||||
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)
|
||||
#expect(m != nil)
|
||||
#expect(m?.event == "manifest")
|
||||
XCTAssertNotNil(m)
|
||||
XCTAssertEqual(m?.event, "manifest")
|
||||
}
|
||||
|
||||
@Test("Braces inside a quoted filename do not corrupt the scan")
|
||||
func bracesInFilename() throws {
|
||||
func testBracesInFilename() 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)
|
||||
#expect(m.pages[0].filename == "a}b.tif")
|
||||
XCTAssertEqual(m.pages[0].filename, "a}b.tif")
|
||||
}
|
||||
|
||||
@Test("Unsafe / non-TIFF filenames rejected")
|
||||
func unsafeFilenames() {
|
||||
func testUnsafeFilenames() {
|
||||
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}"
|
||||
#expect(throws: ManifestError.self) {
|
||||
try PrinttargManifestExtractor.manifest(from: stdout)
|
||||
XCTAssertThrowsError(try PrinttargManifestExtractor.manifest(from: stdout)) { error in
|
||||
XCTAssertTrue(error is ManifestError)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("ArgyllRunner Printtarg")
|
||||
struct ArgyllRunnerPrinttargTests {
|
||||
final class ArgyllRunnerPrinttargTests: XCTestCase {
|
||||
|
||||
private func makeFixture(_ body: String, name: String = "printtarg") throws -> URL {
|
||||
let dir = FileManager.default.temporaryDirectory
|
||||
@@ -319,8 +308,7 @@ struct ArgyllRunnerPrinttargTests {
|
||||
try Data(bytes).write(to: url)
|
||||
}
|
||||
|
||||
@Test("Successful printtarg emits .ti2 + manifest + PNG previews")
|
||||
func success() async throws {
|
||||
func testSuccess() async throws {
|
||||
let dir = try makeFixture("""
|
||||
#!/bin/sh
|
||||
last=""
|
||||
@@ -339,18 +327,17 @@ struct ArgyllRunnerPrinttargTests {
|
||||
processManager: ProcessManager(), binaryResolver: resolver)
|
||||
let config = PrinttargConfig(basename: "pt", workingDirectory: dir)
|
||||
let result = try await runner.runPrinttarg(config: config)
|
||||
#expect(result.ti2URL.lastPathComponent == "pt.ti2")
|
||||
#expect(result.manifest.pages.count == 1)
|
||||
#expect(result.pages.count == 1)
|
||||
XCTAssertEqual(result.ti2URL.lastPathComponent, "pt.ti2")
|
||||
XCTAssertEqual(result.manifest.pages.count, 1)
|
||||
XCTAssertEqual(result.pages.count, 1)
|
||||
let png = result.pages[0].previewPNG
|
||||
#expect(png != nil)
|
||||
XCTAssertNotNil(png)
|
||||
if let png {
|
||||
#expect(png.prefix(8) == Data([0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A]))
|
||||
XCTAssertEqual(png.prefix(8), Data([0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A]))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Non-zero exit throws processFailed and stays on stage")
|
||||
func failure() async throws {
|
||||
func testFailure() async throws {
|
||||
let dir = try makeFixture("""
|
||||
#!/bin/sh
|
||||
echo "oops" >&2
|
||||
@@ -360,14 +347,16 @@ struct ArgyllRunnerPrinttargTests {
|
||||
let runner = ArgyllRunner(
|
||||
processManager: ProcessManager(),
|
||||
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir))
|
||||
await #expect(throws: ArgyllRunnerError.self) {
|
||||
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
|
||||
try await runner.runPrinttarg(
|
||||
config: PrinttargConfig(basename: "x", workingDirectory: dir))
|
||||
} errorHandler: { error in
|
||||
XCTAssertEqual(error, .toolFailed(
|
||||
tool: "printtarg", code: 3, logs: ["oops"]))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Exit 0 without manifest → malformedManifest")
|
||||
func noManifest() async throws {
|
||||
func testNoManifest() async throws {
|
||||
let dir = try makeFixture("""
|
||||
#!/bin/sh
|
||||
last=""
|
||||
@@ -380,14 +369,13 @@ struct ArgyllRunnerPrinttargTests {
|
||||
let runner = ArgyllRunner(
|
||||
processManager: ProcessManager(),
|
||||
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir))
|
||||
await #expect(throws: ArgyllRunnerError.self) {
|
||||
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
|
||||
try await runner.runPrinttarg(
|
||||
config: PrinttargConfig(basename: "x", workingDirectory: dir))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Exit 0 without .ti2 → missingArtefact")
|
||||
func noTi2() async throws {
|
||||
func testNoTi2() async throws {
|
||||
let dir = try makeFixture("""
|
||||
#!/bin/sh
|
||||
printf '{\\n"event":"manifest",\\n"pages":[]\\n}\\n'
|
||||
@@ -397,14 +385,13 @@ struct ArgyllRunnerPrinttargTests {
|
||||
let runner = ArgyllRunner(
|
||||
processManager: ProcessManager(),
|
||||
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir))
|
||||
await #expect(throws: ArgyllRunnerError.self) {
|
||||
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
|
||||
try await runner.runPrinttarg(
|
||||
config: PrinttargConfig(basename: "x", workingDirectory: dir))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Deterministic config produces byte-identical .ti2")
|
||||
func determinism() async throws {
|
||||
func testDeterminism() async throws {
|
||||
let dir = try makeFixture("""
|
||||
#!/bin/sh
|
||||
last=""
|
||||
@@ -424,6 +411,6 @@ struct ArgyllRunnerPrinttargTests {
|
||||
config: PrinttargConfig(basename: "b", workingDirectory: dir))
|
||||
let d1 = try Data(contentsOf: dir.appendingPathComponent("a.ti2"))
|
||||
let d2 = try Data(contentsOf: dir.appendingPathComponent("b.ti2"))
|
||||
#expect(d1 == d2)
|
||||
XCTAssertEqual(d1, d2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import Testing
|
||||
import XCTest
|
||||
import Foundation
|
||||
@testable import ICCeryCore
|
||||
|
||||
/// Helpers shared across ProcessManager tests. Fixture binaries are shell
|
||||
/// scripts written to a temp dir — no resource bundling required.
|
||||
@Suite("ProcessManager", .serialized)
|
||||
struct ProcessManagerTests {
|
||||
/// XCTest executes test methods serially by default.
|
||||
final class ProcessManagerTests: XCTestCase {
|
||||
|
||||
// MARK: - Fixture plumbing
|
||||
|
||||
@@ -43,7 +43,7 @@ struct ProcessManagerTests {
|
||||
if box.finish() { cont.resume(returning: box.events) }
|
||||
}
|
||||
Task {
|
||||
try? await Task.sleep(for: .seconds(timeout))
|
||||
try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
|
||||
if box.finish() { cont.resume(returning: box.events) }
|
||||
}
|
||||
}
|
||||
@@ -58,9 +58,62 @@ struct ProcessManagerTests {
|
||||
func finish() -> Bool { lock.lock(); defer { lock.unlock() }; if finished { return false }; finished = true; return true }
|
||||
}
|
||||
|
||||
/// Subscribes synchronously (registration happens inside `events()`)
|
||||
/// then records every event for `id` until the task is cancelled.
|
||||
/// Unlike `collect`, observation continues past `.exit` so tests can
|
||||
/// prove exactly-once exit emission.
|
||||
private func observe(
|
||||
_ manager: ProcessManager,
|
||||
id: String,
|
||||
into box: Box
|
||||
) -> Task<Void, Never> {
|
||||
let stream = manager.events()
|
||||
return Task {
|
||||
for await event in stream {
|
||||
guard event.id == id else { continue }
|
||||
box.append(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func exitCount(in box: Box) -> Int {
|
||||
box.events.filter { if case .exit = $0 { return true }; return false }.count
|
||||
}
|
||||
|
||||
private func waitForExit(in box: Box, timeout: TimeInterval = 10) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if exitCount(in: box) > 0 { return true }
|
||||
try? await Task.sleep(nanoseconds: 10_000_000)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func waitForFile(_ url: URL, timeout: TimeInterval = 5) async -> Bool {
|
||||
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)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func waitForRunning(
|
||||
_ manager: ProcessManager,
|
||||
id: String,
|
||||
timeout: TimeInterval = 5
|
||||
) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if await manager.isRunning(id) { return true }
|
||||
try? await Task.sleep(nanoseconds: 10_000_000)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Tests
|
||||
|
||||
@Test func streamsStdoutAndEmitsExit() async throws {
|
||||
func testStreamsStdoutAndEmitsExit() 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")
|
||||
@@ -69,21 +122,21 @@ struct ProcessManagerTests {
|
||||
let lines = evs.compactMap { e -> String? in
|
||||
if case .stdout(_, let l) = e { return l }; return nil
|
||||
}
|
||||
#expect(lines == ["hello", "world"])
|
||||
#expect(evs.contains(.exit(id: "t1", code: 0)))
|
||||
XCTAssertEqual(lines, ["hello", "world"])
|
||||
XCTAssertTrue(evs.contains(.exit(id: "t1", code: 0)))
|
||||
}
|
||||
|
||||
@Test func routesStderrSeparately() async throws {
|
||||
func testRoutesStderrSeparately() 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
|
||||
#expect(events.contains(.stdout(id: "t2", line: "out")))
|
||||
#expect(events.contains(.stderr(id: "t2", line: "oops")))
|
||||
XCTAssertTrue(events.contains(.stdout(id: "t2", line: "out")))
|
||||
XCTAssertTrue(events.contains(.stderr(id: "t2", line: "oops")))
|
||||
}
|
||||
|
||||
@Test func stripsRowColorsJSONPrefix() async throws {
|
||||
func testStripsRowColorsJSONPrefix() async throws {
|
||||
let pm = ProcessManager()
|
||||
let bin = try script(
|
||||
"rows.sh",
|
||||
@@ -96,21 +149,22 @@ struct ProcessManagerTests {
|
||||
if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) }
|
||||
return nil
|
||||
}
|
||||
#expect(rows == ["{\"row\":1}"])
|
||||
#expect(events.contains(.stdout(id: "t3", line: "plain")))
|
||||
XCTAssertEqual(rows, ["{\"row\":1}"])
|
||||
XCTAssertTrue(events.contains(.stdout(id: "t3", line: "plain")))
|
||||
// Prefixed lines must not leak into stdout.
|
||||
#expect(!events.contains(.stdout(id: "t3", line: "ROW_COLORS_JSON: {\"row\":1}")))
|
||||
XCTAssertFalse(events.contains(.stdout(id: "t3", line: "ROW_COLORS_JSON: {\"row\":1}")))
|
||||
}
|
||||
|
||||
@Test func unterminatedTailFlushesOnExit() async throws {
|
||||
func testUnterminatedTailFlushesOnExit() 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: [])
|
||||
#expect(await evs.contains(.stdout(id: "t4", line: "no-newline")))
|
||||
let t4SawTail = await evs.contains(.stdout(id: "t4", line: "no-newline"))
|
||||
XCTAssertTrue(t4SawTail)
|
||||
}
|
||||
|
||||
@Test func stdinRoundTrip() async throws {
|
||||
func testStdinRoundTrip() 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.
|
||||
@@ -123,21 +177,23 @@ struct ProcessManagerTests {
|
||||
try await pm.sendStdin(id: "t5", text: " \n")
|
||||
try await pm.sendStdin(id: "t5", text: "d\n")
|
||||
let events = await evs
|
||||
#expect(events.contains(.stdout(id: "t5", line: "got: ")))
|
||||
#expect(events.contains(.stdout(id: "t5", line: "got:d")))
|
||||
XCTAssertTrue(events.contains(.stdout(id: "t5", line: "got: ")))
|
||||
XCTAssertTrue(events.contains(.stdout(id: "t5", line: "got:d")))
|
||||
}
|
||||
|
||||
@Test func duplicateIDRejected() async throws {
|
||||
func testDuplicateIDRejected() 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 #expect(throws: ProcessError.duplicateID("t6")) {
|
||||
await assertAsyncThrows(expectedType: ProcessError.self) {
|
||||
try await pm.runStreaming(id: "t6", binary: bin, arguments: [])
|
||||
} errorHandler: { error in
|
||||
XCTAssertEqual(error, .duplicateID("t6"))
|
||||
}
|
||||
await pm.kill(id: "t6")
|
||||
}
|
||||
|
||||
@Test func killEmitsExitAndClosesStdin() async throws {
|
||||
func testKillEmitsExitAndClosesStdin() async throws {
|
||||
let pm = ProcessManager()
|
||||
let bin = try script("slow2.sh", "#!/bin/sh\ncat\n")
|
||||
async let evs = collect(pm, id: "t7")
|
||||
@@ -146,49 +202,51 @@ struct ProcessManagerTests {
|
||||
let events = await evs
|
||||
// exit emitted exactly once
|
||||
let exits = events.filter { if case .exit = $0 { return true }; return false }
|
||||
#expect(exits.count == 1)
|
||||
await #expect(throws: ProcessError.unknownID("t7")) {
|
||||
XCTAssertEqual(exits.count, 1)
|
||||
await assertAsyncThrows(expectedType: ProcessError.self) {
|
||||
try await pm.sendStdin(id: "t7", text: "d\n")
|
||||
} errorHandler: { error in
|
||||
XCTAssertEqual(error, .unknownID("t7"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func killAllCountsSignaled() async throws {
|
||||
func testKillAllCountsSignaled() 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()
|
||||
#expect(count == 2)
|
||||
XCTAssertEqual(count, 2)
|
||||
}
|
||||
|
||||
@Test func capturedRunReturnsBothStreams() async throws {
|
||||
func testCapturedRunReturnsBothStreams() 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: [])
|
||||
#expect(result.stdout.contains("out-data"))
|
||||
#expect(result.stderr.contains("err-data"))
|
||||
#expect(result.exitCode == 3)
|
||||
XCTAssertTrue(result.stdout.contains("out-data"))
|
||||
XCTAssertTrue(result.stderr.contains("err-data"))
|
||||
XCTAssertEqual(result.exitCode, 3)
|
||||
}
|
||||
|
||||
@Test func capturedRunFastExit() async throws {
|
||||
func testCapturedRunFastExit() 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: [])
|
||||
#expect(result.exitCode == 7)
|
||||
#expect(result.stdout == "")
|
||||
#expect(result.stderr == "")
|
||||
XCTAssertEqual(result.exitCode, 7)
|
||||
XCTAssertEqual(result.stdout, "")
|
||||
XCTAssertEqual(result.stderr, "")
|
||||
}
|
||||
|
||||
@Test func capturedRunStderrOnly() async throws {
|
||||
func testCapturedRunStderrOnly() 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: [])
|
||||
#expect(result.exitCode == 1)
|
||||
#expect(result.stdout == "")
|
||||
#expect(result.stderr.contains("mock lp failure"))
|
||||
XCTAssertEqual(result.exitCode, 1)
|
||||
XCTAssertEqual(result.stdout, "")
|
||||
XCTAssertTrue(result.stderr.contains("mock lp failure"))
|
||||
}
|
||||
|
||||
@Test func capturedRunDoesNotDeadlockOnLargeOutput() async throws {
|
||||
func testCapturedRunDoesNotDeadlockOnLargeOutput() async throws {
|
||||
let pm = ProcessManager()
|
||||
// 5000 lines each stream exceeds the 64 KiB pipe buffer.
|
||||
let bin = try script(
|
||||
@@ -196,85 +254,236 @@ struct ProcessManagerTests {
|
||||
"#!/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: [])
|
||||
#expect(result.stdout.contains("out-4999"))
|
||||
#expect(result.stderr.contains("err-4999"))
|
||||
XCTAssertTrue(result.stdout.contains("out-4999"))
|
||||
XCTAssertTrue(result.stderr.contains("err-4999"))
|
||||
}
|
||||
|
||||
@Test func argyllEnvVarIsSet() async throws {
|
||||
func testArgyllEnvVarIsSet() 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: [])
|
||||
#expect(await evs.contains(.stdout(id: "t10", line: "ANI=1")))
|
||||
let t10SawEnv = await evs.contains(.stdout(id: "t10", line: "ANI=1"))
|
||||
XCTAssertTrue(t10SawEnv)
|
||||
}
|
||||
|
||||
@Test func unknownIDStdinThrows() async throws {
|
||||
func testUnknownIDStdinThrows() async throws {
|
||||
let pm = ProcessManager()
|
||||
await #expect(throws: ProcessError.unknownID("nope")) {
|
||||
await assertAsyncThrows(expectedType: ProcessError.self) {
|
||||
try await pm.sendStdin(id: "nope", text: "d\n")
|
||||
} errorHandler: { error in
|
||||
XCTAssertEqual(error, .unknownID("nope"))
|
||||
}
|
||||
}
|
||||
|
||||
func testExplicitPartialFlushEmitsRowColorsJSON() async throws {
|
||||
let pm = ProcessManager()
|
||||
let marker = Self.fixtureDir
|
||||
.appendingPathComponent("partial-row-ready-\(UUID().uuidString)")
|
||||
let bin = try script(
|
||||
"partial-row.sh",
|
||||
"#!/bin/sh\nprintf 'ROW_COLORS_JSON: {\"row\":9}'\ntouch \"$1\"\nsleep 30\n"
|
||||
)
|
||||
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)
|
||||
// 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
|
||||
for _ in 0..<50 {
|
||||
await pm.flushPartialLine(id: "t11")
|
||||
if box.events.contains(where: { if case .jsonRow = $0 { return true }; return false }) {
|
||||
flushed = true
|
||||
break
|
||||
}
|
||||
try await Task.sleep(nanoseconds: 20_000_000)
|
||||
}
|
||||
XCTAssertTrue(flushed)
|
||||
await pm.kill(id: "t11")
|
||||
let sawExit = await waitForExit(in: box)
|
||||
XCTAssertTrue(sawExit)
|
||||
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}"])
|
||||
// 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)
|
||||
}
|
||||
|
||||
func testUnterminatedRowTailFinalizesAsJSONRow() async throws {
|
||||
let pm = ProcessManager()
|
||||
let bin = try script(
|
||||
"row-tail.sh",
|
||||
"#!/bin/sh\nprintf 'ROW_COLORS_JSON: {\"row\":42}'\n"
|
||||
)
|
||||
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)
|
||||
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}")))
|
||||
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)
|
||||
if let rowIndex, let exitIndex = exitIndexes.first {
|
||||
XCTAssertTrue(rowIndex < exitIndex)
|
||||
} else {
|
||||
XCTFail("expected a jsonRow before the exit event")
|
||||
}
|
||||
}
|
||||
|
||||
func testFastStreamingExitEmitsExactlyOneExit() 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)
|
||||
// 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)
|
||||
observer.cancel()
|
||||
XCTAssertEqual(box.events, [.exit(id: "t13", code: 0)])
|
||||
}
|
||||
|
||||
func testFastCapturedExitEmitsExactlyOneExit() 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)
|
||||
// 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)
|
||||
observer.cancel()
|
||||
XCTAssertEqual(box.events, [.exit(id: "t14", code: 7)])
|
||||
}
|
||||
|
||||
func testCapturedRunSetsArgyllNotInteractive() 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")
|
||||
}
|
||||
|
||||
func testKillAllTerminatesStreamingAndCapturedChildren() async throws {
|
||||
let pm = ProcessManager()
|
||||
let marker = Self.fixtureDir
|
||||
.appendingPathComponent("mixed-cap-ready-\(UUID().uuidString)")
|
||||
let slowBin = try script("mixed-slow.sh", "#!/bin/sh\nsleep 30\n")
|
||||
let capBin = try script("mixed-cap.sh", "#!/bin/sh\ntouch \"$1\"\nsleep 30\n")
|
||||
let streamBox = Box()
|
||||
let capBox = Box()
|
||||
let streamObserver = observe(pm, id: "t16", into: streamBox)
|
||||
let capObserver = observe(pm, id: "t17", into: capBox)
|
||||
try await pm.runStreaming(id: "t16", binary: slowBin, arguments: [])
|
||||
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)
|
||||
_ = try await capTask.value
|
||||
let streamExit = await waitForExit(in: streamBox)
|
||||
XCTAssertTrue(streamExit)
|
||||
let capExit = await waitForExit(in: capBox)
|
||||
XCTAssertTrue(capExit)
|
||||
// Grace window outlasts the streaming finalize watchdog.
|
||||
try await Task.sleep(nanoseconds: 2_500_000_000)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("ProcessLineDecoder")
|
||||
struct ProcessLineDecoderTests {
|
||||
@Test func splitsAcrossChunkBoundaries() {
|
||||
final class ProcessLineDecoderTests: XCTestCase {
|
||||
func testSplitsAcrossChunkBoundaries() {
|
||||
var d = ProcessLineDecoder()
|
||||
#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)
|
||||
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())
|
||||
}
|
||||
|
||||
@Test func crlfIsStripped() {
|
||||
func testCrlfIsStripped() {
|
||||
var d = ProcessLineDecoder()
|
||||
#expect(d.feed(Data("a\r\nb\r\n".utf8)) == ["a", "b"])
|
||||
XCTAssertEqual(d.feed(Data("a\r\nb\r\n".utf8)), ["a", "b"])
|
||||
}
|
||||
|
||||
@Test func finishReturnsRemainder() {
|
||||
func testFinishReturnsRemainder() {
|
||||
var d = ProcessLineDecoder()
|
||||
_ = d.feed(Data("x".utf8))
|
||||
#expect(d.finish() == "x")
|
||||
#expect(d.finish() == nil)
|
||||
XCTAssertEqual(d.finish(), "x")
|
||||
XCTAssertNil(d.finish())
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("JSONAccumulator")
|
||||
struct JSONAccumulatorTests {
|
||||
@Test func multilinePrettyJSON() {
|
||||
final class JSONAccumulatorTests: XCTestCase {
|
||||
func testMultilinePrettyJSON() {
|
||||
var acc = JSONAccumulator()
|
||||
#expect(acc.feed(line: "{") == nil)
|
||||
#expect(acc.feed(line: " \"k\": 1") == nil)
|
||||
XCTAssertNil(acc.feed(line: "{"))
|
||||
XCTAssertNil(acc.feed(line: " \"k\": 1"))
|
||||
let done = acc.feed(line: "}")
|
||||
#expect(done != nil)
|
||||
XCTAssertNotNil(done)
|
||||
let obj = try? JSONSerialization.jsonObject(with: done!) as? [String: Int]
|
||||
#expect(obj?["k"] == 1)
|
||||
XCTAssertEqual(obj?["k"], 1)
|
||||
}
|
||||
|
||||
@Test func nonJSONLinesIgnored() {
|
||||
func testNonJSONLinesIgnored() {
|
||||
var acc = JSONAccumulator()
|
||||
#expect(acc.feed(line: "Reading instrument...") == nil)
|
||||
#expect(acc.feed(line: "still text") == nil)
|
||||
#expect(acc.completeData == nil)
|
||||
XCTAssertNil(acc.feed(line: "Reading instrument..."))
|
||||
XCTAssertNil(acc.feed(line: "still text"))
|
||||
XCTAssertNil(acc.completeData)
|
||||
}
|
||||
|
||||
@Test func decodeTyped() {
|
||||
func testDecodeTyped() {
|
||||
struct Doc: Decodable { let n: Int }
|
||||
var acc = JSONAccumulator()
|
||||
// Split so the doc completes on the second feed.
|
||||
#expect(acc.feed(line: "{\"n\":") == nil)
|
||||
XCTAssertNil(acc.feed(line: "{\"n\":"))
|
||||
let data = acc.feed(line: "7}")
|
||||
#expect(data != nil)
|
||||
XCTAssertNotNil(data)
|
||||
let doc = data.flatMap { try? JSONDecoder().decode(Doc.self, from: $0) }
|
||||
#expect(doc?.n == 7)
|
||||
#expect(acc.isEmpty)
|
||||
XCTAssertEqual(doc?.n, 7)
|
||||
XCTAssertTrue(acc.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("LogSanitizer")
|
||||
struct LogSanitizerTests {
|
||||
@Test func homeIsRewritten() {
|
||||
final class LogSanitizerTests: XCTestCase {
|
||||
func testHomeIsRewritten() {
|
||||
let path = "\(NSHomeDirectory())/Documents/foo.ti1"
|
||||
#expect(LogSanitizer.sanitize(path) == "~/Documents/foo.ti1")
|
||||
XCTAssertEqual(LogSanitizer.sanitize(path), "~/Documents/foo.ti1")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
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,17 +1,15 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import XCTest
|
||||
@testable import ICCeryCore
|
||||
|
||||
@Suite("ProfcheckArgs")
|
||||
struct ProfcheckArgsTests {
|
||||
final class ProfcheckArgsTests: XCTestCase {
|
||||
|
||||
@Test("Hard-coded argv")
|
||||
func argv() throws {
|
||||
func testArgv() throws {
|
||||
let config = ProfcheckConfig(
|
||||
ti3URL: URL(fileURLWithPath: "/tmp/target.ti3"),
|
||||
iccURL: URL(fileURLWithPath: "/tmp/target.icc")
|
||||
)
|
||||
let args = try ProfcheckArgs.build(config: config)
|
||||
#expect(args == ["-v", "-k", "-s", "-u", "/tmp/target.ti3", "/tmp/target.icc"])
|
||||
XCTAssertEqual(args, ["-v", "-k", "-s", "-u", "/tmp/target.ti3", "/tmp/target.icc"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +1,39 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import XCTest
|
||||
@testable import ICCeryCore
|
||||
|
||||
@Suite("ProfcheckParser")
|
||||
struct ProfcheckParserTests {
|
||||
final class ProfcheckParserTests: XCTestCase {
|
||||
|
||||
@Test("Prefers JSON report with de2000 keys")
|
||||
func jsonReport() {
|
||||
func testJsonReport() {
|
||||
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)
|
||||
#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)
|
||||
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)
|
||||
}
|
||||
|
||||
@Test("Falls back to legacy text")
|
||||
func legacyText() {
|
||||
func testLegacyText() {
|
||||
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)
|
||||
#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)
|
||||
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)
|
||||
}
|
||||
|
||||
@Test("Broad regex fallback")
|
||||
func regexFallback() {
|
||||
func testRegexFallback() {
|
||||
let output = """
|
||||
No of test patches = 10
|
||||
avg = 4.25
|
||||
@@ -45,27 +41,25 @@ struct ProfcheckParserTests {
|
||||
rms = 2.30
|
||||
"""
|
||||
let report = ProfcheckParser.parse(output)
|
||||
#expect(report.isValid == true)
|
||||
#expect(report.avgDE == 4.25)
|
||||
#expect(report.maxDE == 6.10)
|
||||
#expect(report.rmsDE == 2.30)
|
||||
#expect(report.status == .poor)
|
||||
XCTAssertEqual(report.isValid, true)
|
||||
XCTAssertEqual(report.avgDE, 4.25)
|
||||
XCTAssertEqual(report.maxDE, 6.10)
|
||||
XCTAssertEqual(report.rmsDE, 2.30)
|
||||
XCTAssertEqual(report.status, .poor)
|
||||
}
|
||||
|
||||
@Test("Unparseable output warns, not zeros")
|
||||
func unparseable() {
|
||||
func testUnparseable() {
|
||||
let output = "some random text without metrics"
|
||||
let report = ProfcheckParser.parse(output)
|
||||
#expect(report.isValid == false)
|
||||
#expect(report.warning != nil)
|
||||
#expect(report.avgDE == nil)
|
||||
XCTAssertEqual(report.isValid, false)
|
||||
XCTAssertNotNil(report.warning)
|
||||
XCTAssertNil(report.avgDE)
|
||||
}
|
||||
|
||||
@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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import XCTest
|
||||
@testable import ICCeryCore
|
||||
|
||||
/// A `FileManager` subclass that reports a temporary directory as the
|
||||
@@ -18,8 +18,7 @@ private final class TestFileManager: FileManager {
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("ProfileInstaller")
|
||||
struct ProfileInstallerTests {
|
||||
final class ProfileInstallerTests: XCTestCase {
|
||||
|
||||
private func makeTempDir() throws -> URL {
|
||||
let fm = FileManager.default
|
||||
@@ -39,8 +38,7 @@ struct ProfileInstallerTests {
|
||||
return url
|
||||
}
|
||||
|
||||
@Test("Installs .icc to user ColorSync folder")
|
||||
func userInstall() throws {
|
||||
func testUserInstall() throws {
|
||||
let fm = FileManager.default
|
||||
let tmp = try makeTempDir()
|
||||
let testFM = TestFileManager(home: tmp)
|
||||
@@ -51,15 +49,14 @@ struct ProfileInstallerTests {
|
||||
fileManager: testFM
|
||||
)
|
||||
|
||||
#expect(result.registered)
|
||||
#expect(!result.overwritten)
|
||||
#expect(!result.renamed)
|
||||
#expect(result.destPath.hasSuffix("test.icc"))
|
||||
#expect(fm.fileExists(atPath: result.destPath))
|
||||
XCTAssertTrue(result.registered)
|
||||
XCTAssertFalse(result.overwritten)
|
||||
XCTAssertFalse(result.renamed)
|
||||
XCTAssertTrue(result.destPath.hasSuffix("test.icc"))
|
||||
XCTAssertTrue(fm.fileExists(atPath: result.destPath))
|
||||
}
|
||||
|
||||
@Test("Overwrite succeeds and replaces the existing file")
|
||||
func overwriteSucceeds() throws {
|
||||
func testOverwriteSucceeds() throws {
|
||||
let fm = FileManager.default
|
||||
let tmp = try makeTempDir()
|
||||
let testFM = TestFileManager(home: tmp)
|
||||
@@ -70,7 +67,7 @@ struct ProfileInstallerTests {
|
||||
config: InstallProfileConfig(sourceURL: source),
|
||||
fileManager: testFM
|
||||
)
|
||||
#expect(!first.overwritten)
|
||||
XCTAssertFalse(first.overwritten)
|
||||
|
||||
// Change the source contents.
|
||||
let newBytes: [UInt8] = (0..<256).map { UInt8(($0 + 100) % 256) }
|
||||
@@ -87,15 +84,14 @@ struct ProfileInstallerTests {
|
||||
fileManager: testFM
|
||||
)
|
||||
|
||||
#expect(second.overwritten)
|
||||
#expect(!second.renamed)
|
||||
#expect(fm.fileExists(atPath: second.destPath))
|
||||
XCTAssertTrue(second.overwritten)
|
||||
XCTAssertFalse(second.renamed)
|
||||
XCTAssertTrue(fm.fileExists(atPath: second.destPath))
|
||||
let installed = try Data(contentsOf: URL(fileURLWithPath: second.destPath))
|
||||
#expect(Array(installed) == newBytes)
|
||||
XCTAssertEqual(Array(installed), newBytes)
|
||||
}
|
||||
|
||||
@Test("Preserves .icm source extension")
|
||||
func preservesIcmExtension() throws {
|
||||
func testPreservesIcmExtension() throws {
|
||||
let tmp = try makeTempDir()
|
||||
let testFM = TestFileManager(home: tmp)
|
||||
let source = try makeSource(at: tmp, name: "m5_profile.icm")
|
||||
@@ -105,12 +101,11 @@ struct ProfileInstallerTests {
|
||||
fileManager: testFM
|
||||
)
|
||||
|
||||
#expect(URL(fileURLWithPath: result.destPath).pathExtension == "icm")
|
||||
#expect(result.destPath.hasSuffix("m5_profile.icm"))
|
||||
XCTAssertEqual(URL(fileURLWithPath: result.destPath).pathExtension, "icm")
|
||||
XCTAssertTrue(result.destPath.hasSuffix("m5_profile.icm"))
|
||||
}
|
||||
|
||||
@Test("Rejects parent traversal in source path")
|
||||
func rejectsParentTraversal() throws {
|
||||
func testRejectsParentTraversal() throws {
|
||||
let fm = FileManager.default
|
||||
let tmp = try makeTempDir()
|
||||
|
||||
@@ -125,20 +120,19 @@ struct ProfileInstallerTests {
|
||||
let sourceURL = tmp
|
||||
.appendingPathComponent("..")
|
||||
.appendingPathComponent(naughtyName)
|
||||
#expect(fm.fileExists(atPath: sourceURL.path))
|
||||
XCTAssertTrue(fm.fileExists(atPath: sourceURL.path))
|
||||
|
||||
do {
|
||||
_ = try ProfileInstaller.install(config: InstallProfileConfig(sourceURL: sourceURL))
|
||||
Issue.record("Expected unsafeStem error")
|
||||
XCTFail("Expected unsafeStem error")
|
||||
} catch let error as ProfileInstallError {
|
||||
if case .unsafeStem = error { } else { Issue.record("Expected unsafeStem, got \(error)") }
|
||||
if case .unsafeStem = error { } else { XCTFail("Expected unsafeStem, got \(error)") }
|
||||
} catch {
|
||||
Issue.record("Unexpected error type: \(error)")
|
||||
XCTFail("Unexpected error type: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Allows stems with consecutive dots like foo..bar")
|
||||
func allowsDoubleDotStem() throws {
|
||||
func testAllowsDoubleDotStem() throws {
|
||||
let fm = FileManager.default
|
||||
let tmp = try makeTempDir()
|
||||
let testFM = TestFileManager(home: tmp)
|
||||
@@ -149,23 +143,22 @@ struct ProfileInstallerTests {
|
||||
fileManager: testFM
|
||||
)
|
||||
|
||||
#expect(result.destPath.hasSuffix("foo..bar.icc"))
|
||||
#expect(fm.fileExists(atPath: result.destPath))
|
||||
XCTAssertTrue(result.destPath.hasSuffix("foo..bar.icc"))
|
||||
XCTAssertTrue(fm.fileExists(atPath: result.destPath))
|
||||
}
|
||||
|
||||
@Test("Rejects source files that are too small")
|
||||
func rejectsSmallSource() throws {
|
||||
func testRejectsSmallSource() 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))
|
||||
Issue.record("Expected sourceTooSmall error")
|
||||
XCTFail("Expected sourceTooSmall error")
|
||||
} catch let error as ProfileInstallError {
|
||||
if case .sourceTooSmall = error { } else { Issue.record("Expected sourceTooSmall, got \(error)") }
|
||||
if case .sourceTooSmall = error { } else { XCTFail("Expected sourceTooSmall, got \(error)") }
|
||||
} catch {
|
||||
Issue.record("Unexpected error type: \(error)")
|
||||
XCTFail("Unexpected error type: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import ICCeryCore
|
||||
|
||||
private func tempStoreURL() -> URL {
|
||||
@@ -8,92 +8,112 @@ private func tempStoreURL() -> URL {
|
||||
.appendingPathComponent("settings.json")
|
||||
}
|
||||
|
||||
@Suite("AppSettings")
|
||||
struct AppSettingsTests {
|
||||
@Test func defaults() {
|
||||
final class AppSettingsTests: XCTestCase {
|
||||
func testDefaults() {
|
||||
let s = AppSettings.default
|
||||
#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)
|
||||
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)
|
||||
}
|
||||
|
||||
@Test func negativeThresholds() {
|
||||
func testNegativeThresholds() {
|
||||
var s = AppSettings.default
|
||||
s.deltaEGoodMax = -1
|
||||
#expect(s.validate() == [AppSettings.errorNegativeDeltaE])
|
||||
XCTAssertEqual(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
|
||||
#expect(s.validate() == [
|
||||
XCTAssertTrue(s.validate() == [
|
||||
AppSettings.errorNegativeDeltaE,
|
||||
AppSettings.errorThresholdOrder,
|
||||
])
|
||||
}
|
||||
|
||||
@Test func goodMustBeStrictlyLessThanWarning() {
|
||||
func testGoodMustBeStrictlyLessThanWarning() {
|
||||
var s = AppSettings.default
|
||||
s.deltaEGoodMax = 5.0
|
||||
#expect(s.validate() == [AppSettings.errorThresholdOrder])
|
||||
XCTAssertEqual(s.validate(), [AppSettings.errorThresholdOrder])
|
||||
s.deltaEGoodMax = 6.0
|
||||
#expect(s.validate() == [AppSettings.errorThresholdOrder])
|
||||
XCTAssertEqual(s.validate(), [AppSettings.errorThresholdOrder])
|
||||
s.deltaEGoodMax = 4.9
|
||||
#expect(s.isValid)
|
||||
XCTAssertTrue(s.isValid)
|
||||
}
|
||||
|
||||
@Test func snakeCaseKeys() throws {
|
||||
func testSnakeCaseKeys() throws {
|
||||
let s = AppSettings.default
|
||||
let data = try JSONEncoder().encode(s)
|
||||
let json = String(data: data, encoding: .utf8)!
|
||||
#expect(json.contains("\"delta_e_good_max\""))
|
||||
#expect(json.contains("\"default_install_location\""))
|
||||
#expect(json.contains("\"enable_i1pro2_leds\""))
|
||||
XCTAssertTrue(json.contains("\"delta_e_good_max\""))
|
||||
XCTAssertTrue(json.contains("\"default_install_location\""))
|
||||
XCTAssertTrue(json.contains("\"enable_i1pro2_leds\""))
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("SettingsStore")
|
||||
struct SettingsStoreTests {
|
||||
@Test func roundTrip() throws {
|
||||
final class SettingsStoreTests: XCTestCase {
|
||||
func testRoundTrip() throws {
|
||||
let url = tempStoreURL()
|
||||
let store = SettingsStore(fileURL: url)
|
||||
var s = AppSettings.default
|
||||
s.deltaEGoodMax = 1.5
|
||||
s.defaultInstrument = "p3"
|
||||
try store.save(s)
|
||||
#expect(store.load() == s)
|
||||
XCTAssertEqual(store.load(), s)
|
||||
}
|
||||
|
||||
@Test func corruptJsonFallsBackToDefaults() throws {
|
||||
func testCorruptJsonFallsBackToDefaults() throws {
|
||||
let url = tempStoreURL()
|
||||
try FileManager.default.createDirectory(
|
||||
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||
)
|
||||
try "{ not json".write(to: url, atomically: true, encoding: .utf8)
|
||||
#expect(SettingsStore(fileURL: url).load() == .default)
|
||||
XCTAssertEqual(SettingsStore(fileURL: url).load(), .default)
|
||||
}
|
||||
|
||||
@Test func missingFileReturnsDefaults() {
|
||||
#expect(SettingsStore(fileURL: tempStoreURL()).load() == .default)
|
||||
func testMissingFileReturnsDefaults() {
|
||||
XCTAssertEqual(SettingsStore(fileURL: tempStoreURL()).load(), .default)
|
||||
}
|
||||
|
||||
@Test func invalidSettingsNotPersisted() throws {
|
||||
func testInvalidSettingsNotPersisted() throws {
|
||||
let url = tempStoreURL()
|
||||
let store = SettingsStore(fileURL: url)
|
||||
var s = AppSettings.default
|
||||
s.deltaEGoodMax = 9.0 // >= warning 5.0
|
||||
#expect(throws: SettingsStore.SettingsError.self) { try store.save(s) }
|
||||
#expect(!FileManager.default.fileExists(atPath: url.path))
|
||||
XCTAssertThrowsError(try store.save(s)) { error in XCTAssertTrue(error is SettingsStore.SettingsError) }
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: url.path))
|
||||
}
|
||||
|
||||
@Test func savePostsNotification() async throws {
|
||||
func testInvalidSaveOverValidFilePreservesBytesAndPostsNothing() throws {
|
||||
let url = tempStoreURL()
|
||||
let store = SettingsStore(fileURL: url)
|
||||
var valid = AppSettings.default
|
||||
valid.deltaEGoodMax = 1.5
|
||||
try store.save(valid)
|
||||
let originalBytes = try Data(contentsOf: url)
|
||||
|
||||
var fired = false
|
||||
let token = NotificationCenter.default.addObserver(
|
||||
forName: SettingsStore.settingsDidChange, object: nil, queue: nil
|
||||
) { _ in fired = true }
|
||||
defer { NotificationCenter.default.removeObserver(token) }
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func testSavePostsNotification() async throws {
|
||||
let url = tempStoreURL()
|
||||
let store = SettingsStore(fileURL: url)
|
||||
var fired = false
|
||||
@@ -102,12 +122,11 @@ struct SettingsStoreTests {
|
||||
) { _ in fired = true }
|
||||
defer { NotificationCenter.default.removeObserver(token) }
|
||||
try store.save(.default)
|
||||
#expect(fired)
|
||||
XCTAssertTrue(fired)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("LogSink")
|
||||
struct LogSinkTests {
|
||||
final class LogSinkTests: XCTestCase {
|
||||
private func tempLog() -> (URL, LogSink) {
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("iccery-log-\(UUID().uuidString)")
|
||||
@@ -115,26 +134,26 @@ struct LogSinkTests {
|
||||
return (url, LogSink(fileURL: url))
|
||||
}
|
||||
|
||||
@Test func writesFormattedLines() {
|
||||
func testWritesFormattedLines() {
|
||||
let (url, sink) = tempLog()
|
||||
sink.setLevel(.debug)
|
||||
sink.write(level: .info, category: "test", message: "hello")
|
||||
let content = (try? String(contentsOf: url, encoding: .utf8)) ?? ""
|
||||
#expect(content.contains("[INFO] test: hello"))
|
||||
XCTAssertTrue(content.contains("[INFO] test: hello"))
|
||||
}
|
||||
|
||||
@Test func levelFilteringIsLive() {
|
||||
func testLevelFilteringIsLive() {
|
||||
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)) ?? ""
|
||||
#expect(!content.contains("hidden"))
|
||||
#expect(content.contains("shown"))
|
||||
XCTAssertFalse(content.contains("hidden"))
|
||||
XCTAssertTrue(content.contains("shown"))
|
||||
}
|
||||
|
||||
@Test func rotatesAt5MiBKeeping5Segments() throws {
|
||||
func testRotatesAt5MiBKeeping5Segments() throws {
|
||||
let (url, sink) = tempLog()
|
||||
sink.setLevel(.trace)
|
||||
// Pre-fill the active log just under the cap, then cross it.
|
||||
@@ -145,20 +164,20 @@ struct LogSinkTests {
|
||||
try big.write(to: url, atomically: true, encoding: .utf8)
|
||||
|
||||
sink.write(level: .info, category: "t", message: "trigger rotation")
|
||||
#expect(FileManager.default.fileExists(
|
||||
XCTAssertTrue(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
|
||||
#expect((size ?? 0) < 1024)
|
||||
XCTAssertTrue((size ?? 0) < 1024)
|
||||
}
|
||||
|
||||
@Test func tailExcerptCaps() throws {
|
||||
func testTailExcerptCaps() throws {
|
||||
let (url, sink) = tempLog()
|
||||
sink.setLevel(.debug)
|
||||
sink.write(level: .info, category: "t", message: "line")
|
||||
#expect(sink.tailExcerpt(maxBytes: 8).count <= 8)
|
||||
XCTAssertTrue(sink.tailExcerpt(maxBytes: 8).count <= 8)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import Testing
|
||||
import XCTest
|
||||
import Foundation
|
||||
@testable import ICCeryCore
|
||||
|
||||
@Suite("TargenArgs")
|
||||
struct TargenArgsTests {
|
||||
final class TargenArgsTests: XCTestCase {
|
||||
|
||||
@Test("RGB baseline: -v -d 2 -f 800 -e 4 -B 4")
|
||||
func rgbBaseline() throws {
|
||||
func testRgbBaseline() throws {
|
||||
let config = TargenConfig(
|
||||
colourSpace: .rgb,
|
||||
patchCount: 800,
|
||||
@@ -15,12 +13,11 @@ struct TargenArgsTests {
|
||||
basename: "test_rgb"
|
||||
)
|
||||
let args = try TargenArgs.build(config: config)
|
||||
#expect(args == ["-v", "-d", "2", "-f", "800", "-e", "4", "-B", "4", "test_rgb"])
|
||||
#expect(!args.contains("-u"))
|
||||
XCTAssertEqual(args, ["-v", "-d", "2", "-f", "800", "-e", "4", "-B", "4", "test_rgb"])
|
||||
XCTAssertFalse(args.contains("-u"))
|
||||
}
|
||||
|
||||
@Test("CMYK baseline: -v -d 4 -f 1500 -e 4 -B 0")
|
||||
func cmykBaseline() throws {
|
||||
func testCmykBaseline() throws {
|
||||
let config = TargenConfig(
|
||||
colourSpace: .cmyk,
|
||||
patchCount: 1500,
|
||||
@@ -29,11 +26,10 @@ struct TargenArgsTests {
|
||||
basename: "test_cmyk"
|
||||
)
|
||||
let args = try TargenArgs.build(config: config)
|
||||
#expect(args == ["-v", "-d", "4", "-f", "1500", "-e", "4", "-B", "0", "test_cmyk"])
|
||||
XCTAssertEqual(args, ["-v", "-d", "4", "-f", "1500", "-e", "4", "-B", "0", "test_cmyk"])
|
||||
}
|
||||
|
||||
@Test("Custom patch count honours -f (#44)")
|
||||
func customPatchCount() throws {
|
||||
func testCustomPatchCount() throws {
|
||||
let config = TargenConfig(
|
||||
colourSpace: .rgb,
|
||||
patchCount: 2500,
|
||||
@@ -42,12 +38,11 @@ struct TargenArgsTests {
|
||||
basename: "custom_patches"
|
||||
)
|
||||
let args = try TargenArgs.build(config: config)
|
||||
#expect(args.contains("-f"))
|
||||
#expect(args[args.firstIndex(of: "-f")! + 1] == "2500")
|
||||
XCTAssertTrue(args.contains("-f"))
|
||||
XCTAssertEqual(args[args.firstIndex(of: "-f")! + 1], "2500")
|
||||
}
|
||||
|
||||
@Test("All advanced flags in stable order")
|
||||
func allAdvancedFlags() throws {
|
||||
func testAllAdvancedFlags() throws {
|
||||
let config = TargenConfig(
|
||||
colourSpace: .cmyk,
|
||||
patchCount: 1200,
|
||||
@@ -85,11 +80,10 @@ struct TargenArgsTests {
|
||||
"-p", "2.00",
|
||||
"advanced_cmyk"
|
||||
]
|
||||
#expect(args == expected)
|
||||
XCTAssertEqual(args, expected)
|
||||
}
|
||||
|
||||
@Test("RGB ignores total ink limit")
|
||||
func rgbIgnoresInkLimit() throws {
|
||||
func testRgbIgnoresInkLimit() throws {
|
||||
let config = TargenConfig(
|
||||
colourSpace: .rgb,
|
||||
patchCount: 800,
|
||||
@@ -99,11 +93,10 @@ struct TargenArgsTests {
|
||||
basename: "rgb_no_ink"
|
||||
)
|
||||
let args = try TargenArgs.build(config: config)
|
||||
#expect(!args.contains("-l"))
|
||||
XCTAssertFalse(args.contains("-l"))
|
||||
}
|
||||
|
||||
@Test("Neutral concentration omitted when approximately 0.50")
|
||||
func neutralConcentrationOmittedWhenDefault() throws {
|
||||
func testNeutralConcentrationOmittedWhenDefault() throws {
|
||||
let config = TargenConfig(
|
||||
colourSpace: .rgb,
|
||||
patchCount: 800,
|
||||
@@ -113,11 +106,10 @@ struct TargenArgsTests {
|
||||
basename: "n_default"
|
||||
)
|
||||
let args = try TargenArgs.build(config: config)
|
||||
#expect(!args.contains("-N"))
|
||||
XCTAssertFalse(args.contains("-N"))
|
||||
}
|
||||
|
||||
@Test("Adaptation emitted even at 0.10 (no default-skip)")
|
||||
func adaptationEmittedAtPointOne() throws {
|
||||
func testAdaptationEmittedAtPointOne() throws {
|
||||
let config = TargenConfig(
|
||||
colourSpace: .rgb,
|
||||
patchCount: 800,
|
||||
@@ -127,12 +119,11 @@ struct TargenArgsTests {
|
||||
basename: "a_flag"
|
||||
)
|
||||
let args = try TargenArgs.build(config: config)
|
||||
#expect(args.contains("-A"))
|
||||
#expect(args[args.firstIndex(of: "-A")! + 1] == "0.10")
|
||||
XCTAssertTrue(args.contains("-A"))
|
||||
XCTAssertEqual(args[args.firstIndex(of: "-A")! + 1], "0.10")
|
||||
}
|
||||
|
||||
@Test("OFPS full spread algorithm emits no flag")
|
||||
func ofpsEmitsNoFlag() throws {
|
||||
func testOfpsEmitsNoFlag() throws {
|
||||
let config = TargenConfig(
|
||||
colourSpace: .rgb,
|
||||
patchCount: 800,
|
||||
@@ -142,12 +133,11 @@ struct TargenArgsTests {
|
||||
basename: "ofps_test"
|
||||
)
|
||||
let args = try TargenArgs.build(config: config)
|
||||
#expect(!args.contains("ofps"))
|
||||
#expect(!args.contains("-t"))
|
||||
XCTAssertFalse(args.contains("ofps"))
|
||||
XCTAssertFalse(args.contains("-t"))
|
||||
}
|
||||
|
||||
@Test("Dark emphasis and device power omitted when 1.0")
|
||||
func darkEmphasisAndPowerOmittedWhenOne() throws {
|
||||
func testDarkEmphasisAndPowerOmittedWhenOne() throws {
|
||||
let config = TargenConfig(
|
||||
colourSpace: .rgb,
|
||||
patchCount: 800,
|
||||
@@ -158,12 +148,37 @@ struct TargenArgsTests {
|
||||
basename: "defaults_omitted"
|
||||
)
|
||||
let args = try TargenArgs.build(config: config)
|
||||
#expect(!args.contains("-V"))
|
||||
#expect(!args.contains("-p"))
|
||||
XCTAssertFalse(args.contains("-V"))
|
||||
XCTAssertFalse(args.contains("-p"))
|
||||
}
|
||||
|
||||
@Test("Invalid basename throws")
|
||||
func invalidBasenameThrows() {
|
||||
func testWhitespacePreconditioner() throws {
|
||||
let config = TargenConfig(
|
||||
colourSpace: .rgb,
|
||||
patchCount: 800,
|
||||
whitePatches: 4,
|
||||
blackPatches: 4,
|
||||
preconditioningProfile: " \n\t ",
|
||||
basename: "ws_pre"
|
||||
)
|
||||
let args = try TargenArgs.build(config: config)
|
||||
XCTAssertFalse(args.contains("-c"))
|
||||
}
|
||||
|
||||
func testPreconditionerTrimmed() throws {
|
||||
let config = TargenConfig(
|
||||
colourSpace: .rgb,
|
||||
patchCount: 800,
|
||||
whitePatches: 4,
|
||||
blackPatches: 4,
|
||||
preconditioningProfile: " /path/to/profile.icc ",
|
||||
basename: "trim_pre"
|
||||
)
|
||||
let args = try TargenArgs.build(config: config)
|
||||
XCTAssertEqual(args[args.firstIndex(of: "-c")! + 1], "/path/to/profile.icc")
|
||||
}
|
||||
|
||||
func testInvalidBasenameThrows() {
|
||||
let config = TargenConfig(
|
||||
colourSpace: .rgb,
|
||||
patchCount: 800,
|
||||
@@ -171,13 +186,12 @@ struct TargenArgsTests {
|
||||
blackPatches: 4,
|
||||
basename: "../bad_name"
|
||||
)
|
||||
#expect(throws: PathSecurity.Error.self) {
|
||||
try TargenArgs.build(config: config)
|
||||
XCTAssertThrowsError(try TargenArgs.build(config: config)) { error in
|
||||
XCTAssertTrue(error is PathSecurity.Error)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Invalid patch count throws")
|
||||
func invalidPatchCountThrows() {
|
||||
func testInvalidPatchCountThrows() {
|
||||
let config = TargenConfig(
|
||||
colourSpace: .rgb,
|
||||
patchCount: 0,
|
||||
@@ -185,13 +199,12 @@ struct TargenArgsTests {
|
||||
blackPatches: 4,
|
||||
basename: "bad_count"
|
||||
)
|
||||
#expect(throws: TargenArgError.self) {
|
||||
try TargenArgs.build(config: config)
|
||||
XCTAssertThrowsError(try TargenArgs.build(config: config)) { error in
|
||||
XCTAssertTrue(error is TargenArgError)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Invalid ink limit throws for CMYK")
|
||||
func invalidInkLimitThrows() {
|
||||
func testInvalidInkLimitThrows() {
|
||||
let config = TargenConfig(
|
||||
colourSpace: .cmyk,
|
||||
patchCount: 800,
|
||||
@@ -200,17 +213,15 @@ struct TargenArgsTests {
|
||||
totalInkLimit: 450,
|
||||
basename: "bad_ink"
|
||||
)
|
||||
#expect(throws: TargenArgError.self) {
|
||||
try TargenArgs.build(config: config)
|
||||
XCTAssertThrowsError(try TargenArgs.build(config: config)) { error in
|
||||
XCTAssertTrue(error is TargenArgError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("ArgyllRunner Targen")
|
||||
struct ArgyllRunnerTargenTests {
|
||||
final class ArgyllRunnerTargenTests: XCTestCase {
|
||||
|
||||
@Test("Successful targen execution creates .ti1 and returns URL")
|
||||
func successfulTargenExecution() async throws {
|
||||
func testSuccessfulTargenExecution() async throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
@@ -256,14 +267,13 @@ struct ArgyllRunnerTargenTests {
|
||||
box.append(batch)
|
||||
}
|
||||
logLines = box.lines
|
||||
#expect(logLines.contains("Generating patches..."))
|
||||
XCTAssertTrue(logLines.contains("Generating patches..."))
|
||||
|
||||
#expect(FileManager.default.fileExists(atPath: ti1URL.path))
|
||||
#expect(ti1URL.lastPathComponent == "mock_test.ti1")
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: ti1URL.path))
|
||||
XCTAssertEqual(ti1URL.lastPathComponent, "mock_test.ti1")
|
||||
}
|
||||
|
||||
@Test("Failed targen execution throws processFailed")
|
||||
func failedTargenExecution() async throws {
|
||||
func testFailedTargenExecution() async throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
@@ -290,13 +300,15 @@ struct ArgyllRunnerTargenTests {
|
||||
workingDirectory: tempDir
|
||||
)
|
||||
|
||||
await #expect(throws: ArgyllRunnerError.self) {
|
||||
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
|
||||
try await runner.runTargen(config: config)
|
||||
} errorHandler: { error in
|
||||
XCTAssertEqual(error, .toolFailed(
|
||||
tool: "targen", code: 1, logs: ["Error: something went wrong"]))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Targen exit 0 without .ti1 throws missingArtefact")
|
||||
func missingArtefactThrows() async throws {
|
||||
func testMissingArtefactThrows() async throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
@@ -323,8 +335,11 @@ struct ArgyllRunnerTargenTests {
|
||||
workingDirectory: tempDir
|
||||
)
|
||||
|
||||
await #expect(throws: ArgyllRunnerError.self) {
|
||||
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
|
||||
try await runner.runTargen(config: config)
|
||||
} errorHandler: { error in
|
||||
XCTAssertEqual(error, .missingArtefact(
|
||||
tempDir.appendingPathComponent("no_file.ti1").path))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import ICCeryCore
|
||||
@testable import ICCery
|
||||
|
||||
/// Dataset-import error contracts through the
|
||||
/// `importMeasurementDataset(from:)` seam (issue #80): parser and I/O
|
||||
/// failures must surface identically as a single `.error` Notice.
|
||||
@MainActor
|
||||
final class TargetWorkflowViewModelTests: XCTestCase {
|
||||
|
||||
func testMalformedDatasetNotice() throws {
|
||||
let env = try TestAppEnvironment.make()
|
||||
defer { env.cleanup() }
|
||||
let vm = TargetWorkflowViewModel(environment: env.environment)
|
||||
|
||||
let bad = env.root.appendingPathComponent("broken.ti3")
|
||||
try Data("this is not CGATS data".utf8).write(to: bad)
|
||||
|
||||
vm.importMeasurementDataset(from: bad)
|
||||
|
||||
let notice = try XCTUnwrap(vm.wizard.notice)
|
||||
XCTAssertEqual(notice.kind, .error)
|
||||
XCTAssertTrue(notice.text.hasPrefix("Import failed:"))
|
||||
}
|
||||
|
||||
func testMissingDatasetNotice() throws {
|
||||
let env = try TestAppEnvironment.make()
|
||||
defer { env.cleanup() }
|
||||
let vm = TargetWorkflowViewModel(environment: env.environment)
|
||||
|
||||
let missing = env.root.appendingPathComponent("does-not-exist.ti3")
|
||||
vm.importMeasurementDataset(from: missing)
|
||||
|
||||
let notice = try XCTUnwrap(vm.wizard.notice)
|
||||
XCTAssertEqual(notice.kind, .error)
|
||||
XCTAssertTrue(notice.text.hasPrefix("Import failed:"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import Foundation
|
||||
@testable import ICCeryCore
|
||||
@testable import ICCery
|
||||
|
||||
/// Shared app-test dependency factory (issue #82).
|
||||
///
|
||||
/// Every store is pointed at a unique temporary directory so tests never
|
||||
/// read or write the user's real Application Support tree, and a fresh
|
||||
/// `ProcessManager` keeps child-process state isolated per test. The
|
||||
/// global process environment is never mutated.
|
||||
struct TestAppEnvironment {
|
||||
|
||||
/// Root temp directory holding all per-test state files.
|
||||
let root: URL
|
||||
let environment: AppEnvironment
|
||||
|
||||
var settingsURL: URL { root.appendingPathComponent("settings.json") }
|
||||
var stateURL: URL { root.appendingPathComponent("wizard_state.json") }
|
||||
var historyURL: URL {
|
||||
root.appendingPathComponent("verification_history.json")
|
||||
}
|
||||
|
||||
/// Creates an isolated environment under `NSTemporaryDirectory()`.
|
||||
/// Call `cleanup()` when finished.
|
||||
static func make() throws -> TestAppEnvironment {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("iccery-test-env-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(
|
||||
at: root, withIntermediateDirectories: true
|
||||
)
|
||||
|
||||
let processManager = ProcessManager()
|
||||
let settingsStore = SettingsStore(
|
||||
fileURL: root.appendingPathComponent("settings.json")
|
||||
)
|
||||
let environment = AppEnvironment(
|
||||
stateStore: WizardStateStore(
|
||||
fileURL: root.appendingPathComponent("wizard_state.json")
|
||||
),
|
||||
settingsStore: settingsStore,
|
||||
presetStore: PresetStore(settingsStore: settingsStore),
|
||||
runner: ArgyllRunner(
|
||||
processManager: processManager,
|
||||
binaryResolver: BinaryResolver(overrideDir: nil)
|
||||
),
|
||||
cupsService: CupsService(
|
||||
processManager: processManager,
|
||||
binaryDir: root.appendingPathComponent("cups-bin")
|
||||
),
|
||||
historyStore: VerificationHistoryStore(
|
||||
url: root.appendingPathComponent("verification_history.json")
|
||||
)
|
||||
)
|
||||
return TestAppEnvironment(root: root, environment: environment)
|
||||
}
|
||||
|
||||
/// Removes the temporary root directory.
|
||||
func cleanup() {
|
||||
try? FileManager.default.removeItem(at: root)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import XCTest
|
||||
@testable import ICCeryCore
|
||||
|
||||
@Suite("VerificationHistoryStore")
|
||||
struct VerificationHistoryStoreTests {
|
||||
final class VerificationHistoryStoreTests: XCTestCase {
|
||||
|
||||
@Test("Append and cap")
|
||||
func appendAndCap() async throws {
|
||||
func testAppendAndCap() async throws {
|
||||
let fm = FileManager.default
|
||||
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
|
||||
@@ -29,12 +27,11 @@ struct VerificationHistoryStoreTests {
|
||||
}
|
||||
|
||||
let all = await store.all()
|
||||
#expect(all.count == 3)
|
||||
#expect(all.first?.avgDE == 2.0)
|
||||
XCTAssertEqual(all.count, 3)
|
||||
XCTAssertEqual(all.first?.avgDE, 2.0)
|
||||
}
|
||||
|
||||
@Test("Parse failure preserves file")
|
||||
func parseFailurePreservesFile() async {
|
||||
func testParseFailurePreservesFile() async {
|
||||
let fm = FileManager.default
|
||||
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try? fm.createDirectory(at: tmp, withIntermediateDirectories: true)
|
||||
@@ -45,14 +42,13 @@ struct VerificationHistoryStoreTests {
|
||||
let store = VerificationHistoryStore(url: url)
|
||||
do {
|
||||
_ = try await store.load()
|
||||
Issue.record("load() should throw on invalid JSON")
|
||||
XCTFail("load() should throw on invalid JSON")
|
||||
} catch {
|
||||
#expect(fm.fileExists(atPath: url.path))
|
||||
XCTAssertTrue(fm.fileExists(atPath: url.path))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Append loads existing records first")
|
||||
func appendLoadsExisting() async throws {
|
||||
func testAppendLoadsExisting() async throws {
|
||||
let fm = FileManager.default
|
||||
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
|
||||
@@ -89,13 +85,12 @@ struct VerificationHistoryStoreTests {
|
||||
_ = try await store2.append(new)
|
||||
|
||||
let all = await store2.all()
|
||||
#expect(all.count == 2)
|
||||
#expect(all.contains { $0.id == "vr-existing" })
|
||||
#expect(all.contains { $0.id == "vr-new" })
|
||||
XCTAssertEqual(all.count, 2)
|
||||
XCTAssertTrue(all.contains { $0.id == "vr-existing" })
|
||||
XCTAssertTrue(all.contains { $0.id == "vr-new" })
|
||||
}
|
||||
|
||||
@Test("Append does not overwrite an unparseable file")
|
||||
func appendPreservesUnparseableFile() async {
|
||||
func testAppendPreservesUnparseableFile() async {
|
||||
let fm = FileManager.default
|
||||
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try? fm.createDirectory(at: tmp, withIntermediateDirectories: true)
|
||||
@@ -119,20 +114,68 @@ struct VerificationHistoryStoreTests {
|
||||
|
||||
do {
|
||||
_ = try await store.append(record)
|
||||
Issue.record("append() should propagate the load error")
|
||||
XCTFail("append() should propagate the load error")
|
||||
} catch {
|
||||
#expect(fm.fileExists(atPath: url.path))
|
||||
XCTAssertTrue(fm.fileExists(atPath: url.path))
|
||||
if let data = try? Data(contentsOf: url),
|
||||
let contents = String(data: data, encoding: .utf8) {
|
||||
#expect(contents == badJSON)
|
||||
XCTAssertEqual(contents, badJSON)
|
||||
} else {
|
||||
Issue.record("Could not read preserved file")
|
||||
XCTFail("Could not read preserved file")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test("CSV export quoting")
|
||||
func csvQuoting() async throws {
|
||||
func testClearPreservesUnparseableFile() async {
|
||||
let fm = FileManager.default
|
||||
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try? fm.createDirectory(at: tmp, withIntermediateDirectories: true)
|
||||
let url = tmp.appendingPathComponent("verification_history.json")
|
||||
|
||||
let badJSON = "not json"
|
||||
try? badJSON.write(to: url, atomically: true, encoding: .utf8)
|
||||
|
||||
let store = VerificationHistoryStore(url: url)
|
||||
do {
|
||||
try await store.clear()
|
||||
XCTFail("clear() should propagate the load error")
|
||||
} catch {
|
||||
let contents = try? String(contentsOf: url, encoding: .utf8)
|
||||
XCTAssertEqual(contents, badJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func testIso8601RoundTrip() async throws {
|
||||
let fm = FileManager.default
|
||||
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
|
||||
let url = tmp.appendingPathComponent("verification_history.json")
|
||||
|
||||
let timestamp = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
let record = VerificationRecord(
|
||||
id: "vr-iso",
|
||||
profileName: "p",
|
||||
printerName: "",
|
||||
avgDE: 1.0,
|
||||
maxDE: 2.0,
|
||||
rmsDE: 1.5,
|
||||
patchCount: 1,
|
||||
status: .good,
|
||||
timestamp: timestamp
|
||||
)
|
||||
let store1 = VerificationHistoryStore(url: url)
|
||||
_ = try await store1.append(record)
|
||||
|
||||
let text = try String(contentsOf: url, encoding: .utf8)
|
||||
XCTAssertTrue(text.contains(ISO8601DateFormatter().string(from: timestamp)))
|
||||
|
||||
let store2 = VerificationHistoryStore(url: url)
|
||||
let loaded = try await store2.load()
|
||||
XCTAssertEqual(loaded.count, 1)
|
||||
XCTAssertEqual(loaded.first?.timestamp, timestamp)
|
||||
}
|
||||
|
||||
func testCsvQuoting() async throws {
|
||||
let fm = FileManager.default
|
||||
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
|
||||
@@ -153,7 +196,7 @@ struct VerificationHistoryStoreTests {
|
||||
_ = try await store.append(record)
|
||||
|
||||
let csv = await store.exportCSV()
|
||||
#expect(csv.contains("\"a,b\""))
|
||||
#expect(csv.contains("\"\"quoted\"\""))
|
||||
XCTAssertTrue(csv.contains("\"a,b\""))
|
||||
XCTAssertTrue(csv.contains("\"\"quoted\"\""))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import ICCeryCore
|
||||
@testable import ICCery
|
||||
|
||||
/// Issue #29 — `CAL_` basename must be restored on relaunch and on any
|
||||
/// attempt to navigate to a non-calibration stage that would use it.
|
||||
@Suite("WizardCalibrationSession")
|
||||
@MainActor
|
||||
struct WizardCalibrationSessionTests {
|
||||
final class WizardCalibrationSessionTests: XCTestCase {
|
||||
|
||||
private func tempURL() -> URL {
|
||||
FileManager.default.temporaryDirectory
|
||||
@@ -24,8 +23,7 @@ struct WizardCalibrationSessionTests {
|
||||
return url
|
||||
}
|
||||
|
||||
@Test("Persist and restore calibrationOriginalBasename across a relaunch")
|
||||
func relaunchRestoresOriginal() throws {
|
||||
func testRelaunchRestoresOriginal() throws {
|
||||
let url = tempURL()
|
||||
let store = WizardStateStore(fileURL: url)
|
||||
var saved = WizardState(
|
||||
@@ -39,14 +37,13 @@ struct WizardCalibrationSessionTests {
|
||||
|
||||
let model = WizardViewModel(stateStore: store)
|
||||
|
||||
#expect(model.basename == "DemoTarget")
|
||||
#expect(model.calibrationOriginalBasename == "")
|
||||
#expect(model.sessionMode == .profile)
|
||||
#expect(model.stage == .generate)
|
||||
XCTAssertEqual(model.basename, "DemoTarget")
|
||||
XCTAssertEqual(model.calibrationOriginalBasename, "")
|
||||
XCTAssertEqual(model.sessionMode, .profile)
|
||||
XCTAssertEqual(model.stage, .generate)
|
||||
}
|
||||
|
||||
@Test("go(to: .buildProfile) while basename is CAL_ refuses and restores the original")
|
||||
func goToBuildProfileRefusesAndRestores() throws {
|
||||
func testGoToBuildProfileRefusesAndRestores() throws {
|
||||
let dir = try tempDir()
|
||||
let url = tempURL()
|
||||
let store = WizardStateStore(fileURL: url)
|
||||
@@ -60,14 +57,13 @@ struct WizardCalibrationSessionTests {
|
||||
|
||||
model.go(to: .buildProfile)
|
||||
|
||||
#expect(model.basename == "DemoTarget")
|
||||
#expect(model.calibrationOriginalBasename == "")
|
||||
#expect(model.sessionMode == .profile)
|
||||
#expect(model.stage == .calibrate)
|
||||
XCTAssertEqual(model.basename, "DemoTarget")
|
||||
XCTAssertEqual(model.calibrationOriginalBasename, "")
|
||||
XCTAssertEqual(model.sessionMode, .profile)
|
||||
XCTAssertEqual(model.stage, .calibrate)
|
||||
}
|
||||
|
||||
@Test("go(to: .layOutPrint) while basename is CAL_ stays in calibration")
|
||||
func goToLayoutStaysCal() throws {
|
||||
func testGoToLayoutStaysCal() throws {
|
||||
let dir = try tempDir()
|
||||
let url = tempURL()
|
||||
let store = WizardStateStore(fileURL: url)
|
||||
@@ -81,9 +77,9 @@ struct WizardCalibrationSessionTests {
|
||||
|
||||
model.go(to: .layOutPrint)
|
||||
|
||||
#expect(model.basename == "CAL_DemoTarget")
|
||||
#expect(model.calibrationOriginalBasename == "DemoTarget")
|
||||
#expect(model.sessionMode == .calibration)
|
||||
#expect(model.stage == .layOutPrint)
|
||||
XCTAssertEqual(model.basename, "CAL_DemoTarget")
|
||||
XCTAssertEqual(model.calibrationOriginalBasename, "DemoTarget")
|
||||
XCTAssertEqual(model.sessionMode, .calibration)
|
||||
XCTAssertEqual(model.stage, .layOutPrint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import ICCeryCore
|
||||
|
||||
private func artefacts(
|
||||
@@ -16,77 +16,75 @@ private func artefacts(
|
||||
return a
|
||||
}
|
||||
|
||||
@Suite("WizardGating matrix")
|
||||
struct WizardGatingTests {
|
||||
final class WizardGatingTests: XCTestCase {
|
||||
|
||||
@Test func emptyProjectOnlyStage1() {
|
||||
func testEmptyProjectOnlyStage1() {
|
||||
let a = artefacts()
|
||||
#expect(WizardGating.isUnlocked(.generate, artefacts: a))
|
||||
#expect(WizardGating.isUnlocked(.calibrate, artefacts: a))
|
||||
XCTAssertTrue(WizardGating.isUnlocked(.generate, artefacts: a))
|
||||
XCTAssertTrue(WizardGating.isUnlocked(.calibrate, artefacts: a))
|
||||
for s in [WizardStage.layOutPrint, .measure, .buildProfile, .verifyInstall] {
|
||||
#expect(!WizardGating.isUnlocked(s, artefacts: a), "\(s) should be locked")
|
||||
XCTAssertFalse(WizardGating.isUnlocked(s, artefacts: a), "\(s) should be locked")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func ti1UnlocksStage2Only() {
|
||||
func testTi1UnlocksStage2Only() {
|
||||
let a = artefacts(ti1: true)
|
||||
#expect(WizardGating.isUnlocked(.layOutPrint, artefacts: a))
|
||||
#expect(!WizardGating.isUnlocked(.measure, artefacts: a))
|
||||
#expect(!WizardGating.isUnlocked(.buildProfile, artefacts: a))
|
||||
#expect(!WizardGating.isUnlocked(.verifyInstall, artefacts: a))
|
||||
XCTAssertTrue(WizardGating.isUnlocked(.layOutPrint, artefacts: a))
|
||||
XCTAssertFalse(WizardGating.isUnlocked(.measure, artefacts: a))
|
||||
XCTAssertFalse(WizardGating.isUnlocked(.buildProfile, artefacts: a))
|
||||
XCTAssertFalse(WizardGating.isUnlocked(.verifyInstall, artefacts: a))
|
||||
}
|
||||
|
||||
@Test func stage3NeedsTi1AndTi2() {
|
||||
#expect(!WizardGating.isUnlocked(.measure, artefacts: artefacts(ti2: true)))
|
||||
#expect(WizardGating.isUnlocked(.measure, artefacts: artefacts(ti1: true, ti2: true)))
|
||||
func testStage3NeedsTi1AndTi2() {
|
||||
XCTAssertFalse(WizardGating.isUnlocked(.measure, artefacts: artefacts(ti2: true)))
|
||||
XCTAssertTrue(WizardGating.isUnlocked(.measure, artefacts: artefacts(ti1: true, ti2: true)))
|
||||
}
|
||||
|
||||
@Test func stage4NeedsTi3NotTi2() {
|
||||
func testStage4NeedsTi3NotTi2() {
|
||||
// #109/#110: .ti2 alone must never unlock Stage 4.
|
||||
let a = artefacts(ti1: true, ti2: true)
|
||||
#expect(!WizardGating.isUnlocked(.buildProfile, artefacts: a))
|
||||
#expect(WizardGating.isUnlocked(.buildProfile, artefacts: artefacts(ti3: true)))
|
||||
XCTAssertFalse(WizardGating.isUnlocked(.buildProfile, artefacts: a))
|
||||
XCTAssertTrue(WizardGating.isUnlocked(.buildProfile, artefacts: artefacts(ti3: true)))
|
||||
}
|
||||
|
||||
@Test func stage5NeedsTi3AndProfile() {
|
||||
#expect(!WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(ti3: true)))
|
||||
#expect(!WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(profile: true)))
|
||||
#expect(WizardGating.isUnlocked(
|
||||
func testStage5NeedsTi3AndProfile() {
|
||||
XCTAssertFalse(WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(ti3: true)))
|
||||
XCTAssertFalse(WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(profile: true)))
|
||||
XCTAssertTrue(WizardGating.isUnlocked(
|
||||
.verifyInstall, artefacts: artefacts(ti3: true, profile: true)
|
||||
))
|
||||
}
|
||||
|
||||
@Test func forwardGatedBackwardFree() {
|
||||
func testForwardGatedBackwardFree() {
|
||||
let a = artefacts()
|
||||
#expect(!WizardGating.canNavigate(to: .layOutPrint, from: .generate, artefacts: a))
|
||||
XCTAssertFalse(WizardGating.canNavigate(to: .layOutPrint, from: .generate, artefacts: a))
|
||||
// Backward always allowed even when artefacts vanished.
|
||||
#expect(WizardGating.canNavigate(to: .generate, from: .measure, artefacts: a))
|
||||
XCTAssertTrue(WizardGating.canNavigate(to: .generate, from: .measure, artefacts: a))
|
||||
// Same stage is a no-op.
|
||||
#expect(WizardGating.canNavigate(to: .measure, from: .measure, artefacts: a))
|
||||
XCTAssertTrue(WizardGating.canNavigate(to: .measure, from: .measure, artefacts: a))
|
||||
// Stage 0 is a side-trip, never gated.
|
||||
#expect(WizardGating.canNavigate(to: .calibrate, from: .generate, artefacts: a))
|
||||
XCTAssertTrue(WizardGating.canNavigate(to: .calibrate, from: .generate, artefacts: a))
|
||||
}
|
||||
|
||||
@Test func deepestUnlocked() {
|
||||
#expect(WizardGating.deepestUnlocked(artefacts: artefacts()) == .generate)
|
||||
#expect(WizardGating.deepestUnlocked(
|
||||
func testDeepestUnlocked() {
|
||||
XCTAssertEqual(WizardGating.deepestUnlocked(artefacts: artefacts()), .generate)
|
||||
XCTAssertEqual(WizardGating.deepestUnlocked(
|
||||
artefacts: artefacts(ti1: true, ti2: true)
|
||||
) == .measure)
|
||||
#expect(WizardGating.deepestUnlocked(
|
||||
), .measure)
|
||||
XCTAssertEqual(WizardGating.deepestUnlocked(
|
||||
artefacts: artefacts(ti3: true, profile: true)
|
||||
) == .verifyInstall)
|
||||
), .verifyInstall)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("WizardStateStore")
|
||||
struct WizardStateStoreTests {
|
||||
final class WizardStateStoreTests: XCTestCase {
|
||||
private func tempURL() -> URL {
|
||||
FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("iccery-wiz-\(UUID().uuidString)")
|
||||
.appendingPathComponent("wizard_state.json")
|
||||
}
|
||||
|
||||
@Test func roundTrip() throws {
|
||||
func testRoundTrip() throws {
|
||||
let url = tempURL()
|
||||
let store = WizardStateStore(fileURL: url)
|
||||
var s = WizardState()
|
||||
@@ -97,32 +95,43 @@ struct WizardStateStoreTests {
|
||||
s.profileBasename = "imported"
|
||||
s.calibrationOriginalBasename = "pre-cal"
|
||||
try store.save(s)
|
||||
#expect(store.load() == s)
|
||||
XCTAssertEqual(store.load(), s)
|
||||
}
|
||||
|
||||
@Test func missingFileDefaults() {
|
||||
func testMissingFileDefaults() {
|
||||
let s = WizardStateStore(fileURL: tempURL()).load()
|
||||
#expect(s == .default)
|
||||
#expect(s.stage == .generate)
|
||||
#expect(s.sessionMode == .profile)
|
||||
XCTAssertEqual(s, .default)
|
||||
XCTAssertEqual(s.stage, .generate)
|
||||
XCTAssertEqual(s.sessionMode, .profile)
|
||||
}
|
||||
|
||||
@Test func corruptStageFallsBackToGenerate() throws {
|
||||
func testCorruptStageFallsBackToGenerate() throws {
|
||||
let url = tempURL()
|
||||
try FileManager.default.createDirectory(
|
||||
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||
)
|
||||
try #"{"current_stage": 99, "basename": "", "cwd": "", "session_mode": "profile"}"#
|
||||
.write(to: url, atomically: true, encoding: .utf8)
|
||||
#expect(WizardStateStore(fileURL: url).load().stage == .generate)
|
||||
XCTAssertEqual(WizardStateStore(fileURL: url).load().stage, .generate)
|
||||
}
|
||||
|
||||
@Test func sessionModeCalibrationRoundTrips() throws {
|
||||
func testCorruptJsonReturnsDefaultAndKeepsBytes() throws {
|
||||
let url = tempURL()
|
||||
try FileManager.default.createDirectory(
|
||||
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||
)
|
||||
try "not json".write(to: url, atomically: true, encoding: .utf8)
|
||||
XCTAssertEqual(WizardStateStore(fileURL: url).load(), .default)
|
||||
let kept = try String(contentsOf: url, encoding: .utf8)
|
||||
XCTAssertEqual(kept, "not json")
|
||||
}
|
||||
|
||||
func testSessionModeCalibrationRoundTrips() throws {
|
||||
var s = WizardState(sessionMode: .calibration)
|
||||
let data = try JSONEncoder().encode(s)
|
||||
let decoded = try JSONDecoder().decode(WizardState.self, from: data)
|
||||
#expect(decoded.sessionMode == .calibration)
|
||||
XCTAssertEqual(decoded.sessionMode, .calibration)
|
||||
s.sessionMode = .profile
|
||||
#expect(s.sessionMode == .profile)
|
||||
XCTAssertEqual(s.sessionMode, .profile)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import XCTest
|
||||
|
||||
extension XCTestCase {
|
||||
func assertAsyncThrows<T, E: Error>(
|
||||
expectedType: E.Type,
|
||||
_ expression: () async throws -> T,
|
||||
_ message: @autoclosure () -> String = "",
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line,
|
||||
errorHandler: ((E) -> Void)? = nil
|
||||
) async {
|
||||
do {
|
||||
_ = try await expression()
|
||||
XCTFail("Expected \(expectedType) to be thrown but expression succeeded. \(message())", file: file, line: line)
|
||||
} catch let error as E {
|
||||
errorHandler?(error)
|
||||
} catch {
|
||||
XCTFail("Expected \(expectedType) but caught \(type(of: error)): \(error). \(message())", file: file, line: line)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,6 +133,18 @@ final class Milestone2UITests: XCTestCase {
|
||||
XCTAssertTrue(element("targenInkLimitGroup").waitForExistence(timeout: 5))
|
||||
}
|
||||
|
||||
/// Stage 1/2 process-log containers resolve under the shared
|
||||
/// `ProcessLogView` identifiers (issue #80).
|
||||
func testProcessLogContainersResolve() throws {
|
||||
launchApp()
|
||||
XCTAssertTrue(waitFor("targenLogContainer").exists)
|
||||
|
||||
app.buttons["btnBrowse"].click()
|
||||
app.buttons["btnGenerate"].click()
|
||||
XCTAssertTrue(waitFor("btnCreateLayout", timeout: 20).exists)
|
||||
XCTAssertTrue(element("printtargLogContainer").exists)
|
||||
}
|
||||
|
||||
/// Fixture-backed targen run creates .ti1 and unlocks Stage 2.
|
||||
func testTargenFixtureUnlocksStage2() throws {
|
||||
launchApp()
|
||||
|
||||
@@ -146,6 +146,8 @@ final class Milestone3UITests: XCTestCase {
|
||||
XCTAssertTrue(notice.waitForExistence(timeout: 10))
|
||||
XCTAssertTrue((notice.value as? String ?? "")
|
||||
.contains("cancelled"))
|
||||
// Cancellation is informational, never an error (#80).
|
||||
XCTAssertEqual(element("printNotificationIcon").value as? String, "info")
|
||||
}
|
||||
|
||||
/// Preferences OK → captured options are replayed verbatim in the
|
||||
@@ -214,6 +216,8 @@ final class Milestone3UITests: XCTestCase {
|
||||
let notice = app.staticTexts.containing(predicate).firstMatch
|
||||
XCTAssertTrue(notice.waitForExistence(timeout: 10))
|
||||
XCTAssertTrue(notice.label.contains("Print failed"))
|
||||
// Spool failure exposes the .error kind on the icon (#80).
|
||||
XCTAssertEqual(element("printNotificationIcon").value as? String, "error")
|
||||
}
|
||||
|
||||
/// wizardState.printerName records the queue used for spooling (#95).
|
||||
|
||||
@@ -140,4 +140,57 @@ final class Milestone4UITests: XCTestCase {
|
||||
}
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: ti3.path))
|
||||
}
|
||||
|
||||
/// Two passes + a failing `average` run promote pass 1 to the
|
||||
/// canonical .ti3 and show the sticky finish error notice via
|
||||
/// `chartreadFinishNotice` (issue #80).
|
||||
func testTwoPassAverageFailurePromotesFirstPass() throws {
|
||||
app.launchEnvironment["MOCK_AVERAGE_FAIL"] = "1"
|
||||
reachStage3()
|
||||
|
||||
app.buttons["btnDetectInstruments"].click()
|
||||
_ = waitFor("chartreadInstrumentSelect", timeout: 20)
|
||||
|
||||
driveOnePass(startButton: "btnStartRead")
|
||||
_ = waitFor("chartreadAveragingPanel", timeout: 20)
|
||||
|
||||
driveOnePass(startButton: "btnMeasureAnotherSheet")
|
||||
|
||||
XCTAssertTrue(waitFor("btnFinishAndAverage", timeout: 20).exists)
|
||||
app.buttons["btnFinishAndAverage"].click()
|
||||
|
||||
// Averaging failed → pass 1 is promoted to the canonical .ti3
|
||||
// and the sticky error notice stays on Stage 3.
|
||||
let ti3 = workDir.appendingPathComponent("mytarget.ti3")
|
||||
let deadline = Date().addingTimeInterval(20)
|
||||
while Date() < deadline, !FileManager.default.fileExists(atPath: ti3.path) {
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
|
||||
}
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: ti3.path))
|
||||
|
||||
let notice = element("chartreadFinishNotice")
|
||||
XCTAssertTrue(notice.waitForExistence(timeout: 10))
|
||||
XCTAssertEqual(notice.value as? String, "error")
|
||||
}
|
||||
|
||||
/// Runs the mock handheld chartread session to completion
|
||||
/// (start → calibrate → strip A → strip B → Done & Save).
|
||||
private func driveOnePass(startButton: String) {
|
||||
let start = app.buttons[startButton]
|
||||
XCTAssertTrue(start.waitForExistence(timeout: 10))
|
||||
let deadline = Date().addingTimeInterval(10)
|
||||
while Date() < deadline, !start.isEnabled {
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
}
|
||||
XCTAssertTrue(start.isEnabled)
|
||||
start.click()
|
||||
_ = waitFor("btnCalibrate", timeout: 25)
|
||||
app.buttons["btnCalibrate"].click()
|
||||
_ = waitFor("btnTrigger", timeout: 20)
|
||||
app.buttons["btnTrigger"].click()
|
||||
_ = waitFor("btnTrigger", timeout: 20)
|
||||
app.buttons["btnTrigger"].click()
|
||||
_ = waitFor("btnDoneRead", timeout: 20)
|
||||
app.buttons["btnDoneRead"].firstMatch.click()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,4 +111,21 @@ final class Milestone5UITests: XCTestCase {
|
||||
"Expected verification status, got '\(statusValue)'"
|
||||
)
|
||||
}
|
||||
|
||||
/// A failing colprof run surfaces through the session-wide wizard
|
||||
/// notice only — no duplicate stage-local error view (issue #80).
|
||||
func testProfileFailureShowsWizardNotice() throws {
|
||||
app.launchEnvironment["ICCERY_MOCK_COLPROF_EXIT"] = "2"
|
||||
launchApp()
|
||||
|
||||
let create = waitFor("btnCreateProfile")
|
||||
XCTAssertTrue(create.isEnabled)
|
||||
create.click()
|
||||
|
||||
let notice = element("noticeText")
|
||||
XCTAssertTrue(notice.waitForExistence(timeout: 20))
|
||||
XCTAssertTrue((notice.value as? String ?? "")
|
||||
.contains("Profile creation failed"))
|
||||
XCTAssertFalse(element("colprofLastError").exists)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ final class Milestone6CalibrationUITests: XCTestCase {
|
||||
"ICCERY_TEST_WORKDIR": testWorkDir.path
|
||||
]
|
||||
app.launch()
|
||||
app.activate()
|
||||
}
|
||||
|
||||
override func tearDown() async throws {
|
||||
@@ -75,6 +76,69 @@ final class Milestone6CalibrationUITests: XCTestCase {
|
||||
// After generation the wizard should advance to Stage 2 (layout) because
|
||||
// a CAL_ .ti1 now exists and the session is in calibration mode.
|
||||
let layout = app.buttons["btnCreateLayout"]
|
||||
if !layout.waitForExistence(timeout: 25) {
|
||||
// The generate tap can be dropped while the dashboard is still
|
||||
// settling after the stage transition; retry once before failing.
|
||||
if calGenerate.waitForExistence(timeout: 2) {
|
||||
calGenerate.tap()
|
||||
}
|
||||
XCTAssertTrue(layout.waitForExistence(timeout: 25))
|
||||
}
|
||||
}
|
||||
|
||||
/// A failing calibration targen surfaces the error through the
|
||||
/// wizard notice and restores the original basename (issue #80).
|
||||
func testCalibrationTargenFailureRestoresBasename() throws {
|
||||
let testRoot = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cal-fail-\(UUID().uuidString)")
|
||||
let appData = testRoot.appendingPathComponent("AppData")
|
||||
try FileManager.default.createDirectory(
|
||||
at: appData, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: testRoot) }
|
||||
|
||||
// Pre-stage wizard state so the failing mock targen is only
|
||||
// exercised by the calibration run, not target generation.
|
||||
let state: [String: Any] = [
|
||||
"currentStage": 1,
|
||||
"basename": "DemoTarget",
|
||||
"cwd": testWorkDir.path,
|
||||
"sessionMode": "profile",
|
||||
"calibrationOriginalBasename": ""
|
||||
]
|
||||
let stateURL = appData.appendingPathComponent("wizard_state.json")
|
||||
try JSONSerialization.data(withJSONObject: state).write(to: stateURL)
|
||||
|
||||
app.terminate()
|
||||
app.launchEnvironment["ICCERY_TEST_ROOT"] = testRoot.path
|
||||
app.launchEnvironment["ICCERY_MOCK_TARGEN_EXIT"] = "2"
|
||||
app.launch()
|
||||
app.activate()
|
||||
|
||||
let calButton = app.buttons["btnCalibratePrinter"]
|
||||
XCTAssertTrue(calButton.waitForExistence(timeout: 10))
|
||||
calButton.tap()
|
||||
|
||||
let calGenerate = app.buttons["btnCalGenerate"]
|
||||
XCTAssertTrue(calGenerate.waitForExistence(timeout: 10))
|
||||
calGenerate.tap()
|
||||
|
||||
let notice = app.descendants(matching: .any)["noticeText"]
|
||||
XCTAssertTrue(notice.waitForExistence(timeout: 20))
|
||||
XCTAssertTrue((notice.value as? String ?? "")
|
||||
.contains("Calibration target failed"))
|
||||
|
||||
// The pre-CAL_ basename is restored and persisted.
|
||||
let deadline = Date().addingTimeInterval(10)
|
||||
var restoredBasename: String?
|
||||
while Date() < deadline {
|
||||
if let data = try? Data(contentsOf: stateURL),
|
||||
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let basename = object["basename"] as? String {
|
||||
restoredBasename = basename
|
||||
if basename == "DemoTarget" { break }
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
}
|
||||
XCTAssertEqual(restoredBasename, "DemoTarget")
|
||||
}
|
||||
}
|
||||
|
||||
+12
-12
@@ -2,7 +2,7 @@ name: ICCery
|
||||
options:
|
||||
bundleIdPrefix: com.gronod
|
||||
deploymentTarget:
|
||||
macOS: "14.0"
|
||||
macOS: "12.0"
|
||||
groupSortPosition: top
|
||||
|
||||
packages:
|
||||
@@ -13,7 +13,7 @@ targets:
|
||||
ICCery:
|
||||
type: application
|
||||
platform: macOS
|
||||
deploymentTarget: "14.0"
|
||||
deploymentTarget: "12.0"
|
||||
sources:
|
||||
- path: Sources/ICCery
|
||||
- path: Resources
|
||||
@@ -46,7 +46,7 @@ targets:
|
||||
PRODUCT_BUNDLE_PACKAGE_TYPE: APPL
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
INFOPLIST_KEY_CFBundleDisplayName: ICCery
|
||||
INFOPLIST_KEY_LSMinimumSystemVersion: "14.0"
|
||||
INFOPLIST_KEY_LSMinimumSystemVersion: "12.0"
|
||||
INFOPLIST_KEY_NSPrincipalClass: NSApplication
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 Gronod. AGPLv3."
|
||||
MARKETING_VERSION: "2.0.0"
|
||||
@@ -58,15 +58,15 @@ targets:
|
||||
ENABLE_APP_SANDBOX: NO
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
|
||||
SWIFT_VERSION: "6.0"
|
||||
SWIFT_STRICT_CONCURRENCY: complete
|
||||
MACOSX_DEPLOYMENT_TARGET: "14.0"
|
||||
SWIFT_VERSION: "5.0"
|
||||
OTHER_SWIFT_FLAGS: ["$(inherited)", "-strict-concurrency=minimal"]
|
||||
MACOSX_DEPLOYMENT_TARGET: "12.0"
|
||||
ARCHS: "$(ARCHS_STANDARD)"
|
||||
|
||||
ICCeryCoreTests:
|
||||
type: bundle.unit-test
|
||||
platform: macOS
|
||||
deploymentTarget: "14.0"
|
||||
deploymentTarget: "12.0"
|
||||
sources:
|
||||
- path: Tests/ICCeryCoreTests
|
||||
dependencies:
|
||||
@@ -79,13 +79,13 @@ targets:
|
||||
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/ICCery.app/Contents/MacOS/ICCery"
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
CODE_SIGN_IDENTITY: "-"
|
||||
SWIFT_VERSION: "6.0"
|
||||
MACOSX_DEPLOYMENT_TARGET: "14.0"
|
||||
SWIFT_VERSION: "5.0"
|
||||
MACOSX_DEPLOYMENT_TARGET: "12.0"
|
||||
|
||||
ICCeryUITests:
|
||||
type: bundle.ui-testing
|
||||
platform: macOS
|
||||
deploymentTarget: "14.0"
|
||||
deploymentTarget: "12.0"
|
||||
sources:
|
||||
- path: Tests/ICCeryUITests
|
||||
dependencies:
|
||||
@@ -95,8 +95,8 @@ targets:
|
||||
TEST_TARGET_NAME: ICCery
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
CODE_SIGN_IDENTITY: "-"
|
||||
SWIFT_VERSION: "6.0"
|
||||
MACOSX_DEPLOYMENT_TARGET: "14.0"
|
||||
SWIFT_VERSION: "5.0"
|
||||
MACOSX_DEPLOYMENT_TARGET: "12.0"
|
||||
|
||||
schemes:
|
||||
ICCery:
|
||||
|
||||
Executable
+103
@@ -0,0 +1,103 @@
|
||||
#!/bin/sh
|
||||
# scripts/attach-release-asset.sh
|
||||
#
|
||||
# Attach ICCery-*.dmg to the Gitea release for the current tag.
|
||||
# actions/upload-artifact only stores a workflow artifact; it does not
|
||||
# publish a release asset (run 29714 left v2.0.0-pre2-grok with no files).
|
||||
|
||||
set -eu
|
||||
|
||||
ROOT="$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
TOKEN="${GITEA_TOKEN:-${GITHUB_TOKEN:-}}"
|
||||
if [ -z "$TOKEN" ]; then
|
||||
echo "error: GITEA_TOKEN or GITHUB_TOKEN is required to attach release assets" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SERVER="${GITEA_SERVER_URL:-${GITHUB_SERVER_URL:-https://git.i3omb.com}}"
|
||||
SERVER="${SERVER%/}"
|
||||
API="$SERVER/api/v1"
|
||||
|
||||
REPO="${GITHUB_REPOSITORY:-gronod/iccery-v2-mac}"
|
||||
TAG="${RELEASE_TAG:-${GITHUB_REF_NAME:-}}"
|
||||
if [ -z "$TAG" ] && [ -n "${GITHUB_REF:-}" ]; then
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
fi
|
||||
if [ -z "$TAG" ] || [ "$TAG" = "${GITHUB_REF:-}" ]; then
|
||||
echo "error: no release tag (set RELEASE_TAG or GITHUB_REF_NAME)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DMG="${1:-}"
|
||||
if [ -z "$DMG" ]; then
|
||||
DMG="$(ls -1 ICCery-*.dmg 2>/dev/null | head -n 1 || true)"
|
||||
fi
|
||||
if [ -z "$DMG" ] || [ ! -f "$DMG" ]; then
|
||||
echo "error: no ICCery-*.dmg to attach" >&2
|
||||
exit 1
|
||||
fi
|
||||
NAME="$(basename "$DMG")"
|
||||
|
||||
echo "==> Resolving release $TAG"
|
||||
HTTP="$(mktemp)"
|
||||
BODY="$(mktemp)"
|
||||
STATUS="$(curl -sS -o "$BODY" -w '%{http_code}' \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Accept: application/json" \
|
||||
"$API/repos/$REPO/releases/tags/$TAG" || true)"
|
||||
|
||||
if [ "$STATUS" = "404" ]; then
|
||||
echo "==> Creating release $TAG"
|
||||
STATUS="$(curl -sS -o "$BODY" -w '%{http_code}' \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-X POST "$API/repos/$REPO/releases" \
|
||||
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"prerelease\":true,\"target_commitish\":\"${GITHUB_SHA:-}\"}")"
|
||||
fi
|
||||
if [ "$STATUS" != "200" ] && [ "$STATUS" != "201" ]; then
|
||||
echo "error: could not load/create release $TAG (HTTP $STATUS)" >&2
|
||||
cat "$BODY" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RELEASE_ID="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("id",""))' "$BODY")"
|
||||
if [ -z "$RELEASE_ID" ]; then
|
||||
echo "error: release JSON missing id" >&2
|
||||
cat "$BODY" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Replace a same-named asset so retags stay idempotent.
|
||||
python3 - "$BODY" "$NAME" > "$HTTP" <<'PY'
|
||||
import json, sys
|
||||
rel = json.load(open(sys.argv[1]))
|
||||
want = sys.argv[2]
|
||||
for a in rel.get("assets") or []:
|
||||
if a.get("name") == want:
|
||||
print(a.get("id", ""))
|
||||
break
|
||||
PY
|
||||
EXISTING="$(cat "$HTTP")"
|
||||
if [ -n "$EXISTING" ]; then
|
||||
echo "==> Replacing existing asset $NAME ($EXISTING)"
|
||||
curl -sS -o /dev/null -w '%{http_code}\n' \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-X DELETE "$API/repos/$REPO/releases/$RELEASE_ID/assets/$EXISTING" >/dev/null || true
|
||||
fi
|
||||
|
||||
echo "==> Uploading $NAME to release $RELEASE_ID"
|
||||
STATUS="$(curl -sS -o "$BODY" -w '%{http_code}' \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Accept: application/json" \
|
||||
-F "attachment=@$DMG;filename=$NAME" \
|
||||
"$API/repos/$REPO/releases/$RELEASE_ID/assets?name=$NAME")"
|
||||
|
||||
if [ "$STATUS" != "201" ]; then
|
||||
echo "error: asset upload failed (HTTP $STATUS)" >&2
|
||||
cat "$BODY" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Attached $NAME to $SERVER/$REPO/releases/tag/$TAG"
|
||||
rm -f "$HTTP" "$BODY"
|
||||
Executable
+40
@@ -0,0 +1,40 @@
|
||||
#!/bin/sh
|
||||
# scripts/ensure-host-tools.sh
|
||||
#
|
||||
# Bootstrap host tools needed by CI on the macOS 12 runner:
|
||||
# - xcodegen: pinned prebuilt release from GitHub (Homebrew's current
|
||||
# formula requires Xcode 15.3, which cannot be installed on macOS 12).
|
||||
# - dmgbuild: via pip3 (used by scripts/package-release.sh).
|
||||
#
|
||||
# Safe to run repeatedly: existing tools are left alone.
|
||||
|
||||
set -eu
|
||||
|
||||
XCODEGEN_VERSION="2.38.0"
|
||||
INSTALL_ROOT="${XCODEGEN_HOME:-$HOME/.local/xcodegen/$XCODEGEN_VERSION}"
|
||||
|
||||
echo "==> Ensuring dmgbuild"
|
||||
python3 -c "import dmgbuild" 2>/dev/null || pip3 install dmgbuild
|
||||
|
||||
if command -v xcodegen >/dev/null 2>&1; then
|
||||
echo "==> xcodegen already on PATH: $(xcodegen --version)"
|
||||
else
|
||||
echo "==> Installing xcodegen $XCODEGEN_VERSION (prebuilt)"
|
||||
TMP="${RUNNER_TEMP:-${TMPDIR:-/tmp}}"
|
||||
ZIP="$TMP/xcodegen-$XCODEGEN_VERSION.zip"
|
||||
curl -fL --retry 3 \
|
||||
"https://github.com/yonaskolb/XcodeGen/releases/download/$XCODEGEN_VERSION/xcodegen.zip" \
|
||||
-o "$ZIP"
|
||||
rm -rf "$INSTALL_ROOT"
|
||||
mkdir -p "$INSTALL_ROOT"
|
||||
# Zip contains xcodegen/{bin/xcodegen,share/xcodegen/SettingPresets};
|
||||
# XcodeGen resolves its presets relative to the binary, so keep the tree.
|
||||
unzip -q "$ZIP" -d "$INSTALL_ROOT"
|
||||
BIN_DIR="$INSTALL_ROOT/xcodegen/bin"
|
||||
chmod +x "$BIN_DIR/xcodegen"
|
||||
if [ -n "${GITHUB_PATH:-}" ]; then
|
||||
echo "$BIN_DIR" >> "$GITHUB_PATH"
|
||||
fi
|
||||
PATH="$BIN_DIR:$PATH"
|
||||
echo "==> Installed: $("$BIN_DIR/xcodegen" --version)"
|
||||
fi
|
||||
Reference in New Issue
Block a user