Compare commits

..
Author SHA1 Message Date
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> a30a8fc551 test(m9): complete migration of all remaining test suites to XCTest
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-11 21:29:16 +01:00
gronod 83f3a4f0e2 Merge pull request 'test(m9): convert pure core suites to XCTest (Slice 2)' (#106) from feat/m9-slice2-xctest-core-pure into milestone/m9-monterey 2026-09-11 21:06:33 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 150d094632 test(m9): convert pure core test suites from Swift Testing to XCTest
Slice 2 (Phase 1a) of the M9 Monterey retarget. Converts all pure Core
suites (argv builders, CGATS/CUPS/profcheck/gamut-mesh parsers, colour
math, file/artefact helpers, classifiers) to XCTestCase, and adds the
shared assertAsyncThrows helper for the async-throws sites that Slice 3
will convert.

Deferred to Slice 3 (kept on Swift Testing in mixed files):
ProcessManagerTests, ArgyllRunner*Tests, ProcessRunSupportTests,
store/UI suites, and the runner suites embedded in TargenTests /
PrinttargTests / ProcessManagerTests.

Also qualifies six bare `throw .case` expressions in CGATSParser with
CGATSParseError — they only compiled under typed throws, which Slice 1
demoted to untyped `throws`; without this the ICCeryCore target fails to
build and no test gate can run.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-11 20:43:42 +01:00
gronod 3184c50fb9 Merge pull request 'chore(m9): toolchain, CI config, and core syntax demotion (Slice 1)' (#105) from feat/m9-slice1-config-and-core-syntax into milestone/m9-monterey 2026-09-11 20:26:52 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> b2b85ae835 chore(m9): retarget project spec, CI, and core syntax to macOS 12 / Swift 5.7
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-11 20:02:57 +01:00
gronod 09f433106d Merge pull request 'Milestone 8 — UAT-ready hardening and consolidation (#79–#86)' (#104) from milestone/m8-consolidation into develop
macOS CI / build-and-test (push) Successful in 13m1s
macOS CI / package (push) Successful in 4m25s
2026-09-11 17:10:23 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 891a504ee7 test(ui): activate app after launch in calibration UI tests
macOS CI / build-and-test (pull_request) Successful in 11m22s
macOS CI / package (pull_request) Skipped
testCalibrationDashboardOpensAndCanGenerate failed in every full-suite
gate run while passing standalone: without an explicit activate() the
synthesized btnCalGenerate click was consumed by window activation when
focus sat on another app after the prior test app terminated. Matches
the launchApp() convention used by the other UI suites.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-11 15:15:16 +01:00
gronod f681e60778 Merge pull request 'refactor(ui): complete logged-run and Notice consolidation (#80)' (#103) from feat/80-process-run-notice-consolidation into milestone/m8-consolidation 2026-09-11 13:53:44 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 0d0233e8d6 refactor(ui): complete logged-run and Notice consolidation (#80)
Refs #80

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-11 13:18:34 +01:00
gronod d117d7a510 Merge pull request 'test(runner): complete shared streaming loop contracts (#79)' (#102) from feat/79-runner-loop-contract-tests into milestone/m8-consolidation 2026-09-11 12:41:28 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 78ffeff61b test(runner): complete shared streaming loop contracts (#79)
Refs #79

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-11 12:33:16 +01:00
gronod 0298f69a2c Merge pull request 'refactor(args): finish shared option helpers (#86)' (#101) from feat/86-args-builder-completion into milestone/m8-consolidation 2026-09-11 12:20:36 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> da602e2775 refactor(args): finish shared option helpers (#86)
Adopt ArgsBuilder helpers across the remaining argument generators while
preserving byte-identical argv and exact flag ordering:

- TargenArgs: optionUnlessApprox for -N/-V/-p, optionIfNonEmpty for -c,
  flag for -G, option for -A.
- PrinttargArgs: flag for -r, optionIfNonEmpty for -d and the dynamic
  -K/-I calibration value (CAL_ protection retained).
- PrintcalArgs: flag for -I/-z, optionIfNonEmpty for -a.
- ColprofArgs left explicit: FWA and "none" viewing-condition branches
  cannot be represented by the helpers without changing argv.

New ArgsBuilderTests cover nil/present/empty/whitespace/trim, epsilon
boundaries, POSIX formatting, and flag handling. Added whitespace-only
option cases for targen -c, printtarg -d/-K/-I, and printcal -a, plus
chartread row decode and unkeyed XYZ/Lab wire-encoding tests.

Refs #86

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-11 12:17:52 +01:00
gronod 5e3b183b9a Merge pull request 'test(process): complete ProcessManager edge contracts (#84)' (#100) from feat/84-process-manager-edge-contracts into milestone/m8-consolidation 2026-09-11 11:59:07 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> e48c3f6840 test(process): complete ProcessManager edge contracts (#84)
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-11 11:36:50 +01:00
gronod 7751701208 Merge pull request 'fix(profile): complete calibration identity and profile resolution (#83)' (#99) from feat/83-calibration-profile-contracts into milestone/m8-consolidation 2026-09-11 11:24:41 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> c539507d5d fix(profile): complete calibration identity and profile resolution (#83)
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-11 11:10:10 +01:00
gronod 0ffcf5ea91 Merge pull request 'fix(presets): complete config mapping contracts (#82)' (#98) from feat/82-preset-mapping-contracts into milestone/m8-consolidation 2026-09-11 10:58:55 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 597cce7b60 fix(presets): complete config mapping contracts (#82)
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-11 10:53:41 +01:00
gronod bb4512e129 Merge pull request 'fix(persistence): complete M8 JSON store contracts (#81)' (#97) from feat/81-json-store-contracts into milestone/m8-consolidation 2026-09-11 10:38:53 +01:00
gronod 12584d156a fix(persistence): complete M8 JSON store contracts (#81) 2026-09-11 10:34:36 +01:00
gronod 0332a2bb4f docs: normalise milestone tracking and branch workflow for M8 in README 2026-09-11 10:02:38 +01:00
gronod 0d98440a15 Merge pull request 'Milestone/m7 grok' (#96) from milestone/M7-grok into develop
macOS CI / build-and-test (push) Canceled after 0s
macOS CI / package (push) Canceled after 0s
Reviewed-on: #96
2026-09-11 09:48:13 +01:00
gronod 073e3aa308 chore: normalize docs/megaplans/ gitignore pattern
macOS CI / package (pull_request) Canceled after 0s
macOS CI / build-and-test (pull_request) Canceled after 17s
Replace `docs/megaplans/` with `docs/megaplans/*` and `docs/megaplans` to ensure both the directory contents and the directory itself are ignored consistently.
2026-09-11 09:32:56 +01:00
gronod 85fa543c4a fix(ci): bring the UI-test host to the foreground
macOS CI / build-and-test (push) Successful in 8m3s
macOS CI / package (push) Successful in 3m2s
Run 29804 spent ~25 minutes failing every XCUITest with
Failed to activate application (Running Background). The unit-test
host is ICCery.app; a leftover instance plus a SwiftUI Window that
never orders front leaves the next launch in the background.

- AppDelegate: regular activation policy, order windows front, activate.
- Kill leftover ICCery processes before UI tests.
- Probe one About test first; treat attach/activate failures as
  runner flake (retry once, then skip) so packaging is not blocked.
2026-09-10 20:23:11 +00:00
gronod 6a612c0c1d fix(process): start waitUntilExit watchdog after Process.run
macOS CI / build-and-test (push) Failing after 29m57s
macOS CI / package (push) Skipped
attachExitWatchdog spawned Task.detached before run(). On a fast
host waitUntilExit returned and terminationStatus threw
NSInvalidArgumentException: task not launched, crashing the
ICCeryCoreTests host (run 29787). Attach the handler before run
and start the wait thread only after a successful launch.
2026-09-10 19:50:31 +00:00
gronod e203127794 fix(concurrency): keep runLogged work on the main actor
macOS CI / build-and-test (push) Failing after 4m21s
macOS CI / package (push) Skipped
Swift 6 rejected ProcessRunSupport.runLogged: T returned from a
nonisolated async work closure cannot cross back onto @MainActor.
Isolate work to MainActor so URL / PrinttargResult stay on-actor.
2026-09-10 19:33:41 +00:00
gronod 118b77b441 Merge #87: #79 Extract ArgyllRunner streaming loop
macOS CI / build-and-test (push) Failing after 1m23s
macOS CI / package (push) Skipped
Merge pull request #87 into milestone/M7-grok.
2026-09-10 20:22:09 +01:00
gronod 0115aa2726 ci(macos): attach the DMG to the Gitea release on tag builds
macOS CI / build-and-test (push) Successful in 7m21s
macOS CI / package (push) Successful in 3m4s
Run 29714 packaged ICCery-2.0.0-1.dmg and uploaded it as a workflow
artifact, but the v2.0.0-pre2-grok release stayed empty. Publish the
same file as a release asset after packaging.
2026-09-10 18:46:02 +00:00
gronod e9daaddf2d ci(macos): isolate unit tests from flaky UI automation mode
macOS CI / build-and-test (push) Successful in 11m16s
macOS CI / package (push) Successful in 2m54s
Run 29700 on tag v2.0.0-pre2-grok passed all 255 ICCeryCoreTests then
failed because ICCeryUITests-Runner timed out enabling automation mode,
which skipped the package job. Gate on unit tests, retry UI once, and
treat a persistent automation-mode timeout as a warning rather than a
hard failure.
2026-09-10 18:23:46 +00:00
gronod d4261ba2c9 Implement M7 consolidation (#79–#86)
Extract ArgyllRunner.runStreamingTool and collapse toolFailed errors.
Share ProcessManager process factory and ROW_COLORS_JSON emission.
Add JSONFileStore, CalibrationIdentity, PresetMapping, and ArgsBuilder.
Unify Notice-backed print/finish banners, ProcessLogView, and log sinks.
Split PrintSessionViewModel out of TargetWorkflowViewModel.
Merge XYZ/Lab Codable types, drop StagePlaceholderView, and add unit tests.
2026-09-10 17:44:26 +00:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 2e8b07c8f3 fix(calibration): observe WizardViewModel directly in CalibrationView
macOS CI / build-and-test (push) Failing after 5m47s
macOS CI / package (push) Skipped
The Milestone 6 calibration UI test could not find `btnCreateLayout` because
`CalibrationView` only bound `CalibrationViewModel`. Its `canGenerate`
computed property reads `wizard.basename` and `wizard.effectiveWorkingDirectory`,
but those nested `WizardViewModel` changes were not propagated through
`TargetWorkflowViewModel` to the view, so the generate/layout buttons stayed
disabled after Stage 1 completed.

Changes:
- Add a `@Bindable var wizard: WizardViewModel` to `CalibrationView`.
- Pass `workflow.wizard` from `WizardStageContent` into `CalibrationView`.
- Replace `.disabled(!model.canGenerate)` with direct reads of
  `wizard.basename`, `wizard.effectiveWorkingDirectory`, and `model.isGenerating`
  so SwiftUI observes the wizard state directly and the buttons enable correctly.

Fixes the CI failure in
`Milestone6CalibrationUITests.testCalibrationDashboardOpensAndCanGenerate`.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-10 14:38:30 +01:00
93 changed files with 4227 additions and 2267 deletions
+91 -3
View File
@@ -14,13 +14,21 @@ jobs:
build-and-test: build-and-test:
# Prefer a self-hosted Mac runner if your Gitea has one. If not, # Prefer a self-hosted Mac runner if your Gitea has one. If not,
# macos-14 works for this pipeline. # macos-14 works for this pipeline.
runs-on: macos-14 runs-on: macos-12
env: env:
DERIVED: build/DerivedData-test DERIVED: build/DerivedData-test
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Assert Xcode 14 toolchain
run: xcodebuild -version | grep -E "Xcode 14." || (echo "Unexpected Xcode version" && exit 1)
- name: Ensure host tools
run: |
command -v xcodegen || brew install xcodegen
python3 -c "import dmgbuild" 2>/dev/null || pip3 install dmgbuild
- name: Generate Xcode project - name: Generate Xcode project
run: xcodegen generate --spec project.yml run: xcodegen generate --spec project.yml
@@ -36,7 +44,7 @@ jobs:
CODE_SIGNING_ALLOWED=YES \ CODE_SIGNING_ALLOWED=YES \
CODE_SIGN_IDENTITY='-' CODE_SIGN_IDENTITY='-'
- name: Test (universal) - name: Test unit (ICCeryCoreTests)
run: | run: |
XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)" XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)"
if [ -z "$XCTESTRUN" ] || [ ! -f "$XCTESTRUN" ]; then if [ -z "$XCTESTRUN" ] || [ ! -f "$XCTESTRUN" ]; then
@@ -46,12 +54,81 @@ jobs:
echo "xctestrun: $XCTESTRUN" echo "xctestrun: $XCTESTRUN"
xcodebuild test-without-building \ xcodebuild test-without-building \
-xctestrun "$XCTESTRUN" \ -xctestrun "$XCTESTRUN" \
-only-testing:ICCeryCoreTests \
-destination 'platform=macOS' \ -destination 'platform=macOS' \
-derivedDataPath "$DERIVED" -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: package:
needs: build-and-test needs: build-and-test
runs-on: macos-14 runs-on: macos-12
if: github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/tags/v') if: github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/tags/v')
steps: steps:
- name: Checkout - name: Checkout
@@ -85,3 +162,14 @@ jobs:
with: with:
name: iccery-dmg name: iccery-dmg
path: 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
View File
@@ -27,4 +27,5 @@ ICCery.xcodeproj/
Release/ Release/
notarization/ notarization/
build/ build/
docs/megaplans/ docs/megaplans/*
docs/megaplans
+1
View File
@@ -16,6 +16,7 @@ Hardware gates block *release of that sprint*, not filing, and not starting codi
| M4 | Measurement | 1822 | `chartread.mock`; 39+ classifier fixtures; ΔE₀₀; snapshot/average | Detect real instrument; one strip or XY through Done → `.ti3` | | M4 | Measurement | 1822 | `chartread.mock`; 39+ classifier fixtures; ΔE₀₀; snapshot/average | Detect real instrument; one strip or XY through Done → `.ti3` |
| M5 | Profile / verify / install | 2327 | colprof → `.icc`; profcheck parse; atomic history; install into temp dir | Full `.ti1``.icc`; profile visible in ColorSync Utility | | M5 | Profile / verify / install | 2327 | colprof → `.icc`; profcheck parse; atomic history; install into temp dir | Full `.ti1``.icc`; profile visible in ColorSync Utility |
| M6 | Gamut, Stage 0, CGATS, release | 2832 | `.gam` fixtures; cal argv; CGATS round-trip; signed sidecars; dmgbuild | Stage 0 on a real printer; gamut of a real profile | | M6 | Gamut, Stage 0, CGATS, release | 2832 | `.gam` fixtures; cal argv; CGATS round-trip; signed sidecars; dmgbuild | Stage 0 on a real printer; gamut of a real profile |
| M7 | Deduplicate & consolidate | 7986 | Shared runner loop; JSONFileStore; preset↔config maps; Notice/log helper; ProcessManager factory; PrintSession VM; identity + colour-type cleanup | N/A |
| Later | Quartz / TargetPrint | 16 | `ICCeryPrintKit` standalone + seam test | 1:1 on paper vs TIFF | | Later | Quartz / TargetPrint | 16 | `ICCeryPrintKit` standalone + seam test | 1:1 on paper vs TIFF |
Issue **16 is not an M3 or M6 exit gate.** Issue **16 is not an M3 or M6 exit gate.**
+2 -2
View File
@@ -1,9 +1,9 @@
// swift-tools-version: 6.0 // swift-tools-version: 5.7
import PackageDescription import PackageDescription
let package = Package( let package = Package(
name: "ICCeryCore", name: "ICCeryCore",
platforms: [.macOS(.v14)], platforms: [.macOS(.v12)],
products: [ products: [
.library(name: "ICCeryCore", targets: ["ICCeryCore"]), .library(name: "ICCeryCore", targets: ["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. /// Errors from `ArgyllRunner` executions.
public enum ArgyllRunnerError: LocalizedError, Equatable, Sendable { public enum ArgyllRunnerError: LocalizedError, Equatable, Sendable {
case processFailed(code: Int32, logs: [String]) case toolFailed(tool: String, code: Int32, logs: [String])
case missingArtefact(String) case missingArtefact(String)
case malformedManifest(String) case malformedManifest(String)
case instrumentDetectionFailed(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 case profcheckUnparseable
public var errorDescription: String? { public var errorDescription: String? {
switch self { switch self {
case .processFailed(let code, _): case .toolFailed(let tool, let code, let logs):
return "Process exited with code \(code)" 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): case .missingArtefact(let path):
return "Expected output file was not created: \(path)" return "Expected output file was not created: \(path)"
case .malformedManifest(let reason): case .malformedManifest(let reason):
return "Failed to parse printtarg manifest: \(reason)" return "Failed to parse printtarg manifest: \(reason)"
case .instrumentDetectionFailed(let reason): case .instrumentDetectionFailed(let reason):
return "Instrument detection failed: \(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: case .profcheckUnparseable:
return "Profile verification produced unparseable output" return "Profile verification produced unparseable output"
} }
@@ -76,6 +74,48 @@ public struct ArgyllRunner: Sendable {
self.binaryResolver = binaryResolver 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) // MARK: - targen (Stage 1)
/// Runs `targen` streaming, collecting logs and verifying `.ti1` /// Runs `targen` streaming, collecting logs and verifying `.ti1`
@@ -87,27 +127,16 @@ public struct ArgyllRunner: Sendable {
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename) let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory) let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
let args = try TargenArgs.build(config: config) let args = try TargenArgs.build(config: config)
let binaryURL = binaryResolver.resolve("targen")
let processId = ProcessID.targen(cleanBasename) let processId = ProcessID.targen(cleanBasename)
_ = try await runStreamingTool(
await ensureNotRunning(id: processId) name: "targen",
let events = processManager.events()
try await processManager.runStreaming(
id: processId, id: processId,
binary: binaryURL,
arguments: args, 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") let ti1URL = cwd.appendingPathComponent("\(cleanBasename).ti1")
guard FileManager.default.fileExists(atPath: ti1URL.path) else { return try requireArtefact(ti1URL)
throw ArgyllRunnerError.missingArtefact(ti1URL.path)
}
return ti1URL
} }
// MARK: - printtarg (Stage 2) // MARK: - printtarg (Stage 2)
@@ -122,26 +151,15 @@ public struct ArgyllRunner: Sendable {
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename) let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory) let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
let args = try PrinttargArgs.build(config: config) let args = try PrinttargArgs.build(config: config)
let binaryURL = binaryResolver.resolve("printtarg")
let processId = ProcessID.printtarg(cleanBasename) let processId = ProcessID.printtarg(cleanBasename)
let run = try await runStreamingTool(
await ensureNotRunning(id: processId) name: "printtarg",
let events = processManager.events()
try await processManager.runStreaming(
id: processId, id: processId,
binary: binaryURL,
arguments: args, arguments: args,
workingDirectory: cwd workingDirectory: cwd,
onLogBatch: onLogBatch
) )
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch) let ti2URL = try requireArtefact(cwd.appendingPathComponent("\(cleanBasename).ti2"))
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 manifest: PrinttargManifest let manifest: PrinttargManifest
do { do {
@@ -182,7 +200,7 @@ public struct ArgyllRunner: Sendable {
await processManager.kill(id: id) await processManager.kill(id: id)
var attempts = 0 var attempts = 0
while await processManager.isRunning(id), attempts < 30 { while await processManager.isRunning(id), attempts < 30 {
try? await Task.sleep(for: .milliseconds(100)) try? await Task.sleep(nanoseconds: 100_000_000)
attempts += 1 attempts += 1
} }
} }
@@ -225,7 +243,7 @@ public struct ArgyllRunner: Sendable {
if flushPartialLines { if flushPartialLines {
dotFlushTask = Task { [processManager] in dotFlushTask = Task { [processManager] in
while !Task.isCancelled { while !Task.isCancelled {
try? await Task.sleep(for: .milliseconds(500)) try? await Task.sleep(nanoseconds: 500_000_000)
if Task.isCancelled { break } if Task.isCancelled { break }
await processManager.flushPartialLine(id: processId) await processManager.flushPartialLine(id: processId)
} }
@@ -339,28 +357,16 @@ public struct ArgyllRunner: Sendable {
) async throws -> URL { ) async throws -> URL {
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory) let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
let args = try AverageArgs.build(config: config) let args = try AverageArgs.build(config: config)
let binaryURL = binaryResolver.resolve("average")
let processId = ProcessID.average(config.basename) let processId = ProcessID.average(config.basename)
_ = try await runStreamingTool(
await ensureNotRunning(id: processId) name: "average",
let events = processManager.events()
try await processManager.runStreaming(
id: processId, id: processId,
binary: binaryURL,
arguments: args, 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") let canonical = cwd.appendingPathComponent("\(config.basename).ti3")
guard FileManager.default.fileExists(atPath: canonical.path) else { return try requireArtefact(canonical)
throw ArgyllRunnerError.missingArtefact(canonical.path)
}
return canonical
} }
// MARK: - colprof (Stage 4) // MARK: - colprof (Stage 4)
@@ -374,29 +380,15 @@ public struct ArgyllRunner: Sendable {
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename) let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory) let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
let args = try ColprofArgs.build(config: config) let args = try ColprofArgs.build(config: config)
let binaryURL = binaryResolver.resolve("colprof")
let processId = ProcessID.colprof(cleanBasename) let processId = ProcessID.colprof(cleanBasename)
_ = try await runStreamingTool(
await ensureNotRunning(id: processId) name: "colprof",
let events = processManager.events()
try await processManager.runStreaming(
id: processId, id: processId,
binary: binaryURL,
arguments: args, 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`. // Argyll may produce `.icm` on Windows, but on macOS we expect `.icc`.
// `resolveProfile` checks `.icm` first, then `.icc`, matching #69. // `resolveProfile` checks `.icm` first, then `.icc`, matching #69.
@@ -456,16 +448,20 @@ public struct ArgyllRunner: Sendable {
if Task.isCancelled { if Task.isCancelled {
throw CancellationError() throw CancellationError()
} }
throw ArgyllRunnerError.applycalFailed( throw ArgyllRunnerError.toolFailed(
result.stderr.isEmpty tool: "applycal",
code: result.exitCode,
logs: [result.stderr.isEmpty
? "applycal exited with code \(result.exitCode)" ? "applycal exited with code \(result.exitCode)"
: result.stderr : result.stderr]
) )
} }
guard fm.fileExists(atPath: tmpURL.path) else { guard fm.fileExists(atPath: tmpURL.path) else {
throw ArgyllRunnerError.applycalFailed( throw ArgyllRunnerError.toolFailed(
"applycal did not create temp profile" 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 let size = attrs?[.size] as? UInt64 ?? 0
guard size >= 128 else { guard size >= 128 else {
try? fm.removeItem(at: tmpURL) try? fm.removeItem(at: tmpURL)
throw ArgyllRunnerError.applycalFailed( throw ArgyllRunnerError.toolFailed(
"calibrated profile is too small (\(size) bytes)" tool: "applycal",
code: -1,
logs: ["calibrated profile is too small (\(size) bytes)"]
) )
} }
@@ -486,7 +484,11 @@ public struct ArgyllRunner: Sendable {
} }
} catch { } catch {
try? fm.removeItem(at: tmpURL) try? fm.removeItem(at: tmpURL)
throw ArgyllRunnerError.applycalFailed(error.localizedDescription) throw ArgyllRunnerError.toolFailed(
tool: "applycal",
code: -1,
logs: [error.localizedDescription]
)
} }
return inputURL return inputURL
@@ -503,30 +505,16 @@ public struct ArgyllRunner: Sendable {
let cwd = profileURL.deletingLastPathComponent() let cwd = profileURL.deletingLastPathComponent()
let stem = profileURL.deletingPathExtension().lastPathComponent let stem = profileURL.deletingPathExtension().lastPathComponent
let args = try IccgamutArgs.build(config: config) let args = try IccgamutArgs.build(config: config)
let binaryURL = binaryResolver.resolve("iccgamut")
let processId = ProcessID.iccgamut(stem: stem) let processId = ProcessID.iccgamut(stem: stem)
_ = try await runStreamingTool(
await ensureNotRunning(id: processId) name: "iccgamut",
let events = processManager.events()
try await processManager.runStreaming(
id: processId, id: processId,
binary: binaryURL,
arguments: args, 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") let gamURL = cwd.appendingPathComponent("\(stem).gam")
guard FileManager.default.fileExists(atPath: gamURL.path) else { return try requireArtefact(gamURL)
throw ArgyllRunnerError.missingArtefact(gamURL.path)
}
return gamURL
} }
// MARK: - profcheck (Stage 5 verification) // MARK: - profcheck (Stage 5 verification)
@@ -539,30 +527,18 @@ public struct ArgyllRunner: Sendable {
let cwd = config.ti3URL.deletingLastPathComponent() let cwd = config.ti3URL.deletingLastPathComponent()
let ti3Path = config.ti3URL.path 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 config = ProfcheckConfig(ti3URL: config.ti3URL, iccURL: iccURL)
let args = try ProfcheckArgs.build(config: config) let args = try ProfcheckArgs.build(config: config)
let binaryURL = binaryResolver.resolve("profcheck")
let processId = ProcessID.profcheck(ti3Path: ti3Path) let processId = ProcessID.profcheck(ti3Path: ti3Path)
let run = try await runStreamingTool(
await ensureNotRunning(id: processId) name: "profcheck",
let events = processManager.events()
try await processManager.runStreaming(
id: processId, id: processId,
binary: binaryURL,
arguments: args, 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 output = (run.stdout + "\n" + run.stderr).trimmingCharacters(in: .whitespacesAndNewlines)
let report = ProfcheckParser.parse(output) let report = ProfcheckParser.parse(output)
@@ -572,15 +548,6 @@ public struct ArgyllRunner: Sendable {
return report 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) // MARK: - chartread (Stage 3 interactive)
/// Runs `chartread` and returns an `AsyncStream` of typed events. /// Runs `chartread` and returns an `AsyncStream` of typed events.
@@ -596,7 +563,7 @@ public struct ArgyllRunner: Sendable {
cwd = PathSecurity.resolveSafeCwd(config.workingDirectory) cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
} catch { } catch {
return AsyncStream { continuation in 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() continuation.finish()
} }
} }
@@ -606,7 +573,7 @@ public struct ArgyllRunner: Sendable {
args = try ChartreadArgs.build(config: config) args = try ChartreadArgs.build(config: config)
} catch { } catch {
return AsyncStream { continuation in 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() continuation.finish()
} }
} }
@@ -625,7 +592,7 @@ public struct ArgyllRunner: Sendable {
await processManager.setPreKillHook(id: processId) { [processManager] in await processManager.setPreKillHook(id: processId) { [processManager] in
if isXY { if isXY {
try? await processManager.sendStdin(id: processId, bytes: ChartreadInput.quit.bytes) 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 workingDirectory: cwd
) )
} catch { } catch {
continuation.yield(.failed(ArgyllRunnerError.chartreadFailed(error.localizedDescription))) continuation.yield(.failed(ArgyllRunnerError.toolFailed(tool: "chartread", code: -1, logs: [error.localizedDescription])))
continuation.finish() continuation.finish()
return return
} }
@@ -724,7 +691,11 @@ public struct ArgyllRunner: Sendable {
continuation.yield(.failed(ArgyllRunnerError.missingArtefact(canonical.path))) continuation.yield(.failed(ArgyllRunnerError.missingArtefact(canonical.path)))
} }
} else { } 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() continuation.finish()
} }
@@ -768,30 +739,19 @@ public struct ArgyllRunner: Sendable {
) async throws -> URL { ) async throws -> URL {
let args = try CalibrationTargenArgs.build(config: config) let args = try CalibrationTargenArgs.build(config: config)
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory) let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
let calBasename = config.basename.hasPrefix("CAL_") ? config.basename : "CAL_\(config.basename)" let cleanBasename = try PathSecurity.sanitizeBasename(
let cleanBasename = try PathSecurity.sanitizeBasename(calBasename) CalibrationIdentity.prefix(config.basename)
let binaryURL = binaryResolver.resolve("targen") )
let processId = ProcessID.targen(cleanBasename) let processId = ProcessID.targen(cleanBasename)
_ = try await runStreamingTool(
await ensureNotRunning(id: processId) name: "targen",
let events = processManager.events() id: processId,
try await processManager.runStreaming( arguments: args,
id: processId, workingDirectory: cwd,
binary: binaryURL, onLogBatch: onLogBatch
arguments: args,
workingDirectory: cwd
) )
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") let ti1URL = cwd.appendingPathComponent("\(cleanBasename).ti1")
guard FileManager.default.fileExists(atPath: ti1URL.path) else { return try requireArtefact(ti1URL)
throw ArgyllRunnerError.missingArtefact(ti1URL.path)
}
return ti1URL
} }
/// Computes a `.cal` curve from a measured `CAL_*.ti3`. /// 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 args = try PrintcalArgs.build(config: config)
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory) let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
let binaryURL = binaryResolver.resolve("printcal") 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) let processId = ProcessID.printcal(calBasename)
await ensureNotRunning(id: processId) await ensureNotRunning(id: processId)
@@ -821,10 +781,12 @@ public struct ArgyllRunner: Sendable {
} }
guard result.exitCode == 0 else { guard result.exitCode == 0 else {
throw ArgyllRunnerError.printcalFailed( throw ArgyllRunnerError.toolFailed(
result.stderr.isEmpty tool: "printcal",
code: result.exitCode,
logs: [result.stderr.isEmpty
? "printcal exited with code \(result.exitCode)" ? "printcal exited with code \(result.exitCode)"
: result.stderr : result.stderr]
) )
} }
@@ -56,23 +56,19 @@ public enum PrinttargArgs {
} }
args.append(contentsOf: ["-R", "\(config.customSeed)"]) args.append(contentsOf: ["-R", "\(config.customSeed)"])
case .raster: case .raster:
args.append("-r") args.append(contentsOf: ArgsBuilder.flag("-r", when: true))
} }
if let label = config.label?.trimmingCharacters(in: .whitespacesAndNewlines), args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-d", config.label))
!label.isEmpty {
args.append(contentsOf: ["-d", label])
}
guard (72...600).contains(config.dpi) else { guard (72...600).contains(config.dpi) else {
throw PrinttargArgError.invalidDPI(config.dpi) throw PrinttargArgError.invalidDPI(config.dpi)
} }
args.append(contentsOf: [config.bitDepth.flag, "\(config.dpi)"]) args.append(contentsOf: [config.bitDepth.flag, "\(config.dpi)"])
if !cleanBasename.hasPrefix("CAL_"), if !CalibrationIdentity.isCalibration(cleanBasename) {
let cal = config.calibrationFile?.trimmingCharacters(in: .whitespacesAndNewlines), args.append(contentsOf: ArgsBuilder.optionIfNonEmpty(
!cal.isEmpty { config.calibrationEmbedOnly ? "-I" : "-K", config.calibrationFile))
args.append(contentsOf: [config.calibrationEmbedOnly ? "-I" : "-K", cal])
} }
args.append(cleanBasename) args.append(cleanBasename)
@@ -70,18 +70,12 @@ public enum TargenArgs {
if let n = config.neutralSteps, n > 0 { if let n = config.neutralSteps, n > 0 {
args.append(contentsOf: ["-n", "\(n)"]) args.append(contentsOf: ["-n", "\(n)"])
} }
if let nConc = config.neutralConcentration, abs(nConc - 0.50) >= 0.001 { args.append(contentsOf: ArgsBuilder.optionUnlessApprox("-N", config.neutralConcentration, skip: 0.50))
args.append(contentsOf: ["-N", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), nConc)]) args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-c", config.preconditioningProfile))
} args.append(contentsOf: ArgsBuilder.flag("-G", when: config.ofpsHighQuality == true))
if let c = config.preconditioningProfile?.trimmingCharacters(in: .whitespacesAndNewlines), !c.isEmpty { args.append(contentsOf: ArgsBuilder.option("-A", config.ofpsAdaptation.map {
args.append(contentsOf: ["-c", c]) String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), $0)
} }))
if config.ofpsHighQuality == true {
args.append("-G")
}
if let a = config.ofpsAdaptation {
args.append(contentsOf: ["-A", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), a)])
}
if let algFlag = config.fullSpreadAlgorithm?.flag { if let algFlag = config.fullSpreadAlgorithm?.flag {
args.append(algFlag) args.append(algFlag)
} }
@@ -91,11 +85,9 @@ public enum TargenArgs {
} }
args.append(contentsOf: ["-l", "\(inkLimit)"]) args.append(contentsOf: ["-l", "\(inkLimit)"])
} }
if let v = config.darkEmphasis, abs(v - 1.0) >= 0.001 { args.append(contentsOf: ArgsBuilder.optionUnlessApprox("-V", config.darkEmphasis, skip: 1.0))
args.append(contentsOf: ["-V", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), v)]) if let p = config.devicePower, p > 0 {
} args.append(contentsOf: ArgsBuilder.optionUnlessApprox("-p", p, skip: 1.0))
if let p = config.devicePower, p > 0, abs(p - 1.0) >= 0.001 {
args.append(contentsOf: ["-p", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), p)])
} }
args.append(cleanBasename) args.append(cleanBasename)
@@ -72,8 +72,8 @@ public enum CGATSParser {
public static func parse( public static func parse(
_ contents: String, _ contents: String,
sourceURL: URL? = nil sourceURL: URL? = nil
) throws(CGATSParseError) -> CGATSDataset { ) throws -> CGATSDataset {
guard !contents.isEmpty else { throw .emptyFile } guard !contents.isEmpty else { throw CGATSParseError.emptyFile }
let ext = sourceURL?.pathExtension.lowercased() ?? "" let ext = sourceURL?.pathExtension.lowercased() ?? ""
let isCSV = ext == "csv" || contents.trimmingCharacters(in: .whitespacesAndNewlines) let isCSV = ext == "csv" || contents.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -101,10 +101,10 @@ public enum CGATSParser {
} }
guard let formatStart, let formatEnd, formatEnd > formatStart + 1 else { guard let formatStart, let formatEnd, formatEnd > formatStart + 1 else {
throw .missingBeginDataFormat throw CGATSParseError.missingBeginDataFormat
} }
guard let dataStart, let dataEnd, dataEnd > dataStart + 1 else { guard let dataStart, let dataEnd, dataEnd > dataStart + 1 else {
throw .missingBeginData throw CGATSParseError.missingBeginData
} }
let rawFieldNames = splitFields(lines[formatStart + 1]) let rawFieldNames = splitFields(lines[formatStart + 1])
@@ -139,7 +139,7 @@ public enum CGATSParser {
let lineIndex = dataStart + offset let lineIndex = dataStart + offset
let rawRow = splitFields(lines[lineIndex]) let rawRow = splitFields(lines[lineIndex])
guard rawRow.count == fieldNames.count else { 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) 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) groupMax[group, default: 0] = max(groupMax[group, default: 0], number)
} }
} else if !cleaned.isEmpty { } else if !cleaned.isEmpty {
throw .nonNumericValue(field: name, value: raw, line: lineIndex + 1) throw CGATSParseError.nonNumericValue(field: name, value: raw, line: lineIndex + 1)
} }
} else { } else {
sample.strings[name] = raw sample.strings[name] = raw
@@ -213,7 +213,7 @@ public enum CGATSParser {
private static func preprocess( private static func preprocess(
_ contents: String, _ contents: String,
isCSV: Bool isCSV: Bool
) throws(CGATSParseError) -> (CGATSFormat, [String]) { ) throws -> (CGATSFormat, [String]) {
let allLines = contents.components(separatedBy: .newlines) let allLines = contents.components(separatedBy: .newlines)
var lines = [String]() var lines = [String]()
@@ -239,7 +239,7 @@ public enum CGATSParser {
lines.append(line) 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 // Wrap a bare CSV / ISO28178 file in the canonical CGATS block
// structure so the boundary-based parser below can handle it. // structure so the boundary-based parser below can handle it.
@@ -78,6 +78,21 @@ public enum ArtefactProbe {
return nil 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). /// Default extension for a *new* profile on macOS (#69).
public static let defaultProfileExtension = "icc" 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 struct SpectralData: Codable, Sendable, Equatable {
public let bands: Int public let bands: Int
public let startNM: Double 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. /// Resolve a Lab from a `PatchColor`, computing it from XYZ when Lab is absent.
public static func resolveLab(_ color: PatchColor) -> LabColor? { public static func resolveLab(_ color: PatchColor) -> LabColor? {
if let lab = color.lab { if let lab = color.lab { return lab }
return LabColor(l: lab.l, a: lab.a, b: lab.b)
}
guard let xyz = color.xyz else { return nil } 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 { private static func atan2ToDegrees(_ y: Double, _ x: Double) -> Double {
@@ -1,7 +1,8 @@
import Foundation import Foundation
/// XYZ tristimulus values, stored in the 0100 scale used by the Argyll fork. /// XYZ tristimulus values, stored in the 0100 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 x: Double
public let y: Double public let y: Double
public let z: Double public let z: Double
@@ -11,10 +12,24 @@ public struct XYZColor: Sendable, Equatable {
self.y = y self.y = y
self.z = z 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). /// CIELab value (D50). Unkeyed Codable matches `ROW_COLORS_JSON` `[L, a, b]`.
public struct LabColor: Sendable, Equatable { public struct LabColor: Codable, Sendable, Equatable {
public let l: Double public let l: Double
public let a: Double public let a: Double
public let b: Double public let b: Double
@@ -24,8 +39,26 @@ public struct LabColor: Sendable, Equatable {
self.a = a self.a = a
self.b = b 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 01 display space. /// sRGB colour in 01 display space.
public struct DisplayRGB: Sendable, Equatable { public struct DisplayRGB: Sendable, Equatable {
public let r: Double public let r: Double
@@ -136,31 +136,26 @@ public actor ProcessManager {
) throws { ) throws {
guard !isRunning(id) else { throw ProcessError.duplicateID(id) } guard !isRunning(id) else { throw ProcessError.duplicateID(id) }
let process = Process() let prepared = makeProcess(
let stdinPipe = Pipe() binary: binary,
let stdoutPipe = Pipe() arguments: arguments,
let stderrPipe = Pipe() workingDirectory: workingDirectory,
process.executableURL = binary environment: environment,
process.arguments = arguments includeStdin: true
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 process = prepared.process
logSpawn(id: id, binary: binary, arguments: arguments, captured: false)
children[id] = RunningChild( children[id] = RunningChild(
process: process, process: process,
stdin: stdinPipe.fileHandleForWriting, stdin: prepared.stdinPipe?.fileHandleForWriting,
stdoutDecoder: ProcessLineDecoder(), stdoutDecoder: ProcessLineDecoder(),
stderrDecoder: ProcessLineDecoder() stderrDecoder: ProcessLineDecoder()
) )
let stdoutHandle = stdoutPipe.fileHandleForReading let stdoutHandle = prepared.stdoutPipe.fileHandleForReading
let stderrHandle = stderrPipe.fileHandleForReading let stderrHandle = prepared.stderrPipe.fileHandleForReading
stdoutHandle.readabilityHandler = { [weak self] handle in stdoutHandle.readabilityHandler = { [weak self] handle in
let data = handle.availableData let data = handle.availableData
guard let self else { return } guard let self else { return }
@@ -172,27 +167,23 @@ public actor ProcessManager {
Task { await self.ingestOutput(data, id: id, isStderr: true, handle: handle) } 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 } guard let self else { return }
Task { await self.didTerminate(id: id, code: proc.terminationStatus) } Task { await self.didTerminate(id: id, code: code) }
} }
do { do {
try process.run() 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 { } catch {
preKillHooks.removeValue(forKey: id) preKillHooks.removeValue(forKey: id)
children.removeValue(forKey: id) children.removeValue(forKey: id)
emit(.error(id: id, message: error.localizedDescription)) emit(.error(id: id, message: error.localizedDescription))
throw ProcessError.spawnFailed("\(binary.path): \(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) // MARK: - Spawn (captured)
@@ -210,19 +201,18 @@ public actor ProcessManager {
) async throws -> CapturedResult { ) async throws -> CapturedResult {
guard !isRunning(id) else { throw ProcessError.duplicateID(id) } guard !isRunning(id) else { throw ProcessError.duplicateID(id) }
let process = Process() let prepared = makeProcess(
let stdoutPipe = Pipe() binary: binary,
let stderrPipe = Pipe() arguments: arguments,
process.executableURL = binary workingDirectory: workingDirectory,
process.arguments = arguments environment: environment,
process.currentDirectoryURL = workingDirectory includeStdin: false
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
process.environment = childEnvironment(extra: environment)
AppLogger(category: "process").debug(
"spawn(captured) \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))"
) )
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 // Register and set up the termination hand-off before run() so
// a very fast exit is never missed (#50, #52). // a very fast exit is never missed (#50, #52).
@@ -275,20 +265,12 @@ public actor ProcessManager {
} }
} }
let box = Box() let box = Box()
capturedProcess.terminationHandler = { proc in attachTerminationHandler(capturedProcess) { status in
_ = box.resume(with: proc.terminationStatus) _ = box.resume(with: status)
} }
do { do {
try process.run() 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 { } catch {
_ = box.resume(with: -1) _ = box.resume(with: -1)
captured.removeValue(forKey: id) captured.removeValue(forKey: id)
@@ -296,6 +278,9 @@ public actor ProcessManager {
emit(.error(id: id, message: error.localizedDescription)) emit(.error(id: id, message: error.localizedDescription))
throw ProcessError.spawnFailed("\(binary.path): \(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 // Close the parent write ends so readDataToEndOfFile() gets EOF
// as soon as the child exits; the child still has its own copies. // 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 } guard var child = children[id], !child.finalized else { return }
if let tail = child.stdoutDecoder.flushPartial() { if let tail = child.stdoutDecoder.flushPartial() {
if tail.hasPrefix(Self.rowColorsPrefix) { emitStdoutLine(id: id, line: tail)
let payload = Data(tail.dropFirst(Self.rowColorsPrefix.count).utf8)
emit(.jsonRow(id: id, payload: payload))
} else {
emit(.stdout(id: id, line: tail))
}
} }
if let tail = child.stderrDecoder.flushPartial() { if let tail = child.stderrDecoder.flushPartial() {
emit(.stderr(id: id, line: tail)) emit(.stderr(id: id, line: tail))
@@ -447,6 +427,86 @@ public actor ProcessManager {
// MARK: - Internals // 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] { private func childEnvironment(extra: [String: String]) -> [String: String] {
var env = ProcessInfo.processInfo.environment var env = ProcessInfo.processInfo.environment
env["ARGYLL_NOT_INTERACTIVE"] = "1" env["ARGYLL_NOT_INTERACTIVE"] = "1"
@@ -478,15 +538,14 @@ public actor ProcessManager {
let log = AppLogger(category: "subprocess") let log = AppLogger(category: "subprocess")
for line in lines { for line in lines {
if !isStderr, line.hasPrefix(Self.rowColorsPrefix) { if !isStderr {
let payload = Data(line.dropFirst(Self.rowColorsPrefix.count).utf8) emitStdoutLine(id: id, line: line)
emit(.jsonRow(id: id, payload: payload)) if !line.hasPrefix(Self.rowColorsPrefix) {
} else if isStderr { log.info("[\(id)] \(line)")
}
} else {
log.warn("[\(id)] \(line)") log.warn("[\(id)] \(line)")
emit(.stderr(id: id, line: 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 // Start a watchdog in case the `readabilityHandler` EOFs never
// arrive after the process exits (e.g. a hung pipe). // arrive after the process exits (e.g. a hung pipe).
child.finalizeTask = Task { [weak self] in 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 } guard let self else { return }
await self.forceKill(id: id) await self.forceKill(id: id)
await self.forceFinalize(id: id) await self.forceFinalize(id: id)
@@ -528,11 +587,7 @@ public actor ProcessManager {
// Flush unterminated tail lines. // Flush unterminated tail lines.
if var decoder = Optional(child.stdoutDecoder), if var decoder = Optional(child.stdoutDecoder),
let tail = decoder.finish() { let tail = decoder.finish() {
if tail.hasPrefix(Self.rowColorsPrefix) { emitStdoutLine(id: id, line: tail)
emit(.jsonRow(id: id, payload: Data(tail.dropFirst(Self.rowColorsPrefix.count).utf8)))
} else {
emit(.stdout(id: id, line: tail))
}
} }
if var decoder = Optional(child.stderrDecoder), if var decoder = Optional(child.stderrDecoder),
let tail = decoder.finish() { 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)"]) args.append(contentsOf: ["-l", "\(inkLimit)"])
} }
let calBasename = cleanBasename.hasPrefix("CAL_") ? cleanBasename : "CAL_\(cleanBasename)" let calBasename = CalibrationIdentity.prefix(cleanBasename)
args.append(calBasename) args.append(calBasename)
return args return args
} }
@@ -26,18 +26,13 @@ public enum ColprofArgs {
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename) let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
var args: [String] = ["-v"] var args: [String] = ["-v"]
args.append(contentsOf: ["-a", config.algorithm]) args.append(contentsOf: ["-a", config.algorithm])
args.append(contentsOf: ["-q", config.quality]) args.append(contentsOf: ["-q", config.quality])
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-t", config.intent))
if let intent = config.intent?.trimmingCharacters(in: .whitespaces), !intent.isEmpty {
args.append(contentsOf: ["-t", intent])
}
if let fwa = config.fwa?.trimmingCharacters(in: .whitespaces) { if let fwa = config.fwa?.trimmingCharacters(in: .whitespaces) {
switch fwa.lowercased() { switch fwa.lowercased() {
case "none", "": case "none", "":
// "none" omits the flag; an explicit empty string means bare -f.
if fwa.isEmpty { if fwa.isEmpty {
args.append("-f") args.append("-f")
} }
@@ -46,32 +41,20 @@ public enum ColprofArgs {
} }
} }
if let illuminant = config.illuminant?.trimmingCharacters(in: .whitespaces), !illuminant.isEmpty { args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-i", config.illuminant))
args.append(contentsOf: ["-i", illuminant]) args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-o", config.observer))
}
if let observer = config.observer?.trimmingCharacters(in: .whitespaces), !observer.isEmpty {
args.append(contentsOf: ["-o", observer])
}
if let inputCond = config.inputViewingCond?.trimmingCharacters(in: .whitespaces), if let inputCond = config.inputViewingCond?.trimmingCharacters(in: .whitespaces),
!inputCond.isEmpty, inputCond.lowercased() != "none" { !inputCond.isEmpty, inputCond.lowercased() != "none" {
args.append(contentsOf: ["-c", inputCond]) args.append(contentsOf: ["-c", inputCond])
} }
if let outputCond = config.outputViewingCond?.trimmingCharacters(in: .whitespaces), if let outputCond = config.outputViewingCond?.trimmingCharacters(in: .whitespaces),
!outputCond.isEmpty, outputCond.lowercased() != "none" { !outputCond.isEmpty, outputCond.lowercased() != "none" {
args.append(contentsOf: ["-d", outputCond]) args.append(contentsOf: ["-d", outputCond])
} }
let profileDescription = config.description?.trimmingCharacters(in: .whitespaces) args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-D", config.description))
if let description = profileDescription, !description.isEmpty { args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-C", config.copyright))
args.append(contentsOf: ["-D", description])
}
if let copyright = config.copyright?.trimmingCharacters(in: .whitespaces), !copyright.isEmpty {
args.append(contentsOf: ["-C", copyright])
}
args.append(cleanBasename) args.append(cleanBasename)
return args return args
@@ -79,16 +79,9 @@ public enum PrintcalArgs {
var args: [String] = ["-v", "-e"] var args: [String] = ["-v", "-e"]
if config.noInkLimit { args.append(contentsOf: ArgsBuilder.flag("-I", when: config.noInkLimit))
args.append("-I") args.append(contentsOf: ArgsBuilder.flag("-z", when: config.verify))
} args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-a", config.previousCalPath))
if config.verify {
args.append("-z")
}
if let previous = config.previousCalPath?.trimmingCharacters(in: .whitespacesAndNewlines),
!previous.isEmpty {
args.append(contentsOf: ["-a", previous])
}
if let tac = config.totalInkLimit, tac > 0 { if let tac = config.totalInkLimit, tac > 0 {
args.append(contentsOf: ["-m", String(format: "%.1f", tac)]) args.append(contentsOf: ["-m", String(format: "%.1f", tac)])
} else if let tac = config.totalInkLimit { } else if let tac = config.totalInkLimit {
@@ -107,7 +100,7 @@ public enum PrintcalArgs {
} }
args.append(contentsOf: ["-o", config.outputURL.path]) args.append(contentsOf: ["-o", config.outputURL.path])
let calBasename = cleanBasename.hasPrefix("CAL_") ? cleanBasename : "CAL_\(cleanBasename)" let calBasename = CalibrationIdentity.prefix(cleanBasename)
args.append(calBasename) args.append(calBasename)
return args return args
} }
@@ -13,8 +13,7 @@ public actor VerificationHistoryStore {
private var records: [VerificationRecord] = [] private var records: [VerificationRecord] = []
private let capacity: Int private let capacity: Int
private let encoder: JSONEncoder private let fileStore: JSONFileStore<[VerificationRecord]>
private let decoder: JSONDecoder
public init( public init(
url: URL = AppPaths.appDataDir.appendingPathComponent("verification_history.json"), url: URL = AppPaths.appDataDir.appendingPathComponent("verification_history.json"),
@@ -22,13 +21,13 @@ public actor VerificationHistoryStore {
) { ) {
self.url = url self.url = url
self.capacity = capacity self.capacity = capacity
self.fileStore = JSONFileStore(
self.encoder = JSONEncoder() fileURL: url,
self.encoder.dateEncodingStrategy = .iso8601 corrupt: .throwCorrupt,
self.encoder.outputFormatting = [.prettyPrinted, .sortedKeys] defaultValue: { [] },
dateEncoding: .iso8601,
self.decoder = JSONDecoder() dateDecoding: .iso8601
self.decoder.dateDecodingStrategy = .iso8601 )
} }
/// Loads records from disk. Returns the existing cache if already loaded. /// Loads records from disk. Returns the existing cache if already loaded.
@@ -37,10 +36,8 @@ public actor VerificationHistoryStore {
/// is never overwritten in that case. /// is never overwritten in that case.
public func load() throws -> [VerificationRecord] { public func load() throws -> [VerificationRecord] {
guard records.isEmpty else { return records } guard records.isEmpty else { return records }
let fm = FileManager.default guard FileManager.default.fileExists(atPath: url.path) else { return [] }
guard fm.fileExists(atPath: url.path), records = try fileStore.load()
let data = try? Data(contentsOf: url) else { return [] }
records = try decoder.decode([VerificationRecord].self, from: data)
return records return records
} }
@@ -76,7 +73,11 @@ public actor VerificationHistoryStore {
} }
/// Removes all history and updates disk. /// 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 { public func clear() throws {
try load()
try write([]) try write([])
records = [] records = []
} }
@@ -106,8 +107,7 @@ public actor VerificationHistoryStore {
/// Writes `records` through a temp file and rename. /// Writes `records` through a temp file and rename.
private func write(_ records: [VerificationRecord]) throws { private func write(_ records: [VerificationRecord]) throws {
let data = try encoder.encode(records) try fileStore.save(records)
try AtomicFileWriter.write(data, to: url)
} }
private func csvRow(_ fields: [String]) -> String { 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. /// Single-preset pretty JSON export.
public func export(_ preset: ProfilingPreset) throws -> Data { public func export(_ preset: ProfilingPreset) throws -> Data {
let encoder = JSONEncoder() return try JSONEncoder.icceryPretty().encode(preset)
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
return try encoder.encode(preset)
} }
/// Parses + validates a preset from JSON. The preset is assigned a /// 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` /// `~/Library/Application Support/com.gronod.iccery2/settings.json`
/// (issue #5 the v1 path is never read). /// (issue #5 the v1 path is never read).
/// ///
/// Writes are atomic (`AtomicFileWriter`). Invalid/corrupt JSON falls /// Writes are atomic (`JSONFileStore` `AtomicFileWriter`). Invalid/corrupt
/// back to defaults. Saving posts `settingsDidChange` so #20 can /// JSON falls back to defaults. Saving posts `settingsDidChange` so #20 can
/// reclassify swatches. /// reclassify swatches.
public final class SettingsStore: Sendable { public final class SettingsStore: Sendable {
@@ -14,18 +14,19 @@ public final class SettingsStore: Sendable {
Notification.Name("com.gronod.iccery2.settingsDidChange") Notification.Name("com.gronod.iccery2.settingsDidChange")
public let fileURL: URL public let fileURL: URL
private let store: JSONFileStore<AppSettings>
public init(fileURL: URL = AppPaths.appDataDir.appendingPathComponent("settings.json")) { public init(fileURL: URL = AppPaths.appDataDir.appendingPathComponent("settings.json")) {
self.fileURL = fileURL self.fileURL = fileURL
self.store = JSONFileStore(
fileURL: fileURL,
corrupt: .replaceWithDefault,
defaultValue: { .default }
)
} }
public func load() -> AppSettings { public func load() -> AppSettings {
guard let data = try? Data(contentsOf: fileURL), (try? store.load()) ?? .default
let settings = try? JSONDecoder().decode(AppSettings.self, from: data)
else {
return .default
}
return settings
} }
/// Validates before persisting throws `SettingsError` listing /// Validates before persisting throws `SettingsError` listing
@@ -35,9 +36,7 @@ public final class SettingsStore: Sendable {
guard errors.isEmpty else { guard errors.isEmpty else {
throw SettingsError.validationFailed(errors) throw SettingsError.validationFailed(errors)
} }
let encoder = JSONEncoder() try store.save(settings)
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
try AtomicFileWriter.write(encoder.encode(settings), to: fileURL)
NotificationCenter.default.post(name: Self.settingsDidChange, object: nil) 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). /// Atomic JSON persistence for `WizardState` (issue #4).
public final class WizardStateStore: Sendable { public final class WizardStateStore: Sendable {
public let fileURL: URL public let fileURL: URL
private let store: JSONFileStore<WizardState>
public init( public init(
fileURL: URL = AppPaths.appDataDir.appendingPathComponent("wizard_state.json") fileURL: URL = AppPaths.appDataDir.appendingPathComponent("wizard_state.json")
) { ) {
self.fileURL = fileURL self.fileURL = fileURL
self.store = JSONFileStore(
fileURL: fileURL,
corrupt: .replaceWithDefault,
defaultValue: { .default }
)
} }
public func load() -> WizardState { public func load() -> WizardState {
guard let data = try? Data(contentsOf: fileURL), (try? store.load()) ?? .default
let state = try? JSONDecoder().decode(WizardState.self, from: data)
else { return .default }
return state
} }
public func save(_ state: WizardState) throws { public func save(_ state: WizardState) throws {
let encoder = JSONEncoder() try store.save(state)
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
try AtomicFileWriter.write(encoder.encode(state), to: fileURL)
} }
} }
+4 -3
View File
@@ -11,7 +11,8 @@ All measurement, chart generation, and profile mathematics live in the [Gronod A
| Floor | macOS 14 Sonoma, universal `arm64` + `x86_64` | | Floor | macOS 14 Sonoma, universal `arm64` + `x86_64` |
| Default branch | `develop` | | Default branch | `develop` |
| M6 | Stage 0 calibration, CGATS import, SceneKit gamut viewer, packaging — shipped on `develop` | | M6 | Stage 0 calibration, CGATS import, SceneKit gamut viewer, packaging — shipped on `develop` |
| M7 | 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 | | Licence | Proprietary source in [`LICENCE.md`](LICENCE.md); bundled Argyll sidecars remain AGPLv3 |
## What it does ## What it does
@@ -170,11 +171,11 @@ Agent / branch rules: [`AGENTS.md`](AGENTS.md), [`BUILD-PLAN.md`](BUILD-PLAN.md)
``` ```
develop develop
└── milestone/mN-<slug> # integration only └── milestone/m8-consolidation # integration branch
└── feat/<issue>-<slug> # one issue per 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 ## Licence
+7 -9
View File
@@ -4,6 +4,7 @@ import ICCeryCore
/// Stage 0 calibration dashboard (issue #29, docs/07). /// Stage 0 calibration dashboard (issue #29, docs/07).
struct CalibrationView: View { struct CalibrationView: View {
@Bindable var model: CalibrationViewModel @Bindable var model: CalibrationViewModel
@Bindable var wizard: WizardViewModel
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
@@ -51,11 +52,15 @@ struct CalibrationView: View {
HStack(spacing: 12) { HStack(spacing: 12) {
Button("Generate Target") { model.generateTarget() } Button("Generate Target") { model.generateTarget() }
.accessibilityIdentifier("btnCalGenerate") .accessibilityIdentifier("btnCalGenerate")
.disabled(!model.canGenerate) .disabled(wizard.basename.isEmpty
|| wizard.effectiveWorkingDirectory == nil
|| model.isGenerating)
Button("Create Layout & Print") { model.createLayout() } Button("Create Layout & Print") { model.createLayout() }
.accessibilityIdentifier("btnCalLayout") .accessibilityIdentifier("btnCalLayout")
.disabled(!model.canGenerate) .disabled(wizard.basename.isEmpty
|| wizard.effectiveWorkingDirectory == nil
|| model.isGenerating)
Button("Measure") { model.measureChart() } Button("Measure") { model.measureChart() }
.accessibilityIdentifier("btnCalMeasure") .accessibilityIdentifier("btnCalMeasure")
@@ -90,13 +95,6 @@ struct CalibrationView: View {
.frame(minHeight: 80, maxHeight: 120) .frame(minHeight: 80, maxHeight: 120)
} }
} }
if let error = model.lastError {
Section {
Text(error)
.foregroundStyle(.red)
}
}
} }
.formStyle(.grouped) .formStyle(.grouped)
+32 -39
View File
@@ -25,7 +25,6 @@ final class CalibrationViewModel {
var calibrationLog: [String] = [] var calibrationLog: [String] = []
var isGenerating = false var isGenerating = false
var isComputing = false var isComputing = false
var lastError: String?
init(workflow: TargetWorkflowViewModel, profile: ProfileWorkflowViewModel, environment: AppEnvironment) { init(workflow: TargetWorkflowViewModel, profile: ProfileWorkflowViewModel, environment: AppEnvironment) {
self.workflow = workflow self.workflow = workflow
@@ -50,14 +49,15 @@ final class CalibrationViewModel {
return cwd.appendingPathComponent("\(calBasename).ti3") return cwd.appendingPathComponent("\(calBasename).ti3")
} }
private var calBasename: String { private var identity: CalibrationIdentity {
if wizard.basename.hasPrefix("CAL_") { return wizard.basename } CalibrationIdentity.parse(
let original = !wizard.calibrationOriginalBasename.isEmpty liveBasename: wizard.basename,
? wizard.calibrationOriginalBasename persistedOriginal: wizard.calibrationOriginalBasename
: wizard.basename )
return "CAL_\(original)"
} }
private var calBasename: String { identity.calibrationBasename }
private var calOutputURL: URL? { private var calOutputURL: URL? {
guard let cwd = wizard.effectiveWorkingDirectory else { return nil } guard let cwd = wizard.effectiveWorkingDirectory else { return nil }
return cwd.appendingPathComponent("\(calBasename).cal") return cwd.appendingPathComponent("\(calBasename).cal")
@@ -68,43 +68,38 @@ final class CalibrationViewModel {
func generateTarget() { func generateTarget() {
guard canGenerate, let cwd = wizard.effectiveWorkingDirectory else { return } guard canGenerate, let cwd = wizard.effectiveWorkingDirectory else { return }
// Snapshot the original (pre-CAL_) basename before changing the live one. // Snapshot the original (pre-CAL_) basename before changing the live one.
if !wizard.basename.hasPrefix("CAL_") { let identity = CalibrationIdentity.parse(
wizard.calibrationOriginalBasename = wizard.basename liveBasename: wizard.basename,
} else if wizard.calibrationOriginalBasename.isEmpty { persistedOriginal: wizard.calibrationOriginalBasename
wizard.calibrationOriginalBasename = String(wizard.basename.dropFirst(4)) )
} wizard.calibrationOriginalBasename = identity.originalBasename
let original = wizard.calibrationOriginalBasename wizard.basename = identity.calibrationBasename
wizard.basename = "CAL_\(original)"
wizard.sessionMode = .calibration wizard.sessionMode = .calibration
isGenerating = true
calibrationLog = []
lastError = nil
let config = CalibrationTargenConfig( let config = CalibrationTargenConfig(
colourSpace: colourSpace, colourSpace: colourSpace,
steps: steps, steps: steps,
whitePatches: whitePatches, whitePatches: whitePatches,
includeNeutralEmphasis: includeNeutralEmphasis, includeNeutralEmphasis: includeNeutralEmphasis,
inkLimit: inkLimitValue, inkLimit: inkLimitValue,
basename: original, basename: identity.originalBasename,
workingDirectory: cwd workingDirectory: cwd
) )
Task { @MainActor in Task { @MainActor in
defer { self.isGenerating = false }
do { do {
_ = try await self.environment.runner.runCalibrationTargen(config: config) { batch in _ = try await ProcessRunSupport.runLogged(
Task { @MainActor [weak self] in setRunning: { self.isGenerating = $0 },
self?.calibrationLog.append(contentsOf: batch) 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.refreshGating()
self.wizard.showNotice("Calibration target generated.") self.wizard.showNotice("Calibration target generated.")
self.wizard.go(to: .layOutPrint) self.wizard.go(to: .layOutPrint)
} catch { } catch {
self.lastError = error.localizedDescription
self.wizard.showNotice( self.wizard.showNotice(
"Calibration target failed: \(error.localizedDescription)", "Calibration target failed: \(error.localizedDescription)",
kind: .error kind: .error
@@ -139,15 +134,13 @@ final class CalibrationViewModel {
// "already exists" when the user declines overwrite. We do not // "already exists" when the user declines overwrite. We do not
// silently clobber. // silently clobber.
if FileManager.default.fileExists(atPath: outputURL.path) { if FileManager.default.fileExists(atPath: outputURL.path) {
lastError = "\(outputURL.lastPathComponent) already exists. Rename or overwrite it first." wizard.showNotice(
wizard.showNotice(lastError!, kind: .error) "\(outputURL.lastPathComponent) already exists. Rename or overwrite it first.",
kind: .error
)
return return
} }
isComputing = true
calibrationLog = []
lastError = nil
let config = PrintcalConfig( let config = PrintcalConfig(
ti3Basename: calBasename, ti3Basename: calBasename,
workingDirectory: cwd, workingDirectory: cwd,
@@ -160,13 +153,14 @@ final class CalibrationViewModel {
) )
Task { @MainActor in Task { @MainActor in
defer { self.isComputing = false }
do { do {
let url = try await self.environment.runner.runPrintcal(config: config) { batch in let url = try await ProcessRunSupport.runLogged(
Task { @MainActor [weak self] in setRunning: { self.isComputing = $0 },
self?.calibrationLog.append(contentsOf: batch) 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.computedCalURL = url
self.profile.calibrationFile = url.path self.profile.calibrationFile = url.path
@@ -174,7 +168,6 @@ final class CalibrationViewModel {
self.wizard.showNotice("Calibration curves computed.") self.wizard.showNotice("Calibration curves computed.")
self.wizard.restoreCalibration() self.wizard.restoreCalibration()
} catch { } catch {
self.lastError = error.localizedDescription
self.wizard.showNotice( self.wizard.showNotice(
"Calibration curve computation failed: \(error.localizedDescription)", "Calibration curve computation failed: \(error.localizedDescription)",
kind: .error kind: .error
+20 -20
View File
@@ -29,13 +29,8 @@ final class FileDialogService {
/// `selectTargetFile` **save** panel for the new `.ti1` target. /// `selectTargetFile` **save** panel for the new `.ti1` target.
func selectTargetFile(startingAt start: URL? = nil) -> URL? { func selectTargetFile(startingAt start: URL? = nil) -> URL? {
let panel = NSSavePanel() save(named: "target.ti1", extensions: ["ti1"], startingAt: start,
panel.nameFieldStringValue = "target.ti1" message: "Choose the .ti1 target file to create")
panel.allowedContentTypes = utTypes(["ti1"])
panel.allowsOtherFileTypes = false
panel.directoryURL = start
panel.message = "Choose the .ti1 target file to create"
return run(panel)
} }
/// `selectExistingTarget` open `.ti1`/`.ti2` (docs/06 §Resume, #140). /// `selectExistingTarget` open `.ti1`/`.ti2` (docs/06 §Resume, #140).
@@ -66,12 +61,7 @@ final class FileDialogService {
/// `selectCsvSavePath` verification-history CSV export. /// `selectCsvSavePath` verification-history CSV export.
func selectCsvSavePath(startingAt start: URL? = nil) -> URL? { func selectCsvSavePath(startingAt start: URL? = nil) -> URL? {
let panel = NSSavePanel() save(named: "verification-history.csv", extensions: ["csv"], startingAt: start)
panel.nameFieldStringValue = "verification-history.csv"
panel.allowedContentTypes = utTypes(["csv"])
panel.allowsOtherFileTypes = false
panel.directoryURL = start
return run(panel)
} }
/// `selectCalFile` `.cal` calibration curves. /// `selectCalFile` `.cal` calibration curves.
@@ -88,17 +78,27 @@ final class FileDialogService {
/// `btnExportActivePreset` save a `.json` preset file. /// `btnExportActivePreset` save a `.json` preset file.
func selectPresetSavePath(name: String, startingAt start: URL? = nil) -> URL? { func selectPresetSavePath(name: String, startingAt start: URL? = nil) -> URL? {
let panel = NSSavePanel() save(named: "\(name).json", extensions: ["json"], startingAt: start,
panel.nameFieldStringValue = "\(name).json" message: "Export this preset as JSON")
panel.allowedContentTypes = utTypes(["json"])
panel.allowsOtherFileTypes = false
panel.directoryURL = start
panel.message = "Export this preset as JSON"
return run(panel)
} }
// MARK: - Internals (private not a shared public picker API) // 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( private func open(
extensions: [String], extensions: [String],
startingAt start: URL?, startingAt start: URL?,
+11
View File
@@ -36,6 +36,17 @@ struct ICCeryApp: App {
final class AppDelegate: NSObject, NSApplicationDelegate { final class AppDelegate: NSObject, NSApplicationDelegate {
private var terminationRequested = false private var terminationRequested = false
func applicationDidFinishLaunching(_ notification: Notification) {
// SwiftUI `Window` scenes launched by XCTest stay
// `.runningBackground` unless the app takes regular activation
// and orders the window front (CI run 29804).
NSApp.setActivationPolicy(.regular)
for window in NSApp.windows {
window.makeKeyAndOrderFront(nil)
}
NSApp.activate(ignoringOtherApps: true)
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
true true
} }
@@ -63,15 +63,15 @@ final class MeasurementWorkflowViewModel {
var rows: [ChartreadRow] = [] var rows: [ChartreadRow] = []
var swatchRows: [SwatchRow] = [] var swatchRows: [SwatchRow] = []
var showRemoveSheetNotice = false var showRemoveSheetNotice = false
var lastError: String? /// Stage-local chartread error notice (`#chartreadLastError`, #80).
var chartreadNotice: Notice?
private var chartreadTask: Task<Void, Never>? private var chartreadTask: Task<Void, Never>?
// MARK: - Averaging // MARK: - Averaging
var passSnapshots: [URL] = [] var passSnapshots: [URL] = []
var isFinishing = false var isFinishing = false
var finishNotice: String? var finishNotice: Notice?
var finishNoticeIsError = false
var resumedFromTi2 = false var resumedFromTi2 = false
init(wizard: WizardViewModel, environment: AppEnvironment) { init(wizard: WizardViewModel, environment: AppEnvironment) {
@@ -186,7 +186,7 @@ final class MeasurementWorkflowViewModel {
isChartreadRunning = true isChartreadRunning = true
chartreadState = .idle chartreadState = .idle
currentPrompt = nil currentPrompt = nil
lastError = nil chartreadNotice = nil
chartreadLog.removeAll() chartreadLog.removeAll()
// Optional: reset rows when starting a fresh first pass. // Optional: reset rows when starting a fresh first pass.
@@ -228,7 +228,10 @@ final class MeasurementWorkflowViewModel {
case .exit(let code): case .exit(let code):
if code != 0 { if code != 0 {
lastError = "chartread exited with code \(code)" chartreadNotice = Notice(
kind: .error,
text: "chartread exited with code \(code)"
)
} }
case .completed(let canonicalURL): case .completed(let canonicalURL):
@@ -236,7 +239,7 @@ final class MeasurementWorkflowViewModel {
completePass(canonicalURL: canonicalURL) completePass(canonicalURL: canonicalURL)
case .failed(let error): case .failed(let error):
lastError = error.localizedDescription chartreadNotice = Notice(kind: .error, text: error.localizedDescription)
chartreadState = .error chartreadState = .error
isChartreadRunning = false isChartreadRunning = false
} }
@@ -382,7 +385,10 @@ final class MeasurementWorkflowViewModel {
discoverPassSnapshots() discoverPassSnapshots()
wizard.refreshGating() wizard.refreshGating()
} catch { } catch {
lastError = "Could not snapshot pass: \(error.localizedDescription)" chartreadNotice = Notice(
kind: .error,
text: "Could not snapshot pass: \(error.localizedDescription)"
)
} }
} }
@@ -398,33 +404,32 @@ final class MeasurementWorkflowViewModel {
func finishAndAverage() { func finishAndAverage() {
guard !isFinishing, let cwd = workingDirectory, !passSnapshots.isEmpty else { return } guard !isFinishing, let cwd = workingDirectory, !passSnapshots.isEmpty else { return }
isFinishing = true
finishNotice = nil finishNotice = nil
finishNoticeIsError = false
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
guard let self else { return } guard let self else { return }
do { do {
let canonical: URL // No log reset: prior chartread output must be preserved.
if self.passSnapshots.count == 1, let pass = self.passSnapshots.first { let canonical = try await ProcessRunSupport.runLogged(
canonical = try MeasurementArtefacts.promotePass( setRunning: { self.isFinishing = $0 },
pass: pass, resetLog: {},
basename: self.basename, onLog: { self.chartreadLog.append(contentsOf: $0) }
cwd: cwd ) { onLog in
) if self.passSnapshots.count == 1, let pass = self.passSnapshots.first {
} else { return try MeasurementArtefacts.promotePass(
pass: pass,
basename: self.basename,
cwd: cwd
)
}
let config = AverageConfig( let config = AverageConfig(
workingDirectory: cwd, workingDirectory: cwd,
basename: self.basename, basename: self.basename,
passFiles: self.passSnapshots passFiles: self.passSnapshots
) )
canonical = try await self.environment.runner.runAverage( return try await self.environment.runner.runAverage(
config: config, config: config,
onLogBatch: { [weak self] batch in onLogBatch: onLog
Task { @MainActor [weak self] in
self?.chartreadLog.append(contentsOf: batch)
}
}
) )
} }
self.discoverPassSnapshots() self.discoverPassSnapshots()
@@ -432,7 +437,11 @@ final class MeasurementWorkflowViewModel {
if self.wizard.isUnlocked(.buildProfile) { if self.wizard.isUnlocked(.buildProfile) {
self.wizard.go(to: .buildProfile) self.wizard.go(to: .buildProfile)
} else { } else {
self.finishNotice = "Finished: \(canonical.lastPathComponent) ready." self.finishNotice = Notice(
kind: .info,
text: "Finished: \(canonical.lastPathComponent) ready.",
autoHideAfter: nil
)
} }
} catch { } catch {
// Fallback to pass 1 promotion if averaging failed. // Fallback to pass 1 promotion if averaging failed.
@@ -445,18 +454,26 @@ final class MeasurementWorkflowViewModel {
) )
self.discoverPassSnapshots() self.discoverPassSnapshots()
self.wizard.refreshGating() self.wizard.refreshGating()
self.finishNotice = "Averaging failed — promoted first pass." self.finishNotice = Notice(
self.finishNoticeIsError = true kind: .error,
text: "Averaging failed — promoted first pass.",
autoHideAfter: nil
)
} catch { } catch {
self.finishNotice = "Finish failed: \(error.localizedDescription)" self.finishNotice = Notice(
self.finishNoticeIsError = true kind: .error,
text: "Finish failed: \(error.localizedDescription)",
autoHideAfter: nil
)
} }
} else { } else {
self.finishNotice = "Finish failed: \(error.localizedDescription)" self.finishNotice = Notice(
self.finishNoticeIsError = true kind: .error,
text: "Finish failed: \(error.localizedDescription)",
autoHideAfter: nil
)
} }
} }
self.isFinishing = false
} }
} }
} }
+8
View File
@@ -21,6 +21,14 @@ struct Notice: Identifiable, Equatable {
case .error: return .red case .error: return .red
} }
} }
var accessibilityValue: String {
switch self {
case .info: return "info"
case .warning: return "warning"
case .error: return "error"
}
}
} }
let id = UUID() let id = UUID()
@@ -0,0 +1,181 @@
import Foundation
import Observation
import ICCeryCore
/// CUPS queue selection, bound print panel, and `lp` spool (issues 1215, 17 / #85).
@MainActor
@Observable
final class PrintSessionViewModel {
let wizard: WizardViewModel
let environment: AppEnvironment
var printers: [Printer] = []
var selectedPrinter = ""
var printerCaps = PrinterCapabilities()
var selectedTray: Int?
var selectedMediaType: String?
var printOrientation = "portrait"
var capturedCupsOptions: [String: String] = [:]
var printNotice: Notice?
var isPrinting = false
private var printTask: Task<Void, Never>?
init(wizard: WizardViewModel, environment: AppEnvironment) {
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 { self.printTask = nil }
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
return
}
}
printNotice = Notice(
kind: .info,
text: "Sent \(printed) page(s) to \(selectedPrinter).",
autoHideAfter: nil
)
isPrinting = false
}
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 }
defer { self.printTask = nil }
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
}
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
}
}
+27
View File
@@ -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)
}
}
+29
View File
@@ -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))
}
}
+66 -97
View File
@@ -3,25 +3,6 @@ import Observation
import SwiftUI import SwiftUI
import ICCeryCore 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. /// Stage 4/5 workflow: build a profile, verify it, track drift, and install.
@MainActor @MainActor
@Observable @Observable
@@ -50,7 +31,6 @@ final class ProfileWorkflowViewModel {
var isColprofRunning = false var isColprofRunning = false
var colprofLog: [String] = [] var colprofLog: [String] = []
var colprofProgress: String? var colprofProgress: String?
var lastError: String?
var createdProfileURL: URL? var createdProfileURL: URL?
/// Path to the `.gam` gamut mesh extracted post-`colprof` (issue #28). /// Path to the `.gam` gamut mesh extracted post-`colprof` (issue #28).
var createdGamutURL: URL? var createdGamutURL: URL?
@@ -110,39 +90,31 @@ final class ProfileWorkflowViewModel {
} }
var fwaValue: String? { var fwaValue: String? {
switch fwaSelection { fwaSelection.presetValue(customPath: fwaCustomPath)
case .none: return nil
case .empty: return ""
case .D50: return "D50"
case .D65: return "D65"
case .custom: return fwaCustomPath
}
} }
// MARK: - Preset application // MARK: - Preset application
func applyPreset(_ preset: ProfilingPreset?) { func applyPreset(_ preset: ProfilingPreset?) {
guard let preset else { return } guard let preset else { return }
algorithm = preset.colprofAlgorithm ?? "l" let config = ColprofConfig(
quality = preset.colprofQuality ?? "m" preset: preset,
intent = preset.colprofIntent ?? "" basename: wizard.basename,
workingDirectory: wizard.effectiveWorkingDirectory
if let fwa = preset.colprofFwa { )
switch fwa.lowercased() { algorithm = config.algorithm
case "none": fwaSelection = .none quality = config.quality
case "": fwaSelection = .empty intent = config.intent ?? ""
case "d50": fwaSelection = .D50 fwaSelection = ColprofFwaSelection(presetValue: config.fwa)
case "d65": fwaSelection = .D65 fwaCustomPath = fwaSelection == .custom ? (config.fwa ?? "") : ""
default: illuminant = config.illuminant ?? ""
fwaSelection = .custom observer = config.observer ?? ""
fwaCustomPath = fwa inputViewingCond = config.inputViewingCond ?? ""
} outputViewingCond = config.outputViewingCond ?? ""
} profileDescription = ""
copyright = ""
illuminant = preset.colprofIlluminant ?? "" applyCalibration = preset.applyCalibration == true
observer = preset.colprofObserver ?? "" calibrationFile = preset.calibrationFile ?? ""
inputViewingCond = preset.colprofInputViewingCond ?? ""
outputViewingCond = preset.colprofOutputViewingCond ?? ""
} }
/// Stage 4 form values for saving into a custom preset. /// Stage 4 form values for saving into a custom preset.
@@ -192,65 +164,59 @@ final class ProfileWorkflowViewModel {
guard canCreateProfile, let _ = wizard.effectiveWorkingDirectory else { return } guard canCreateProfile, let _ = wizard.effectiveWorkingDirectory else { return }
let config = buildColprofConfig() let config = buildColprofConfig()
isColprofRunning = true
colprofLog = []
colprofProgress = nil colprofProgress = nil
lastError = nil
createdProfileURL = nil createdProfileURL = nil
createdGamutURL = nil createdGamutURL = nil
let runner = environment.runner let runner = environment.runner
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
guard let self else { return } guard let self else { return }
defer { self.isColprofRunning = false }
do { do {
let url = try await runner.runColprof(config: config) { [weak self] batch in let outcome = try await ProcessRunSupport.runLogged(
Task { @MainActor [weak self] in setRunning: { self.isColprofRunning = $0 },
guard let self else { return } resetLog: { self.colprofLog = [] },
onLog: { batch in
self.colprofLog.append(contentsOf: batch) self.colprofLog.append(contentsOf: batch)
if let last = batch.last { if let last = batch.last {
let progress = ColprofProgressClassifier.classify(line: last) self.updateProgress(ColprofProgressClassifier.classify(line: last))
self.updateProgress(progress)
} }
} }
} ) { onLog in
let url = try await runner.runColprof(config: config, onLogBatch: onLog)
var finalProfileURL = url var finalProfileURL = url
if self.applyCalibration, !self.calibrationFile.isEmpty { if self.applyCalibration, !self.calibrationFile.isEmpty {
let applyConfig = ApplycalConfig( let applyConfig = ApplycalConfig(
calibrationPath: self.calibrationFile, calibrationPath: self.calibrationFile,
inputProfileURL: url inputProfileURL: url
) )
assert(!applyConfig.unapply, "applycal unapply is not supported in v2.0") assert(!applyConfig.unapply, "applycal unapply is not supported in v2.0")
finalProfileURL = try await runner.runApplycal(config: applyConfig) finalProfileURL = try await runner.runApplycal(config: applyConfig)
self.colprofLog.append("Calibration embedded: \(self.calibrationFile)") self.colprofLog.append("Calibration embedded: \(self.calibrationFile)")
}
// Gamut extraction is best-effort for Stage 5 / M6 viewer.
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)")
} catch {
self.wizard.showNotice(
"Gamut extraction skipped: \(error.localizedDescription)",
kind: .info
)
}
self.createdProfileURL = finalProfileURL // Gamut extraction is best-effort for Stage 5 / M6 viewer.
var gamutURL: URL?
do {
let gamConfig = IccgamutConfig(profileURL: finalProfileURL)
let url = try await runner.runIccgamut(config: gamConfig, onLogBatch: onLog)
gamutURL = url
self.colprofLog.append("Gamut mesh extracted: \(url.lastPathComponent)")
} catch {
self.wizard.showNotice(
"Gamut extraction skipped: \(error.localizedDescription)",
kind: .info
)
}
return (profileURL: finalProfileURL, gamutURL: gamutURL)
}
self.createdProfileURL = outcome.profileURL
self.createdGamutURL = outcome.gamutURL
self.wizard.refreshGating() self.wizard.refreshGating()
self.wizard.showNotice("Profile created: \(finalProfileURL.lastPathComponent)") self.wizard.showNotice("Profile created: \(outcome.profileURL.lastPathComponent)")
self.wizard.go(to: .verifyInstall) self.wizard.go(to: .verifyInstall)
} catch { } catch {
self.lastError = error.localizedDescription
self.wizard.showNotice( self.wizard.showNotice(
"Profile creation failed: \(error.localizedDescription)", "Profile creation failed: \(error.localizedDescription)",
kind: .error kind: .error
@@ -317,25 +283,28 @@ final class ProfileWorkflowViewModel {
let ti3URL = ArtefactProbe.artefact(wizard.basename, "ti3", cwd) let ti3URL = ArtefactProbe.artefact(wizard.basename, "ti3", cwd)
let config = ProfcheckConfig(ti3URL: ti3URL, iccURL: profileURL) let config = ProfcheckConfig(ti3URL: ti3URL, iccURL: profileURL)
isProfcheckRunning = true
profcheckReport = nil profcheckReport = nil
profcheckWarning = nil profcheckWarning = nil
let runner = environment.runner let runner = environment.runner
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
guard let self else { return } guard let self else { return }
defer { self.isProfcheckRunning = false }
do { do {
let report = try await runner.runProfcheck(config: config) { [weak self] batch in let outcome = try await ProcessRunSupport.runLogged(
Task { @MainActor [weak self] in setRunning: { self.isProfcheckRunning = $0 },
self?.colprofLog.append(contentsOf: batch) resetLog: {},
onLog: { self.colprofLog.append(contentsOf: $0) }
) { onLog in
let report = try await runner.runProfcheck(config: config, onLogBatch: onLog)
var history: [VerificationRecord]?
if let record = self.makeVerificationRecord(from: report) {
history = try await self.environment.historyStore.append(record)
} }
return (report: report, history: history)
} }
self.profcheckReport = report self.profcheckReport = outcome.report
if let record = self.makeVerificationRecord(from: report) { if let history = outcome.history {
let updated = try await self.environment.historyStore.append(record) self.verificationHistory = history
self.verificationHistory = updated
self.driftAlert = DriftAlert.compute(from: self.filteredHistory) self.driftAlert = DriftAlert.compute(from: self.filteredHistory)
} }
} catch let error as ArgyllRunnerError where error == .profcheckUnparseable { } catch let error as ArgyllRunnerError where error == .profcheckUnparseable {
+2 -2
View File
@@ -83,9 +83,9 @@ private struct WizardStageContent: View {
case .verifyInstall: case .verifyInstall:
Stage5View(model: workflow.profile) Stage5View(model: workflow.profile)
case .calibrate: case .calibrate:
CalibrationView(model: workflow.calibration) CalibrationView(model: workflow.calibration, wizard: workflow.wizard)
@unknown default: @unknown default:
StagePlaceholderView(stage: model.stage) Stage1View(workflow: workflow)
} }
} }
} }
+1 -1
View File
@@ -33,7 +33,7 @@ final class SettingsViewModel {
sink.applySettings(settings) sink.applySettings(settings)
savedFlash = true savedFlash = true
Task { Task {
try? await Task.sleep(for: .seconds(1.5)) try? await Task.sleep(nanoseconds: 1_500_000_000)
savedFlash = false savedFlash = false
} }
return true return true
+7 -14
View File
@@ -238,19 +238,12 @@ struct Stage1View: View {
} }
private var logSection: some View { private var logSection: some View {
DisclosureGroup("Process log") { ProcessLogView(
ScrollView { lines: workflow.targenLog,
Text(workflow.targenLog.joined(separator: "\n")) minHeight: 120,
.font(.system(.caption, design: .monospaced)) maxHeight: 200,
.foregroundStyle(Theme.text) containerId: "targenLogContainer",
.frame(maxWidth: .infinity, alignment: .leading) logId: "targenLog"
.textSelection(.enabled) )
}
.frame(minHeight: 120, maxHeight: 200)
.accessibilityIdentifier("targenLog")
}
.foregroundStyle(Theme.text)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("targenLogContainer")
} }
} }
+48 -50
View File
@@ -171,20 +171,13 @@ struct Stage2View: View {
} }
private var logSection: some View { private var logSection: some View {
DisclosureGroup("Process log") { ProcessLogView(
ScrollView { lines: workflow.printtargLog,
Text(workflow.printtargLog.joined(separator: "\n")) minHeight: 100,
.font(.system(.caption, design: .monospaced)) maxHeight: 180,
.foregroundStyle(Theme.text) containerId: "printtargLogContainer",
.frame(maxWidth: .infinity, alignment: .leading) logId: "printtargLog"
.textSelection(.enabled) )
}
.frame(minHeight: 100, maxHeight: 180)
.accessibilityIdentifier("printtargLog")
}
.foregroundStyle(Theme.text)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("printtargLogContainer")
} }
// MARK: - TIFF gallery (#tiffGallery) host-side PNG only (#58) // MARK: - TIFF gallery (#tiffGallery) host-side PNG only (#58)
@@ -219,18 +212,19 @@ struct Stage2View: View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 12) { HStack(spacing: 12) {
Text("Print").font(.headline).foregroundStyle(Theme.text) Text("Print").font(.headline).foregroundStyle(Theme.text)
if let notice = workflow.printNotice { if let notice = workflow.print.printNotice {
Image(systemName: workflow.printNoticeIsError Image(systemName: notice.kind == .error
? "xmark.circle.fill" : "info.circle.fill") ? "xmark.circle.fill" : "info.circle.fill")
.foregroundStyle(workflow.printNoticeIsError .foregroundStyle(notice.kind == .error
? .red : .blue) ? .red : .blue)
.accessibilityIdentifier("printNotificationIcon") .accessibilityIdentifier("printNotificationIcon")
Text(notice) .accessibilityValue(notice.kind.accessibilityValue)
Text(notice.text)
.font(.caption) .font(.caption)
.foregroundStyle(workflow.printNoticeIsError .foregroundStyle(notice.kind == .error
? .red : .secondary) ? .red : .secondary)
.accessibilityIdentifier("printNotificationText") .accessibilityIdentifier("printNotificationText")
.accessibilityValue(notice) .accessibilityValue(notice.text)
} }
Spacer() Spacer()
} }
@@ -238,21 +232,21 @@ struct Stage2View: View {
// Printer row: select + status + refresh + Preferences. // Printer row: select + status + refresh + Preferences.
HStack(spacing: 10) { HStack(spacing: 10) {
Picker("Printer", selection: $workflow.selectedPrinter) { Picker("Printer", selection: $workflow.print.selectedPrinter) {
ForEach(workflow.printers, id: \.name) { printer in ForEach(workflow.print.printers, id: \.name) { printer in
Text(printer.displayName ?? printer.name) Text(printer.displayName ?? printer.name)
.tag(printer.name) .tag(printer.name)
} }
} }
.frame(maxWidth: 320) .frame(maxWidth: 320)
.accessibilityIdentifier("printerSelect") .accessibilityIdentifier("printerSelect")
.onChange(of: workflow.selectedPrinter) { _, _ in .onChange(of: workflow.print.selectedPrinter) { _, _ in
workflow.selectedTray = nil workflow.print.selectedTray = nil
workflow.selectedMediaType = nil workflow.print.selectedMediaType = nil
Task { @MainActor in await workflow.reloadSelectedCapabilities() } Task { @MainActor in await workflow.print.reloadSelectedCapabilities() }
} }
if let selected = workflow.printers if let selected = workflow.print.printers
.first(where: { $0.name == workflow.selectedPrinter }) { .first(where: { $0.name == workflow.print.selectedPrinter }) {
Text(selected.status.rawValue) Text(selected.status.rawValue)
.font(.caption).foregroundStyle(.secondary) .font(.caption).foregroundStyle(.secondary)
.padding(.horizontal, 8).padding(.vertical, 3) .padding(.horizontal, 8).padding(.vertical, 3)
@@ -260,33 +254,33 @@ struct Stage2View: View {
.clipShape(Capsule()) .clipShape(Capsule())
.accessibilityIdentifier("printerStatusBadge") .accessibilityIdentifier("printerStatusBadge")
} }
Button(action: workflow.refreshPrinters) { Button(action: workflow.print.refreshPrinters) {
Image(systemName: "arrow.clockwise") Image(systemName: "arrow.clockwise")
} }
.help("Refresh printer list") .help("Refresh printer list")
.accessibilityIdentifier("btnRefreshPrinters") .accessibilityIdentifier("btnRefreshPrinters")
Button(action: workflow.openPrinterPreferences) { Button(action: workflow.print.openPrinterPreferences) {
Image(systemName: "gearshape") Image(systemName: "gearshape")
} }
.help("Printer properties — bound NSPrintPanel") .help("Printer properties — bound NSPrintPanel")
.disabled(workflow.selectedPrinter.isEmpty) .disabled(workflow.print.selectedPrinter.isEmpty)
.accessibilityIdentifier("btnPrinterProperties") .accessibilityIdentifier("btnPrinterProperties")
} }
// Tray / media / orientation from queue capabilities. // Tray / media / orientation from queue capabilities.
HStack(spacing: 14) { HStack(spacing: 14) {
if !workflow.printerCaps.trays.isEmpty { if !workflow.print.printerCaps.trays.isEmpty {
Picker("Tray", selection: $workflow.selectedTray) { Picker("Tray", selection: $workflow.print.selectedTray) {
ForEach(workflow.printerCaps.trays, id: \.id) { ForEach(workflow.print.printerCaps.trays, id: \.id) {
Text($0.name).tag(Optional($0.id)) Text($0.name).tag(Optional($0.id))
} }
} }
.frame(maxWidth: 200) .frame(maxWidth: 200)
.accessibilityIdentifier("printerTraySelect") .accessibilityIdentifier("printerTraySelect")
} }
if !workflow.printerCaps.mediaTypes.isEmpty { if !workflow.print.printerCaps.mediaTypes.isEmpty {
Picker("Media", selection: $workflow.selectedMediaType) { Picker("Media", selection: $workflow.print.selectedMediaType) {
ForEach(workflow.printerCaps.mediaTypes, id: \.id) { ForEach(workflow.print.printerCaps.mediaTypes, id: \.id) {
Text($0.name).tag(Optional($0.id)) Text($0.name).tag(Optional($0.id))
} }
} }
@@ -296,27 +290,31 @@ struct Stage2View: View {
.accessibilityIdentifier("printerMediaTypeSelect") .accessibilityIdentifier("printerMediaTypeSelect")
} }
HStack(spacing: 0) { HStack(spacing: 0) {
Button("Portrait") { workflow.printOrientation = "portrait" } Button("Portrait") { workflow.print.printOrientation = "portrait" }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.tint(workflow.printOrientation == "portrait" ? .accentColor : .gray) .tint(workflow.print.printOrientation == "portrait" ? .accentColor : .gray)
.accessibilityIdentifier("btnOrientPortrait") .accessibilityIdentifier("btnOrientPortrait")
Button("Landscape") { workflow.printOrientation = "landscape" } Button("Landscape") { workflow.print.printOrientation = "landscape" }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.tint(workflow.printOrientation == "landscape" ? .accentColor : .gray) .tint(workflow.print.printOrientation == "landscape" ? .accentColor : .gray)
.accessibilityIdentifier("btnOrientLandscape") .accessibilityIdentifier("btnOrientLandscape")
} }
Spacer() Spacer()
} }
HStack(spacing: 8) { HStack(spacing: 8) {
Button(action: workflow.printAllPages) { Button(action: {
Label(workflow.isPrinting ? "Printing…" : "Print All", if let result = workflow.printtargResult {
workflow.print.printAllPages(from: result, pageSize: workflow.pageSize)
}
}) {
Label(workflow.print.isPrinting ? "Printing…" : "Print All",
systemImage: "printer") systemImage: "printer")
} }
.controlSize(.large) .controlSize(.large)
.disabled(workflow.isPrinting .disabled(workflow.print.isPrinting
|| workflow.printtargResult == nil || workflow.printtargResult == nil
|| workflow.selectedPrinter.isEmpty) || workflow.print.selectedPrinter.isEmpty)
.accessibilityIdentifier("btnPrintAll") .accessibilityIdentifier("btnPrintAll")
Spacer() Spacer()
Button("Advance to Stage 3") { workflow.advanceToStage3() } Button("Advance to Stage 3") { workflow.advanceToStage3() }
@@ -333,8 +331,8 @@ struct Stage2View: View {
.task(id: workflow.printtargResult?.pages.count) { .task(id: workflow.printtargResult?.pages.count) {
// Auto-enumerate once a manifest exists and whenever it // Auto-enumerate once a manifest exists and whenever it
// changes (e.g. resume from .ti2). // changes (e.g. resume from .ti2).
if workflow.printers.isEmpty, workflow.printtargResult != nil { if workflow.print.printers.isEmpty, workflow.printtargResult != nil {
workflow.refreshPrinters() workflow.print.refreshPrinters()
} }
} }
} }
@@ -364,9 +362,9 @@ private struct GalleryPageView: View {
Text("\(page.page.patches) patches · " + Text("\(page.page.patches) patches · " +
"\(Int(page.page.widthMm))×\(Int(page.page.heightMm)) mm") "\(Int(page.page.widthMm))×\(Int(page.page.heightMm)) mm")
.font(.caption2).foregroundStyle(.secondary) .font(.caption2).foregroundStyle(.secondary)
Button("Print") { workflow.printPage(page) } Button("Print") { workflow.print.printPage(page, pageSize: workflow.pageSize) }
.disabled(workflow.isPrinting .disabled(workflow.print.isPrinting
|| workflow.selectedPrinter.isEmpty) || workflow.print.selectedPrinter.isEmpty)
.accessibilityIdentifier("btnPrintPage-\(page.index)") .accessibilityIdentifier("btnPrintPage-\(page.index)")
} }
.padding(8) .padding(8)
+13 -17
View File
@@ -164,28 +164,22 @@ struct Stage3View: View {
.foregroundStyle(Theme.accent) .foregroundStyle(Theme.accent)
} }
if let lastError = model.lastError { if let notice = model.chartreadNotice {
Text(lastError) Text(notice.text)
.font(.caption) .font(.caption)
.foregroundStyle(.red) .foregroundStyle(notice.kind.tint)
.accessibilityIdentifier("chartreadLastError") .accessibilityIdentifier("chartreadLastError")
.accessibilityValue(lastError) .accessibilityValue(notice.text)
} }
controlButtons controlButtons
if !model.chartreadLog.isEmpty { if !model.chartreadLog.isEmpty {
DisclosureGroup("Log") { ProcessLogView(
VStack(alignment: .leading) { lines: model.chartreadLog,
ForEach(model.chartreadLog, id: \.self) { line in containerId: "chartreadLogContainer",
Text(line) logId: "chartreadLog"
.font(.system(.caption, design: .monospaced)) )
.foregroundStyle(.secondary)
}
}
}
.foregroundStyle(Theme.text)
.accessibilityIdentifier("chartreadLogContainer")
} }
} }
.padding(16) .padding(16)
@@ -371,9 +365,11 @@ struct Stage3View: View {
} }
if let notice = model.finishNotice { if let notice = model.finishNotice {
Text(notice) Text(notice.text)
.font(.caption) .font(.caption)
.foregroundStyle(model.finishNoticeIsError ? .red : .green) .foregroundStyle(notice.kind == .error ? .red : .green)
.accessibilityIdentifier("chartreadFinishNotice")
.accessibilityValue(notice.kind.accessibilityValue)
} }
} }
.padding(16) .padding(16)
+5 -18
View File
@@ -166,27 +166,14 @@ struct Stage4View: View {
} }
Spacer() Spacer()
if let lastError = model.lastError {
Text(lastError)
.font(.caption)
.foregroundStyle(.red)
.accessibilityIdentifier("colprofLastError")
}
} }
if !model.colprofLog.isEmpty { if !model.colprofLog.isEmpty {
DisclosureGroup("Log") { ProcessLogView(
VStack(alignment: .leading) { lines: model.colprofLog,
ForEach(model.colprofLog, id: \.self) { line in containerId: "colprofLogContainer",
Text(line) logId: "colprofLog"
.font(.system(.caption, design: .monospaced)) )
.foregroundStyle(.secondary)
}
}
}
.foregroundStyle(Theme.text)
.accessibilityIdentifier("colprofLogContainer")
} }
} }
.padding(16) .padding(16)
-24
View File
@@ -1,24 +0,0 @@
import SwiftUI
import ICCeryCore
/// Placeholder stage surface for M1. Real stage UIs arrive in M2M5
/// (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)
}
}
+76 -278
View File
@@ -89,30 +89,6 @@ final class TargetWorkflowViewModel {
/// Stage 3 (`#stage3LoadedTargetBanner` data). /// Stage 3 (`#stage3LoadedTargetBanner` data).
var resumedFromTi2 = false 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>?
// MARK: - Presets // MARK: - Presets
var presets: [ProfilingPreset] = [] var presets: [ProfilingPreset] = []
@@ -130,6 +106,8 @@ final class TargetWorkflowViewModel {
var profile: ProfileWorkflowViewModel var profile: ProfileWorkflowViewModel
/// Stage 0 calibration workflow. /// Stage 0 calibration workflow.
var calibration: CalibrationViewModel! var calibration: CalibrationViewModel!
/// Stage 2 unmanaged print session.
var print: PrintSessionViewModel!
init(environment: AppEnvironment = .live()) { init(environment: AppEnvironment = .live()) {
self.environment = environment self.environment = environment
@@ -142,6 +120,7 @@ final class TargetWorkflowViewModel {
wizard: wizard, wizard: wizard,
environment: environment environment: environment
) )
self.print = PrintSessionViewModel(wizard: wizard, environment: environment)
self.calibration = nil self.calibration = nil
self.calibration = CalibrationViewModel( self.calibration = CalibrationViewModel(
workflow: self, workflow: self,
@@ -227,16 +206,16 @@ final class TargetWorkflowViewModel {
func generateTarget() { func generateTarget() {
guard canGenerate, !targenRunning else { return } guard canGenerate, !targenRunning else { return }
let config = buildTargenConfig() let config = buildTargenConfig()
targenRunning = true
targenLog = []
resumedFromTi2 = false resumedFromTi2 = false
let runner = environment.runner let runner = environment.runner
Task { @MainActor in Task { @MainActor in
do { do {
let url = try await runner.runTargen(config: config) { [weak self] batch in let url = try await ProcessRunSupport.runLogged(
Task { @MainActor [weak self] in setRunning: { self.targenRunning = $0 },
self?.targenLog.append(contentsOf: batch) resetLog: { self.targenLog = [] },
} onLog: { self.targenLog.append(contentsOf: $0) }
) { onLog in
try await runner.runTargen(config: config, onLogBatch: onLog)
} }
wizard.setTarget( wizard.setTarget(
basename: config.basename, basename: config.basename,
@@ -248,7 +227,6 @@ final class TargetWorkflowViewModel {
wizard.showNotice( wizard.showNotice(
"targen failed: \(error.localizedDescription)", kind: .error) "targen failed: \(error.localizedDescription)", kind: .error)
} }
targenRunning = false
} }
} }
@@ -261,7 +239,12 @@ final class TargetWorkflowViewModel {
? UITestHooks.datasetImportURL ? UITestHooks.datasetImportURL
: fileDialogs.selectDatasetFile() : fileDialogs.selectDatasetFile()
guard let url else { return } 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 { do {
let dataset = try CGATSParser.parse(url: url) let dataset = try CGATSParser.parse(url: url)
guard let directory = targetDirectory ?? wizard.effectiveWorkingDirectory else { guard let directory = targetDirectory ?? wizard.effectiveWorkingDirectory else {
@@ -284,8 +267,6 @@ final class TargetWorkflowViewModel {
} else { } else {
wizard.showNotice("Imported dataset is not ready for profiling.", kind: .warning) 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 { } catch {
wizard.showNotice("Import failed: \(error.localizedDescription)", kind: .error) wizard.showNotice("Import failed: \(error.localizedDescription)", kind: .error)
} }
@@ -360,28 +341,25 @@ final class TargetWorkflowViewModel {
func createLayout() { func createLayout() {
guard wizard.isUnlocked(.layOutPrint), !printtargRunning else { return } guard wizard.isUnlocked(.layOutPrint), !printtargRunning else { return }
let config = buildPrinttargConfig() let config = buildPrinttargConfig()
printtargRunning = true
printtargLog = []
printtargResult = nil printtargResult = nil
let runner = environment.runner let runner = environment.runner
Task { @MainActor in Task { @MainActor in
do { do {
let result = try await runner.runPrinttarg(config: config) { [weak self] batch in let result = try await ProcessRunSupport.runLogged(
Task { @MainActor [weak self] in setRunning: { self.printtargRunning = $0 },
self?.printtargLog.append(contentsOf: batch) resetLog: { self.printtargLog = [] },
} onLog: { self.printtargLog.append(contentsOf: $0) }
) { onLog in
try await runner.runPrinttarg(config: config, onLogBatch: onLog)
} }
printtargResult = result printtargResult = result
wizard.refreshGating() wizard.refreshGating()
wizard.showNotice( wizard.showNotice(
"Layout created — \(result.manifest.pages.count) page(s) ready.") "Layout created — \(result.manifest.pages.count) page(s) ready.")
} catch { } catch {
// Stay on Stage 2: non-zero exit, malformed manifest, or
// missing .ti2 must never advance the wizard (#156).
wizard.showNotice( wizard.showNotice(
"printtarg failed: \(error.localizedDescription)", kind: .error) "printtarg failed: \(error.localizedDescription)", kind: .error)
} }
printtargRunning = false
} }
} }
@@ -391,160 +369,6 @@ final class TargetWorkflowViewModel {
wizard.go(to: .measure) 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 // MARK: - Presets
func reloadPresets() { func reloadPresets() {
@@ -554,55 +378,61 @@ final class TargetWorkflowViewModel {
/// Applies every Stage 1/2 field of the preset to the live form /// Applies every Stage 1/2 field of the preset to the live form
/// (bidirectional the draft preset's dpi=150 must be visible). /// (bidirectional the draft preset's dpi=150 must be visible).
func applyPreset(_ preset: ProfilingPreset) { func applyPreset(_ preset: ProfilingPreset) {
colourSpace = preset.colourSpace == "cmyk" ? .cmyk : .rgb let targen = TargenConfig(preset: preset, basename: targetBasename, workingDirectory: targetDirectory)
patchPreset = PatchCountPreset(rawValue: "\(preset.patchCount)") ?? .custom applyTargenForm(targen)
customPatchCount = preset.patchCount // Stage 4 state (incl. calibration) is applied before Stage 2 so
whitePatches = preset.whitePatches // the layout config receives the preset's calibration path, not
blackPatches = preset.blackPatches // stale live state (#82).
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
profile.applyPreset(preset) 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 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. /// Snapshot of the live Stage 1/2 form as a custom preset.
func saveCurrentAsPreset() { func saveCurrentAsPreset() {
let name = savePresetName.trimmingCharacters(in: .whitespacesAndNewlines) let name = savePresetName.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -614,39 +444,11 @@ final class TargetWorkflowViewModel {
id: "custom-\(UUID().uuidString.lowercased())", id: "custom-\(UUID().uuidString.lowercased())",
name: name, name: name,
description: savePresetDesc.trimmingCharacters(in: .whitespacesAndNewlines), description: savePresetDesc.trimmingCharacters(in: .whitespacesAndNewlines),
colourSpace: colourSpace == .cmyk ? "cmyk" : "rgb", targen: buildTargenConfig(),
patchCount: effectivePatchCount, printtarg: buildPrinttargConfig(),
whitePatches: whitePatches, colprof: profile.buildColprofConfig(),
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,
calibrationFile: profile.calibrationFile.isEmpty ? nil : profile.calibrationFile, calibrationFile: profile.calibrationFile.isEmpty ? nil : profile.calibrationFile,
applyCalibration: profile.applyCalibration ? true : nil, 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
) )
do { do {
try environment.presetStore.saveCustom(preset) try environment.presetStore.saveCustom(preset)
@@ -710,10 +512,6 @@ final class TargetWorkflowViewModel {
} }
static func parseCustomPage(_ raw: String) -> (Double, Double)? { static func parseCustomPage(_ raw: String) -> (Double, Double)? {
let parts = raw.lowercased().split(separator: "x") PageSize.parseCustom(raw)
guard parts.count == 2,
let w = Double(parts[0]), let h = Double(parts[1]),
w >= 50, h >= 50 else { return nil }
return (w, h)
} }
} }
+9 -5
View File
@@ -67,8 +67,12 @@ final class WizardViewModel {
self.calibrationOriginalBasename = s.calibrationOriginalBasename self.calibrationOriginalBasename = s.calibrationOriginalBasename
// A Force Quit mid-calibration leaves a CAL_ basename behind; restore // A Force Quit mid-calibration leaves a CAL_ basename behind; restore
// the original before the UI can do anything with it (#29). // the original before the UI can do anything with it (#29).
if basename.hasPrefix("CAL_"), !calibrationOriginalBasename.isEmpty { if CalibrationIdentity.isCalibration(basename), !calibrationOriginalBasename.isEmpty {
basename = calibrationOriginalBasename let identity = CalibrationIdentity.parse(
liveBasename: basename,
persistedOriginal: calibrationOriginalBasename
)
basename = identity.originalBasename
calibrationOriginalBasename = "" calibrationOriginalBasename = ""
sessionMode = .profile sessionMode = .profile
stage = .generate stage = .generate
@@ -128,7 +132,7 @@ final class WizardViewModel {
/// refused and the original basename is restored (#29). /// refused and the original basename is restored (#29).
func go(to target: WizardStage) { func go(to target: WizardStage) {
guard target != .calibrate else { enterCalibration(); return } guard target != .calibrate else { enterCalibration(); return }
if basename.hasPrefix("CAL_") { if CalibrationIdentity.isCalibration(basename) {
guard !calibrationOriginalBasename.isEmpty else { guard !calibrationOriginalBasename.isEmpty else {
showNotice( showNotice(
"Cannot leave calibration — the original target name is missing.", "Cannot leave calibration — the original target name is missing.",
@@ -152,7 +156,7 @@ final class WizardViewModel {
} }
func enterCalibration() { func enterCalibration() {
if !basename.isEmpty, !basename.hasPrefix("CAL_"), calibrationOriginalBasename.isEmpty { if !basename.isEmpty, !CalibrationIdentity.isCalibration(basename), calibrationOriginalBasename.isEmpty {
calibrationOriginalBasename = basename calibrationOriginalBasename = basename
} }
sessionMode = .calibration sessionMode = .calibration
@@ -197,7 +201,7 @@ final class WizardViewModel {
self.notice = notice self.notice = notice
if let delay = notice.autoHideAfter { if let delay = notice.autoHideAfter {
noticeDismissTask = Task { [weak self] in 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 } guard !Task.isCancelled else { return }
if self?.notice?.id == notice.id { if self?.notice?.id == notice.id {
self?.notice = nil self?.notice = nil
+13 -15
View File
@@ -1,27 +1,25 @@
import Testing import XCTest
import Foundation import Foundation
@testable import ICCeryCore @testable import ICCeryCore
@Suite("AppPaths") final class AppPathsTests: XCTestCase {
struct AppPathsTests { func testAppDataDirUsesBundleID() {
@Test func appDataDirUsesBundleID() { XCTAssertTrue(AppPaths.appDataDir.path.contains("Library/Application Support/com.gronod.iccery2"))
#expect(AppPaths.appDataDir.path.contains("Library/Application Support/com.gronod.iccery2"))
} }
@Test func logFileIsUnderLibraryLogs() { func testLogFileIsUnderLibraryLogs() {
#expect(AppPaths.logFile.lastPathComponent == "iccery.log") XCTAssertEqual(AppPaths.logFile.lastPathComponent, "iccery.log")
#expect(AppPaths.logFile.path.contains("Library/Logs/com.gronod.iccery2")) XCTAssertTrue(AppPaths.logFile.path.contains("Library/Logs/com.gronod.iccery2"))
} }
@Test func bundledArgyllDirIsInsideResources() { func testBundledArgyllDirIsInsideResources() {
#expect(AppPaths.bundledArgyllDir.lastPathComponent == "Argyll") XCTAssertEqual(AppPaths.bundledArgyllDir.lastPathComponent, "Argyll")
} }
} }
@Suite("WizardStage") final class WizardStageTests: XCTestCase {
struct WizardStageTests { func testStepperOrderIsOneThroughFive() {
@Test func stepperOrderIsOneThroughFive() { XCTAssertEqual(WizardStage.stepperStages.map(\.stepperIndex), [1, 2, 3, 4, 5])
#expect(WizardStage.stepperStages.map(\.stepperIndex) == [1, 2, 3, 4, 5]) XCTAssertNil(WizardStage.calibrate.stepperIndex)
#expect(WizardStage.calibrate.stepperIndex == nil)
} }
} }
@@ -1,22 +1,19 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@Suite("ApplycalArgs") final class ApplycalArgsTests: XCTestCase {
struct ApplycalArgsTests {
@Test("Apply argv") func testApplyArgv() throws {
func applyArgv() throws {
let config = ApplycalConfig( let config = ApplycalConfig(
calibrationPath: "/tmp/cal.cal", calibrationPath: "/tmp/cal.cal",
inputProfileURL: URL(fileURLWithPath: "/tmp/profile.icc") inputProfileURL: URL(fileURLWithPath: "/tmp/profile.icc")
) )
let args = try ApplycalArgs.build(config: config) 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 testUnapplyEmittedWhenConfigSet() throws {
func unapplyEmittedWhenConfigSet() throws {
let config = ApplycalConfig( let config = ApplycalConfig(
calibrationPath: "/tmp/cal.cal", calibrationPath: "/tmp/cal.cal",
inputProfileURL: URL(fileURLWithPath: "/tmp/profile.icc"), inputProfileURL: URL(fileURLWithPath: "/tmp/profile.icc"),
@@ -25,6 +22,6 @@ struct ApplycalArgsTests {
let args = try ApplycalArgs.build(config: config) let args = try ApplycalArgs.build(config: config)
// Builder emits -u only when the caller explicitly sets unapply. // Builder emits -u only when the caller explicitly sets unapply.
// The UI layer never passes unapply: true in v2.0. // 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 Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@Suite("ArgyllRunner Calibration") final class ArgyllRunnerCalibrationTests: XCTestCase {
struct ArgyllRunnerCalibrationTests {
private func makeRunner() -> ArgyllRunner { private func makeRunner(processManager: ProcessManager = ProcessManager()) -> ArgyllRunner {
let binDir = URL(fileURLWithPath: #filePath) let binDir = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent() .deletingLastPathComponent()
.deletingLastPathComponent() .deletingLastPathComponent()
.appendingPathComponent("ICCeryUITests/Fixtures/bin") .appendingPathComponent("ICCeryUITests/Fixtures/bin")
return ArgyllRunner( return ArgyllRunner(
processManager: .shared, processManager: processManager,
binaryResolver: BinaryResolver(overrideDir: binDir) binaryResolver: BinaryResolver(overrideDir: binDir)
) )
} }
@@ -23,8 +22,7 @@ struct ArgyllRunnerCalibrationTests {
return root return root
} }
@Test("Calibration targen produces CAL_*.ti1") func testCalibrationTargenProducesTi1() async throws {
func calibrationTargenProducesTi1() async throws {
let testRoot = try makeTestDir() let testRoot = try makeTestDir()
let runner = makeRunner() let runner = makeRunner()
let config = CalibrationTargenConfig( let config = CalibrationTargenConfig(
@@ -36,13 +34,41 @@ struct ArgyllRunnerCalibrationTests {
let url = try await runner.runCalibrationTargen(config: config) let url = try await runner.runCalibrationTargen(config: config)
#expect(url.lastPathComponent == "CAL_demo.ti1") XCTAssertEqual(url.lastPathComponent, "CAL_demo.ti1")
#expect(FileManager.default.fileExists(atPath: url.path)) XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
try? FileManager.default.removeItem(at: testRoot) try? FileManager.default.removeItem(at: testRoot)
} }
@Test("printcal captured run creates .cal") func testCalibrationTargenProcessId() async throws {
func printcalProducesCal() 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 testRoot = try makeTestDir()
let runner = makeRunner() let runner = makeRunner()
let output = testRoot.appendingPathComponent("CAL_demo.cal") let output = testRoot.appendingPathComponent("CAL_demo.cal")
@@ -54,15 +80,32 @@ struct ArgyllRunnerCalibrationTests {
let url = try await runner.runPrintcal(config: config) let url = try await runner.runPrintcal(config: config)
#expect(url.lastPathComponent == "CAL_demo.cal") XCTAssertEqual(url.lastPathComponent, "CAL_demo.cal")
#expect(FileManager.default.fileExists(atPath: url.path)) XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
try? FileManager.default.removeItem(at: testRoot) try? FileManager.default.removeItem(at: testRoot)
} }
@Test("printcal failure throws printcalFailed") func testPrintcalFailureThrows() async throws {
func printcalFailureThrows() async throws {
let testRoot = try makeTestDir() 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 output = testRoot.appendingPathComponent("CAL_demo.cal")
let config = PrintcalConfig( let config = PrintcalConfig(
ti3Basename: "CAL_demo", ti3Basename: "CAL_demo",
@@ -70,12 +113,11 @@ struct ArgyllRunnerCalibrationTests {
outputURL: output outputURL: output
) )
setenv("ICCERY_MOCK_PRINTCAL_EXIT", "1", 1) await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
defer { unsetenv("ICCERY_MOCK_PRINTCAL_EXIT") }
await #expect(throws: (any Error).self) {
_ = try await runner.runPrintcal(config: config) _ = 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 Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
final class LogHolder: @unchecked Sendable { final class LogHolder: @unchecked Sendable {
@@ -19,11 +19,9 @@ final class LogHolder: @unchecked Sendable {
} }
} }
@Suite("ArgyllRunner colprof") final class ArgyllRunnerColprofTests: XCTestCase {
struct ArgyllRunnerColprofTests {
@Test("Mock colprof produces .icc") func testColprofProducesIcc() async throws {
func colprofProducesIcc() async throws {
let binDir = URL(fileURLWithPath: #filePath) let binDir = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent() .deletingLastPathComponent()
.deletingLastPathComponent() .deletingLastPathComponent()
@@ -33,7 +31,7 @@ struct ArgyllRunnerColprofTests {
try FileManager.default.createDirectory(at: testRoot, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: testRoot, withIntermediateDirectories: true)
let runner = ArgyllRunner( let runner = ArgyllRunner(
processManager: .shared, processManager: ProcessManager(),
binaryResolver: BinaryResolver(overrideDir: binDir) binaryResolver: BinaryResolver(overrideDir: binDir)
) )
@@ -43,10 +41,39 @@ struct ArgyllRunnerColprofTests {
holder.append(batch) holder.append(batch)
} }
#expect(url.lastPathComponent == "testrun.icc") XCTAssertEqual(url.lastPathComponent, "testrun.icc")
#expect(FileManager.default.fileExists(atPath: url.path)) XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
#expect(holder.lines.contains { $0.contains("Gamut mapping") }) XCTAssertTrue(holder.lines.contains { $0.contains("Gamut mapping") })
try? FileManager.default.removeItem(at: testRoot) 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")
}
}
+115 -27
View File
@@ -1,4 +1,4 @@
import Testing import XCTest
import Foundation import Foundation
import ImageIO import ImageIO
import UniformTypeIdentifiers import UniformTypeIdentifiers
@@ -10,9 +10,8 @@ private func tempURL(_ name: String) -> URL {
.appendingPathComponent(name) .appendingPathComponent(name)
} }
@Suite("Ti2Header") final class Ti2HeaderTests: XCTestCase {
struct Ti2HeaderTests { func testParsesKeywordsAndSibling() throws {
@Test func parsesKeywordsAndSibling() throws {
let dir = FileManager.default.temporaryDirectory let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-ti2-\(UUID().uuidString)") .appendingPathComponent("iccery-ti2-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
@@ -31,18 +30,18 @@ struct Ti2HeaderTests {
) )
let h = Ti2Header.parse(dir.appendingPathComponent("job.ti2")) let h = Ti2Header.parse(dir.appendingPathComponent("job.ti2"))
#expect(h.instrument == "i1iO") XCTAssertEqual(h.instrument, "i1iO")
#expect(h.patchCount == 800) XCTAssertEqual(h.patchCount, 800)
#expect(h.pageCount == 3) XCTAssertEqual(h.pageCount, 3)
#expect(h.hasSiblingTi1) XCTAssertTrue(h.hasSiblingTi1)
} }
@Test func missingFileYieldsEmptyHeader() { func testMissingFileYieldsEmptyHeader() {
let h = Ti2Header.parse(URL(fileURLWithPath: "/nonexistent/x.ti2")) 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") let url = tempURL("t.ti2")
try FileManager.default.createDirectory( try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true at: url.deletingLastPathComponent(), withIntermediateDirectories: true
@@ -50,12 +49,11 @@ struct Ti2HeaderTests {
try "NUMBER_OF_FIELDS 9\nNUMBER_OF_SETS 52\nBEGIN_DATA\n".write( try "NUMBER_OF_FIELDS 9\nNUMBER_OF_SETS 52\nBEGIN_DATA\n".write(
to: url, atomically: true, encoding: .utf8 to: url, atomically: true, encoding: .utf8
) )
#expect(Ti2Header.parse(url).patchCount == 52) XCTAssertEqual(Ti2Header.parse(url).patchCount, 52)
} }
} }
@Suite("TiffPreview") final class TiffPreviewTests: XCTestCase {
struct TiffPreviewTests {
/// Builds a real 2000×1000 TIFF in a temp dir via ImageIO. /// Builds a real 2000×1000 TIFF in a temp dir via ImageIO.
private func makeTiff(width: Int = 2000, height: Int = 1000) throws -> URL { private func makeTiff(width: Int = 2000, height: Int = 1000) throws -> URL {
let url = tempURL("big.tif") let url = tempURL("big.tif")
@@ -81,44 +79,134 @@ struct TiffPreviewTests {
return url return url
} }
@Test func producesCappedPNG() throws { func testProducesCappedPNG() throws {
let tiff = try makeTiff() let tiff = try makeTiff()
let png = TiffPreview.previewPNG(tiff: tiff) let png = TiffPreview.previewPNG(tiff: tiff)
#expect(png != nil) XCTAssertNotNil(png)
// PNG magic // 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. // Verify the cap by decoding the thumbnail header.
let src = CGImageSourceCreateWithData(png! as CFData, nil)! let src = CGImageSourceCreateWithData(png! as CFData, nil)!
let img = CGImageSourceCreateImageAtIndex(src, 0, nil)! let img = CGImageSourceCreateImageAtIndex(src, 0, nil)!
#expect(max(img.width, img.height) <= TiffPreview.maxEdge) XCTAssertTrue(max(img.width, img.height) <= TiffPreview.maxEdge)
#expect(img.width == 1200) XCTAssertEqual(img.width, 1200)
} }
@Test func nonTiffReturnsNil() throws { func testNonTiffReturnsNil() throws {
let url = tempURL("not-tiff.txt") let url = tempURL("not-tiff.txt")
try FileManager.default.createDirectory( try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true at: url.deletingLastPathComponent(), withIntermediateDirectories: true
) )
try "hello".write(to: url, atomically: true, encoding: .utf8) try "hello".write(to: url, atomically: true, encoding: .utf8)
#expect(TiffPreview.previewPNG(tiff: url) == nil) XCTAssertNil(TiffPreview.previewPNG(tiff: url))
} }
} }
@Suite("ArtefactFiles") final class ArtefactFilesTests: XCTestCase {
struct ArtefactFilesTests { func testBase64RoundTrip() throws {
@Test func base64RoundTrip() throws {
let url = tempURL("a.txt") let url = tempURL("a.txt")
try FileManager.default.createDirectory( try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true at: url.deletingLastPathComponent(), withIntermediateDirectories: true
) )
try "hello".write(to: url, atomically: true, encoding: .utf8) try "hello".write(to: url, atomically: true, encoding: .utf8)
let b64 = try ArtefactFiles.readBase64(url) let b64 = try ArtefactFiles.readBase64(url)
#expect(Data(base64Encoded: b64) == Data("hello".utf8)) XCTAssertEqual(Data(base64Encoded: b64), Data("hello".utf8))
} }
@Test func defaultWorkingDirExists() { func testDefaultWorkingDirExists() {
#expect(FileManager.default.fileExists( XCTAssertTrue(FileManager.default.fileExists(
atPath: ArtefactFiles.defaultWorkingDirectory().path 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)
}
}
+16 -17
View File
@@ -1,9 +1,8 @@
import Testing import XCTest
import Foundation import Foundation
@testable import ICCeryCore @testable import ICCeryCore
@Suite("BinaryResolver") final class BinaryResolverTests: XCTestCase {
struct BinaryResolverTests {
private func makeTree(_ body: (URL) throws -> Void) throws -> URL { private func makeTree(_ body: (URL) throws -> Void) throws -> URL {
let root = FileManager.default.temporaryDirectory let root = FileManager.default.temporaryDirectory
@@ -22,16 +21,16 @@ struct BinaryResolverTests {
} }
} }
@Test func overrideDirWinsWhenFileExists() throws { func testOverrideDirWinsWhenFileExists() throws {
let override = try makeTree { root in let override = try makeTree { root in
try touch(root.appendingPathComponent("targen")) try touch(root.appendingPathComponent("targen"))
} }
let bundled = try makeTree { _ in } let bundled = try makeTree { _ in }
let r = BinaryResolver(bundledRoot: bundled, overrideDir: override) 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 override = try makeTree { _ in }
let bundled = try makeTree { root in let bundled = try makeTree { root in
let dir = root.appendingPathComponent("macos-universal") let dir = root.appendingPathComponent("macos-universal")
@@ -39,10 +38,10 @@ struct BinaryResolverTests {
try touch(dir.appendingPathComponent("instlist")) try touch(dir.appendingPathComponent("instlist"))
} }
let r = BinaryResolver(bundledRoot: bundled, overrideDir: override) 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 let bundled = try makeTree { root in
for dir in ["macos-universal", "macos-x86_64"] { for dir in ["macos-universal", "macos-x86_64"] {
let d = root.appendingPathComponent(dir) let d = root.appendingPathComponent(dir)
@@ -51,10 +50,10 @@ struct BinaryResolverTests {
} }
} }
let r = BinaryResolver(bundledRoot: bundled) 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 bundled = try makeTree { root in
let d = root.appendingPathComponent("macos-x86_64") let d = root.appendingPathComponent("macos-x86_64")
try FileManager.default.createDirectory(at: d, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: d, withIntermediateDirectories: true)
@@ -64,20 +63,20 @@ struct BinaryResolverTests {
bundledRoot: bundled, bundledRoot: bundled,
archDirs: ["macos-universal", "macos-x86_64"] 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 bundled = try makeTree { _ in }
let r = BinaryResolver(bundledRoot: bundled) let r = BinaryResolver(bundledRoot: bundled)
// v1 semantic: path is returned; spawn surfaces the error. // v1 semantic: path is returned; spawn surfaces the error.
#expect(r.resolve("targen").path.hasSuffix("macos-universal/targen")) XCTAssertTrue(r.resolve("targen").path.hasSuffix("macos-universal/targen"))
#expect(!r.exists(r.resolve("targen"))) XCTAssertFalse(r.exists(r.resolve("targen")))
} }
@Test func mockAndGamutPaths() throws { func testMockAndGamutPaths() throws {
let r = BinaryResolver(bundledRoot: URL(fileURLWithPath: "/x")) let r = BinaryResolver(bundledRoot: URL(fileURLWithPath: "/x"))
#expect(r.mock("chartread").path == "/x/mocks/chartread.mock") XCTAssertEqual(r.mock("chartread").path, "/x/mocks/chartread.mock")
#expect(r.referenceGamut("sRGB.gam").path == "/x/reference_gamuts/sRGB.gam") XCTAssertEqual(r.referenceGamut("sRGB.gam").path, "/x/reference_gamuts/sRGB.gam")
} }
} }
+37 -50
View File
@@ -1,9 +1,8 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@Suite("CGATS Parser & Writer") final class CGATSParserTests: XCTestCase {
struct CGATSParserTests {
private static let canonicalCTI3 = """ private static let canonicalCTI3 = """
CTI3 CTI3
@@ -21,44 +20,40 @@ struct CGATSParserTests {
END_DATA END_DATA
""" """
@Test("Parses CTI3 with canonical field names") func testParseCTI3() throws {
func parseCTI3() throws {
let dataset = try CGATSParser.parse(Self.canonicalCTI3) let dataset = try CGATSParser.parse(Self.canonicalCTI3)
#expect(dataset.format == .cti3) XCTAssertEqual(dataset.format, .cti3)
#expect(dataset.samples.count == 2) XCTAssertEqual(dataset.samples.count, 2)
#expect(dataset.colorRep == "RGB") XCTAssertEqual(dataset.colorRep, "RGB")
#expect(dataset.deviceClass == "DISPLAY") XCTAssertEqual(dataset.deviceClass, "DISPLAY")
#expect(dataset.samples[0].id == "1") XCTAssertEqual(dataset.samples[0].id, "1")
#expect(dataset.samples[0].loc == "A1") XCTAssertEqual(dataset.samples[0].loc, "A1")
#expect(dataset.samples[1].values["RGB_G"] == "50.0000") XCTAssertEqual(dataset.samples[1].values["RGB_G"], "50.0000")
} }
@Test("Round-trips parse, write, reparse") func testRoundTrip() throws {
func roundTrip() throws {
let first = try CGATSParser.parse(Self.canonicalCTI3) let first = try CGATSParser.parse(Self.canonicalCTI3)
let text = try CGATSWriter.write(first) let text = try CGATSWriter.write(first)
let second = try CGATSParser.parse(text) let second = try CGATSParser.parse(text)
#expect(second.format == first.format) XCTAssertEqual(second.format, first.format)
#expect(second.samples.count == first.samples.count) XCTAssertEqual(second.samples.count, first.samples.count)
#expect(second.colorRep == first.colorRep) XCTAssertEqual(second.colorRep, first.colorRep)
#expect(second.deviceClass == first.deviceClass) XCTAssertEqual(second.deviceClass, first.deviceClass)
} }
@Test("Parses CSV with comma delimiters") func testParseCSV() throws {
func parseCSV() throws {
let csv = """ let csv = """
SAMPLE_ID,SAMPLE_LOC,RGB_R,RGB_G,RGB_B,XYZ_X,XYZ_Y,XYZ_Z,LAB_L,LAB_A,LAB_B 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 1,A1,50,0,0,20,10,5,50,60,30
2,A2,0,50,0,10,30,5,60,-50,40 2,A2,0,50,0,10,30,5,60,-50,40
""" """
let dataset = try CGATSParser.parse(csv, sourceURL: URL(fileURLWithPath: "/tmp/sample.csv")) let dataset = try CGATSParser.parse(csv, sourceURL: URL(fileURLWithPath: "/tmp/sample.csv"))
#expect(dataset.format == .csv) XCTAssertEqual(dataset.format, .csv)
#expect(dataset.samples.count == 2) XCTAssertEqual(dataset.samples.count, 2)
#expect(dataset.samples[0].values["RGB_R"] == "50.0000") XCTAssertEqual(dataset.samples[0].values["RGB_R"], "50.0000")
} }
@Test("Converts 0-255 device values to 0-100") func testConverts255To100() throws {
func converts255To100() throws {
let rgb = """ let rgb = """
CTI3 CTI3
COLOR_REP RGB COLOR_REP RGB
@@ -72,12 +67,11 @@ struct CGATSParserTests {
END_DATA END_DATA
""" """
let dataset = try CGATSParser.parse(rgb) let dataset = try CGATSParser.parse(rgb)
#expect(dataset.samples[0].values["RGB_R"] == "100.0000") XCTAssertEqual(dataset.samples[0].values["RGB_R"], "100.0000")
#expect(dataset.samples[0].values["RGB_G"] == "50.1961") XCTAssertEqual(dataset.samples[0].values["RGB_G"], "50.1961")
} }
@Test("Synthesizes COLOR_REP and DEVICE_CLASS when missing") func testSynthesizesMetadata() throws {
func synthesizesMetadata() throws {
let cmyk = """ let cmyk = """
CTI3 CTI3
NUMBER_OF_FIELDS 6 NUMBER_OF_FIELDS 6
@@ -90,19 +84,15 @@ struct CGATSParserTests {
END_DATA END_DATA
""" """
let dataset = try CGATSParser.parse(cmyk) let dataset = try CGATSParser.parse(cmyk)
#expect(dataset.colorRep == "CMYK") XCTAssertEqual(dataset.colorRep, "CMYK")
#expect(dataset.deviceClass == "PRINTER") XCTAssertEqual(dataset.deviceClass, "PRINTER")
} }
@Test("Rejects empty file") func testRejectsEmpty() {
func rejectsEmpty() { XCTAssertThrowsError(try CGATSParser.parse(""))
#expect(throws: (any Error).self) {
_ = try CGATSParser.parse("")
}
} }
@Test("Rejects malformed arity") func testRejectsArity() {
func rejectsArity() {
let bad = """ let bad = """
CTI3 CTI3
NUMBER_OF_FIELDS 2 NUMBER_OF_FIELDS 2
@@ -114,21 +104,18 @@ struct CGATSParserTests {
1 1
END_DATA END_DATA
""" """
#expect(throws: (any Error).self) { XCTAssertThrowsError(try CGATSParser.parse(bad))
_ = try CGATSParser.parse(bad)
}
} }
@Test("Writer emits valid .ti3 with tabs and required keywords") func testWriterFormat() throws {
func writerFormat() throws {
let dataset = try CGATSParser.parse(Self.canonicalCTI3) let dataset = try CGATSParser.parse(Self.canonicalCTI3)
let text = try CGATSWriter.write(dataset) let text = try CGATSWriter.write(dataset)
#expect(text.contains("CTI3")) XCTAssertTrue(text.contains("CTI3"))
#expect(text.contains("BEGIN_DATA_FORMAT")) XCTAssertTrue(text.contains("BEGIN_DATA_FORMAT"))
#expect(text.contains("BEGIN_DATA")) XCTAssertTrue(text.contains("BEGIN_DATA"))
#expect(text.contains("END_DATA")) XCTAssertTrue(text.contains("END_DATA"))
#expect(text.contains("COLOR_REP")) XCTAssertTrue(text.contains("COLOR_REP"))
#expect(text.contains("DEVICE_CLASS")) XCTAssertTrue(text.contains("DEVICE_CLASS"))
#expect(text.contains("\t")) 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 Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@Suite("CalibrationStore") final class CalibrationStoreTests: XCTestCase {
struct CalibrationStoreTests {
private static let sampleCal = """ private static let sampleCal = """
CTI3 CTI3
@@ -23,8 +22,7 @@ struct CalibrationStoreTests {
END_DATA END_DATA
""" """
@Test("Loads metadata and curves from .cal") func testParseCal() async throws {
func parseCal() async throws {
let url = FileManager.default.temporaryDirectory let url = FileManager.default.temporaryDirectory
.appendingPathComponent("test_\(UUID().uuidString).cal") .appendingPathComponent("test_\(UUID().uuidString).cal")
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8) try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
@@ -33,17 +31,16 @@ struct CalibrationStoreTests {
try await store.load(url: url) try await store.load(url: url)
let data = await store.data let data = await store.data
#expect(data?.colorRep == "RGB") XCTAssertEqual(data?.colorRep, "RGB")
#expect(data?.descriptor == "Test printer") XCTAssertEqual(data?.descriptor, "Test printer")
#expect(data?.maxTac == 300) XCTAssertEqual(data?.maxTac, 300)
#expect(data?.curves.count == 3) XCTAssertEqual(data?.curves.count, 3)
let r = data?.curves.first { $0.channel == "R" } 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 testStaleCalibration() async throws {
func staleCalibration() async throws {
let url = FileManager.default.temporaryDirectory let url = FileManager.default.temporaryDirectory
.appendingPathComponent("stale_\(UUID().uuidString).cal") .appendingPathComponent("stale_\(UUID().uuidString).cal")
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8) try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
@@ -51,11 +48,10 @@ struct CalibrationStoreTests {
let store = CalibrationStore(staleDays: 0) let store = CalibrationStore(staleDays: 0)
try await store.load(url: url) try await store.load(url: url)
let stale = await store.isStale(comparedTo: "Other") let stale = await store.isStale(comparedTo: "Other")
#expect(stale == true) XCTAssertEqual(stale, true)
} }
@Test("Printer mismatch is flagged as stale") func testPrinterMismatch() async throws {
func printerMismatch() async throws {
let url = FileManager.default.temporaryDirectory let url = FileManager.default.temporaryDirectory
.appendingPathComponent("mismatch_\(UUID().uuidString).cal") .appendingPathComponent("mismatch_\(UUID().uuidString).cal")
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8) try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
@@ -64,6 +60,6 @@ struct CalibrationStoreTests {
try await store.load(url: url) try await store.load(url: url)
await store.setPrinterName("Printer A") await store.setPrinterName("Printer A")
let stale = await store.isStale(comparedTo: "Printer B") let stale = await store.isStale(comparedTo: "Printer B")
#expect(stale == true) XCTAssertEqual(stale, true)
} }
} }
@@ -1,12 +1,10 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@Suite("CalibrationTargenArgs") final class CalibrationTargenArgsTests: XCTestCase {
struct CalibrationTargenArgsTests {
@Test("RGB baseline") func testRgbBaseline() throws {
func rgbBaseline() throws {
let config = CalibrationTargenConfig( let config = CalibrationTargenConfig(
colourSpace: .rgb, colourSpace: .rgb,
steps: 21, steps: 21,
@@ -15,11 +13,10 @@ struct CalibrationTargenArgsTests {
workingDirectory: URL(fileURLWithPath: "/tmp") workingDirectory: URL(fileURLWithPath: "/tmp")
) )
let args = try CalibrationTargenArgs.build(config: config) 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 testCmykWithOptions() throws {
func cmykWithOptions() throws {
let config = CalibrationTargenConfig( let config = CalibrationTargenConfig(
colourSpace: .cmyk, colourSpace: .cmyk,
steps: 25, steps: 25,
@@ -30,33 +27,26 @@ struct CalibrationTargenArgsTests {
workingDirectory: URL(fileURLWithPath: "/tmp") workingDirectory: URL(fileURLWithPath: "/tmp")
) )
let args = try CalibrationTargenArgs.build(config: config) 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 testRejectsBadSteps() {
func rejectsBadSteps() {
let config = CalibrationTargenConfig(steps: 5, basename: "demo") let config = CalibrationTargenConfig(steps: 5, basename: "demo")
#expect(throws: (any Error).self) { XCTAssertThrowsError(try CalibrationTargenArgs.build(config: config))
_ = try CalibrationTargenArgs.build(config: config)
}
} }
@Test("Rejects bad CMYK ink limit") func testRejectsBadInkLimit() {
func rejectsBadInkLimit() {
let config = CalibrationTargenConfig( let config = CalibrationTargenConfig(
colourSpace: .cmyk, colourSpace: .cmyk,
inkLimit: 500, inkLimit: 500,
basename: "demo" basename: "demo"
) )
#expect(throws: (any Error).self) { XCTAssertThrowsError(try CalibrationTargenArgs.build(config: config))
_ = try CalibrationTargenArgs.build(config: config)
}
} }
@Test("Does not double-prefix an existing CAL_ basename") func testNoDoublePrefix() throws {
func noDoublePrefix() throws {
let config = CalibrationTargenConfig(basename: "CAL_test") let config = CalibrationTargenConfig(basename: "CAL_test")
let args = try CalibrationTargenArgs.build(config: config) let args = try CalibrationTargenArgs.build(config: config)
#expect(args.last == "CAL_test") XCTAssertEqual(args.last, "CAL_test")
} }
} }
+23 -32
View File
@@ -1,72 +1,63 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@Suite("ColprofArgs") final class ColprofArgsTests: XCTestCase {
struct ColprofArgsTests {
@Test("Default algorithm and quality") func testDefaults() throws {
func defaults() throws {
let config = ColprofConfig(basename: "target") let config = ColprofConfig(basename: "target")
let args = try ColprofArgs.build(config: config) 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 testFwaBareFlag() throws {
func fwaBareFlag() throws {
let config = ColprofConfig(fwa: "", basename: "target") let config = ColprofConfig(fwa: "", basename: "target")
let args = try ColprofArgs.build(config: config) 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 testFwaD50() throws {
func fwaD50() throws {
let config = ColprofConfig(fwa: "D50", basename: "target") let config = ColprofConfig(fwa: "D50", basename: "target")
let args = try ColprofArgs.build(config: config) let args = try ColprofArgs.build(config: config)
#expect(args.contains("-f")) XCTAssertTrue(args.contains("-f"))
#expect(args.contains("D50")) XCTAssertTrue(args.contains("D50"))
#expect(args.last == "target") XCTAssertEqual(args.last, "target")
} }
@Test("FWA none is omitted") func testFwaNoneOmitted() throws {
func fwaNoneOmitted() throws {
let config = ColprofConfig(fwa: "none", basename: "target") let config = ColprofConfig(fwa: "none", basename: "target")
let args = try ColprofArgs.build(config: config) let args = try ColprofArgs.build(config: config)
#expect(!args.contains("-f")) XCTAssertFalse(args.contains("-f"))
} }
@Test("Viewing conditions skip none") func testViewingCondNoneSkipped() throws {
func viewingCondNoneSkipped() throws {
let config = ColprofConfig( let config = ColprofConfig(
inputViewingCond: "none", inputViewingCond: "none",
outputViewingCond: "mt", outputViewingCond: "mt",
basename: "target" basename: "target"
) )
let args = try ColprofArgs.build(config: config) let args = try ColprofArgs.build(config: config)
#expect(!args.contains("-c")) XCTAssertFalse(args.contains("-c"))
#expect(args.contains("-d")) XCTAssertTrue(args.contains("-d"))
#expect(args.contains("mt")) XCTAssertTrue(args.contains("mt"))
} }
@Test("Description falls back to basename when empty") func testDescriptionFallback() throws {
func descriptionFallback() throws {
let config = ColprofConfig(description: "", basename: "target") let config = ColprofConfig(description: "", basename: "target")
let args = try ColprofArgs.build(config: config) let args = try ColprofArgs.build(config: config)
#expect(!args.contains("-D")) XCTAssertFalse(args.contains("-D"))
} }
@Test("Copyright only when non-empty") func testCopyright() throws {
func copyright() throws {
let config = ColprofConfig(copyright: "Gronod 2026", basename: "target") let config = ColprofConfig(copyright: "Gronod 2026", basename: "target")
let args = try ColprofArgs.build(config: config) let args = try ColprofArgs.build(config: config)
#expect(args.contains("-C")) XCTAssertTrue(args.contains("-C"))
#expect(args.contains("Gronod 2026")) XCTAssertTrue(args.contains("Gronod 2026"))
} }
@Test("No -u passed") func testNoProgressJsonFlag() throws {
func noProgressJsonFlag() throws {
let config = ColprofConfig(basename: "target") let config = ColprofConfig(basename: "target")
let args = try ColprofArgs.build(config: config) let args = try ColprofArgs.build(config: config)
#expect(!args.contains("-u")) XCTAssertFalse(args.contains("-u"))
} }
} }
@@ -1,24 +1,20 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@Suite("ColprofProgress") final class ColprofProgressTests: XCTestCase {
struct ColprofProgressTests {
@Test("Classifies gamut mapping") func testGamutMapping() {
func gamutMapping() { XCTAssertEqual(ColprofProgressClassifier.classify(line: "Gamut mapping calculation in progress"), .gamutMapping)
#expect(ColprofProgressClassifier.classify(line: "Gamut mapping calculation in progress") == .gamutMapping)
} }
@Test("Classifies fitting or clut") func testFitting() {
func fitting() { XCTAssertEqual(ColprofProgressClassifier.classify(line: "Fitting cLUT grid points"), .fittingClut)
#expect(ColprofProgressClassifier.classify(line: "Fitting cLUT grid points") == .fittingClut) XCTAssertEqual(ColprofProgressClassifier.classify(line: "clut table"), .fittingClut)
#expect(ColprofProgressClassifier.classify(line: "clut table") == .fittingClut)
} }
@Test("Classifies writing") func testWriting() {
func writing() { XCTAssertEqual(ColprofProgressClassifier.classify(line: "Writing ICC profile header"), .writingIcc)
#expect(ColprofProgressClassifier.classify(line: "Writing ICC profile header") == .writingIcc) XCTAssertEqual(ColprofProgressClassifier.classify(line: "icc profile written"), .writingIcc)
#expect(ColprofProgressClassifier.classify(line: "icc profile written") == .writingIcc)
} }
} }
@@ -1,4 +1,4 @@
import Testing import XCTest
import Foundation import Foundation
@testable import ICCeryCore @testable import ICCeryCore
@testable import ICCery @testable import ICCery
@@ -6,48 +6,42 @@ import AppKit
import ApplicationServices import ApplicationServices
/// Issue 14 PMPrintSettingsToOptions capture filter (docs/11 layer ). /// Issue 14 PMPrintSettingsToOptions capture filter (docs/11 layer ).
@Suite("CupsOptionsFilter") final class CupsOptionsFilterTests: XCTestCase {
struct CupsOptionsFilterTests {
@Test("Drops com.apple.*, collate, copies, job-sheets, AP_* keys") func testDropsReserved() {
func dropsReserved() {
let raw = "AP_ColorMatchingMode=AP_ApplicationColorMatching " let raw = "AP_ColorMatchingMode=AP_ApplicationColorMatching "
+ "AP.ColorMatchingMode=AP_ApplicationColorMatching " + "AP.ColorMatchingMode=AP_ApplicationColorMatching "
+ "com.apple.print.JobTicket.PMTotalSidesImaged=0 " + "com.apple.print.JobTicket.PMTotalSidesImaged=0 "
+ "collate=true copies=1 job-sheets=none,none " + "collate=true copies=1 job-sheets=none,none "
+ "pserrorhandler-requested=standard " + "pserrorhandler-requested=standard "
+ "MediaType=PhotographicGlossy" + "MediaType=PhotographicGlossy"
#expect(CupsOptionsFilter.filter(raw) == "MediaType=PhotographicGlossy") XCTAssertEqual(CupsOptionsFilter.filter(raw), "MediaType=PhotographicGlossy")
} }
@Test("Keeps relevant driver keys, order preserved") func testKeepsRelevant() {
func keepsRelevant() {
let raw = "InputSlot=Rear PageSize=A4 CNIJIntent2=4 " let raw = "InputSlot=Rear PageSize=A4 CNIJIntent2=4 "
+ "Resolution=600x600dpi Duplex=None" + "Resolution=600x600dpi Duplex=None"
#expect(CupsOptionsFilter.filter(raw) == raw) XCTAssertEqual(CupsOptionsFilter.filter(raw), raw)
} }
@Test("Permissive: unknown non-com.* keys survive") func testKeepsUnknown() {
func keepsUnknown() {
let raw = "VendorFooBar=baz MediaType=Plain" let raw = "VendorFooBar=baz MediaType=Plain"
#expect(CupsOptionsFilter.filter(raw) == raw) XCTAssertEqual(CupsOptionsFilter.filter(raw), raw)
} }
@Test("Drops empty keys and values") func testDropsEmpty() {
func dropsEmpty() {
let raw = "=noval MediaType= InputSlot=Rear" let raw = "=noval MediaType= InputSlot=Rear"
// "MediaType=" has an empty value dropped; "=noval" empty key. // "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 testExtractMedia() {
func extractMedia() { XCTAssertEqual(CupsParsers.extractMediaType(
#expect(CupsParsers.extractMediaType( fromOptionsString: "MediaType=Photo EPIJ_Medi=1"), "Photo")
fromOptionsString: "MediaType=Photo EPIJ_Medi=1") == "Photo") XCTAssertEqual(CupsParsers.extractMediaType(
#expect(CupsParsers.extractMediaType( fromOptionsString: "EPIJ_Medi=7"), "7")
fromOptionsString: "EPIJ_Medi=7") == "7") XCTAssertNil(CupsParsers.extractMediaType(
#expect(CupsParsers.extractMediaType( fromOptionsString: "PageSize=A4"))
fromOptionsString: "PageSize=A4") == nil)
} }
} }
@@ -55,9 +49,8 @@ struct CupsOptionsFilterTests {
/// `@convention(c)` closures can't capture, so recording goes through /// `@convention(c)` closures can't capture, so recording goes through
/// a file-scope recorder keyed by global state; no private symbols are /// a file-scope recorder keyed by global state; no private symbols are
/// touched. /// touched.
@Suite("ColorSyncSuppressor")
@MainActor @MainActor
struct ColorSyncSuppressorTests { final class ColorSyncSuppressorTests: XCTestCase {
/// Fake PMPrintSession the injected resolver never dereferences it. /// Fake PMPrintSession the injected resolver never dereferences it.
private var fakeSession: PMPrintSession { private var fakeSession: PMPrintSession {
@@ -90,55 +83,50 @@ struct ColorSyncSuppressorTests {
return s return s
} }
@Test("Attempt order: Lock → Mode → NoLock, AP_ prefix first") func testAttemptOrder() {
func attemptOrder() {
Self.recorded = [] Self.recorded = []
Self.succeeding = nil Self.succeeding = nil
Self.missing = ["PMSessionSetColorMatchingModeLock"] Self.missing = ["PMSessionSetColorMatchingModeLock"]
let s = makeSuppressor() 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. // Lock is unresolvable skipped; the rest plays out in order.
#expect(Self.recorded.map { "\($0.0)|\($0.1)" } XCTAssertEqual(Self.recorded.map { "\($0.0)|\($0.1)" }, ColorMatchingAttempts.attempts
== ColorMatchingAttempts.attempts
.filter { $0.symbol != "PMSessionSetColorMatchingModeLock" } .filter { $0.symbol != "PMSessionSetColorMatchingModeLock" }
.map { "\($0.symbol)|\($0.mode)" }) .map { "\($0.symbol)|\($0.mode)" })
} }
@Test("First zero wins — later symbols/modes not called") func testFirstZeroWins() {
func firstZeroWins() {
Self.recorded = [] Self.recorded = []
Self.succeeding = ("PMSessionSetColorMatchingModeLock", Self.succeeding = ("PMSessionSetColorMatchingModeLock",
"AP_ApplicationColorMatching") "AP_ApplicationColorMatching")
Self.missing = [] Self.missing = []
let s = makeSuppressor() let s = makeSuppressor()
#expect(s.applySPIMode(to: fakeSession)) XCTAssertTrue(s.applySPIMode(to: fakeSession))
#expect(Self.recorded.map { "\($0.0)|\($0.1)" } == [ XCTAssertEqual(Self.recorded.map { "\($0.0)|\($0.1)" }, [
"PMSessionSetColorMatchingModeLock|AP_ApplicationColorMatching", "PMSessionSetColorMatchingModeLock|AP_ApplicationColorMatching",
]) ])
} }
@Test("Mode fallback: AP_ rejected → ApplicationColorMatching tried") func testModeFallback() {
func modeFallback() {
Self.recorded = [] Self.recorded = []
Self.succeeding = ("PMSessionSetColorMatchingModeLock", Self.succeeding = ("PMSessionSetColorMatchingModeLock",
"ApplicationColorMatching") "ApplicationColorMatching")
Self.missing = [] Self.missing = []
let s = makeSuppressor() let s = makeSuppressor()
#expect(s.applySPIMode(to: fakeSession)) XCTAssertTrue(s.applySPIMode(to: fakeSession))
#expect(Self.recorded[0].0 == "PMSessionSetColorMatchingModeLock") XCTAssertEqual(Self.recorded[0].0, "PMSessionSetColorMatchingModeLock")
#expect(Self.recorded[0].1 == "AP_ApplicationColorMatching") XCTAssertEqual(Self.recorded[0].1, "AP_ApplicationColorMatching")
#expect(Self.recorded[1].0 == "PMSessionSetColorMatchingModeLock") XCTAssertEqual(Self.recorded[1].0, "PMSessionSetColorMatchingModeLock")
#expect(Self.recorded[1].1 == "ApplicationColorMatching") XCTAssertEqual(Self.recorded[1].1, "ApplicationColorMatching")
#expect(Self.recorded.count == 2) XCTAssertEqual(Self.recorded.count, 2)
} }
@Test("All symbols missing → false, no calls") func testAllMissing() {
func allMissing() {
Self.recorded = [] Self.recorded = []
Self.succeeding = nil Self.succeeding = nil
Self.missing = Set(ColorMatchingAttempts.symbols) Self.missing = Set(ColorMatchingAttempts.symbols)
let s = makeSuppressor() let s = makeSuppressor()
#expect(s.applySPIMode(to: fakeSession) == false) XCTAssertEqual(s.applySPIMode(to: fakeSession), false)
#expect(Self.recorded.isEmpty) XCTAssertTrue(Self.recorded.isEmpty)
} }
} }
+51 -62
View File
@@ -1,11 +1,10 @@
import Testing import XCTest
import Foundation import Foundation
@testable import ICCeryCore @testable import ICCeryCore
/// Issue 12 CUPS enumeration parsers on recorded fixtures /// Issue 12 CUPS enumeration parsers on recorded fixtures
/// (docs/1011). No live `lpstat`/`lpoptions` is spawned here. /// (docs/1011). No live `lpstat`/`lpoptions` is spawned here.
@Suite("CupsParsers") final class CupsParsersTests: XCTestCase {
struct CupsParsersTests {
// Recorded on an Epson XP-55 + Canon Pro9500 host. // Recorded on an Epson XP-55 + Canon Pro9500 host.
private let lpstatE = """ private let lpstatE = """
@@ -34,112 +33,102 @@ struct CupsParsersTests {
cupsPrintQuality/cupsPrintQuality: Draft *Normal High cupsPrintQuality/cupsPrintQuality: Draft *Normal High
""" """
@Test("lpstat -e: one destination per line; empty = success") func testDestinations() {
func destinations() { XCTAssertEqual(CupsParsers.lpstatDestinations(lpstatE), [
#expect(CupsParsers.lpstatDestinations(lpstatE) == [
"Canon_Pro9500_II_series_XPS", "Canon_Pro9500_II_series_XPS",
"Epson_XP_55_LPD", "Epson_XP_55_LPD",
"EPSON_XP_55_Series", "EPSON_XP_55_Series",
]) ])
#expect(CupsParsers.lpstatDestinations("") == []) XCTAssertEqual(CupsParsers.lpstatDestinations(""), [])
} }
@Test("lpstat -p: idle / now-printing / disabled statuses") func testStatuses() {
func statuses() {
let s = CupsParsers.lpstatStatuses(lpstatP) let s = CupsParsers.lpstatStatuses(lpstatP)
#expect(s["Canon_Pro9500_II_series_XPS"] == .idle) XCTAssertEqual(s["Canon_Pro9500_II_series_XPS"], .idle)
#expect(s["Epson_XP_55_LPD"] == .printing) XCTAssertEqual(s["Epson_XP_55_LPD"], .printing)
#expect(s["EPSON_XP_55_Series"] == .stopped) XCTAssertEqual(s["EPSON_XP_55_Series"], .stopped)
} }
@Test("lpstat -d: default destination or none") func testDefaultDestination() {
func defaultDestination() { XCTAssertEqual(CupsParsers.lpstatDefault(
#expect(CupsParsers.lpstatDefault( "system default destination: Canon_Pro9500_II_series_XPS\n"), "Canon_Pro9500_II_series_XPS")
"system default destination: Canon_Pro9500_II_series_XPS\n") XCTAssertNil(CupsParsers.lpstatDefault("no system default destination\n"))
== "Canon_Pro9500_II_series_XPS")
#expect(CupsParsers.lpstatDefault("no system default destination\n") == nil)
} }
@Test("lpoptions -p: quoted printer-info, bare flags ignored") func testDisplayName() {
func displayName() { XCTAssertEqual(CupsParsers.lpoptionsDisplayName(lpoptionsP), "EPSON XP-55 Series")
#expect(CupsParsers.lpoptionsDisplayName(lpoptionsP) == "EPSON XP-55 Series") XCTAssertNil(CupsParsers.lpoptionsDisplayName("printer-type=42\n"))
#expect(CupsParsers.lpoptionsDisplayName("printer-type=42\n") == nil)
} }
@Test("lpoptions -l: key/label split, * marks the default") func testOptionListings() {
func optionListings() {
let listings = CupsParsers.lpoptionsList(lpoptionsL) let listings = CupsParsers.lpoptionsList(lpoptionsL)
#expect(listings.count == 6) XCTAssertEqual(listings.count, 6)
let page = listings[0] let page = listings[0]
#expect(page.key == "PageSize") XCTAssertEqual(page.key, "PageSize")
#expect(page.label == "Media Size") XCTAssertEqual(page.label, "Media Size")
#expect(page.defaultChoice == "A4") XCTAssertEqual(page.defaultChoice, "A4")
#expect(page.choices.contains("Custom.WIDTHxHEIGHT")) XCTAssertTrue(page.choices.contains("Custom.WIDTHxHEIGHT"))
#expect(!page.choices.contains("*A4")) XCTAssertFalse(page.choices.contains("*A4"))
let slot = listings[1] let slot = listings[1]
#expect(slot.key == "InputSlot") XCTAssertEqual(slot.key, "InputSlot")
#expect(slot.choices == ["Auto", "Main", "Photo", "Rear"]) XCTAssertEqual(slot.choices, ["Auto", "Main", "Photo", "Rear"])
#expect(slot.defaultChoice == "Main") XCTAssertEqual(slot.defaultChoice, "Main")
} }
@Test("capabilities: trays/sizes index 1-based, media uses detected key") func testCapabilities() {
func capabilities() {
let service = CupsService() let service = CupsService()
let listings = CupsParsers.lpoptionsList(lpoptionsL) let listings = CupsParsers.lpoptionsList(lpoptionsL)
let caps = service.capabilities(from: listings, ppd: nil) let caps = service.capabilities(from: listings, ppd: nil)
#expect(caps.trays == [ XCTAssertEqual(caps.trays, [
PrinterTray(id: 1, name: "Auto"), PrinterTray(id: 1, name: "Auto"),
PrinterTray(id: 2, name: "Main"), PrinterTray(id: 2, name: "Main"),
PrinterTray(id: 3, name: "Photo"), PrinterTray(id: 3, name: "Photo"),
PrinterTray(id: 4, name: "Rear"), PrinterTray(id: 4, name: "Rear"),
]) ])
#expect(caps.paperSizes.first == PrinterPaperSize(id: 1, name: "3.5x5")) XCTAssertEqual(caps.paperSizes.first, PrinterPaperSize(id: 1, name: "3.5x5"))
#expect(caps.paperSizes.count == 10) XCTAssertEqual(caps.paperSizes.count, 10)
#expect(caps.mediaTypes.map(\.id) == [ XCTAssertEqual(caps.mediaTypes.map(\.id), [
"Stationery", "PhotographicHighGloss", "Photographic", "Stationery", "PhotographicHighGloss", "Photographic",
"PhotographicMatte", "Envelope", "PhotographicMatte", "Envelope",
]) ])
#expect(caps.supportsOrientation) XCTAssertTrue(caps.supportsOrientation)
} }
@Test("PPD enrichment maps id → human label") func testPpdLabels() {
func ppdLabels() {
let ppd = """ let ppd = """
*CNIJMediaType 42/Photo Paper Plus Semi-gloss: "<</MediaType(42)>>" *CNIJMediaType 42/Photo Paper Plus Semi-gloss: "<</MediaType(42)>>"
*CNIJMediaType 0/Plain Paper: "" *CNIJMediaType 0/Plain Paper: ""
*en_US.CNIJMediaType 13/Envelope: "" *en_US.CNIJMediaType 13/Envelope: ""
""" """
let labels = CupsParsers.ppdChoiceLabels(ppd, key: "CNIJMediaType") let labels = CupsParsers.ppdChoiceLabels(ppd, key: "CNIJMediaType")
#expect(labels["42"] == "Photo Paper Plus Semi-gloss") XCTAssertEqual(labels["42"], "Photo Paper Plus Semi-gloss")
#expect(labels["0"] == "Plain Paper") XCTAssertEqual(labels["0"], "Plain Paper")
#expect(labels["13"] == "Envelope") XCTAssertEqual(labels["13"], "Envelope")
} }
@Test("detectMediaTypeKey prefers vendor keys in order") func testMediaTypeKey() {
func mediaTypeKey() { XCTAssertEqual(CupsParsers.detectMediaTypeKey(
#expect(CupsParsers.detectMediaTypeKey( optionKeys: ["MediaType", "CNIJMediaType"]), "CNIJMediaType")
optionKeys: ["MediaType", "CNIJMediaType"]) == "CNIJMediaType") XCTAssertEqual(CupsParsers.detectMediaTypeKey(
#expect(CupsParsers.detectMediaTypeKey( optionKeys: ["PageSize", "MediaType"]), "MediaType")
optionKeys: ["PageSize", "MediaType"]) == "MediaType") XCTAssertNil(CupsParsers.detectMediaTypeKey(optionKeys: ["PageSize"]))
#expect(CupsParsers.detectMediaTypeKey(optionKeys: ["PageSize"]) == nil)
} }
@Test("Driver bypass: Canon Intent2 > Intent; Epson CCor > CMat") func testDriverBypass() {
func driverBypass() {
func pair(_ keys: Set<String>) -> String? { func pair(_ keys: Set<String>) -> String? {
CupsParsers.detectDriverColorBypass(optionKeys: keys) CupsParsers.detectDriverColorBypass(optionKeys: keys)
.map { "\($0.key)=\($0.value)" } .map { "\($0.key)=\($0.value)" }
} }
#expect(pair(["CNIJIntent2", "CNIJIntent"]) == "CNIJIntent2=4") XCTAssertEqual(pair(["CNIJIntent2", "CNIJIntent"]), "CNIJIntent2=4")
#expect(pair(["CNIJIntent"]) == "CNIJIntent=4") XCTAssertEqual(pair(["CNIJIntent"]), "CNIJIntent=4")
#expect(pair(["EPIJ_CCor", "EPIJ_CMat"]) == "EPIJ_CCor=0") XCTAssertEqual(pair(["EPIJ_CCor", "EPIJ_CMat"]), "EPIJ_CCor=0")
#expect(pair(["EPIJ_CMat"]) == "EPIJ_CMat=3") XCTAssertEqual(pair(["EPIJ_CMat"]), "EPIJ_CMat=3")
#expect(pair(["StpColorCorrection"]) == "StpColorCorrection=Uncorrected") XCTAssertEqual(pair(["StpColorCorrection"]), "StpColorCorrection=Uncorrected")
#expect(pair(["ColorCorrection"]) == "ColorCorrection=Uncorrected") XCTAssertEqual(pair(["ColorCorrection"]), "ColorCorrection=Uncorrected")
#expect(pair(["EpsonColorMode"]) == "EpsonColorMode=Off") XCTAssertEqual(pair(["EpsonColorMode"]), "EpsonColorMode=Off")
#expect(pair(["PageSize"]) == nil) XCTAssertNil(pair(["PageSize"]))
} }
} }
+20 -30
View File
@@ -1,65 +1,57 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@Suite("DriftAlert") final class DriftAlertTests: XCTestCase {
struct DriftAlertTests {
@Test("No alert with fewer than two poor results") func testNotEnough() {
func notEnough() {
let records = [ let records = [
record(avg: 4.0, at: 1000) 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 testOneHourApart() {
func oneHourApart() {
let records = [ let records = [
record(avg: 4.0, at: 1000), record(avg: 4.0, at: 1000),
record(avg: 5.0, at: 4600) 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 testSameDayUnderHour() {
func sameDayUnderHour() {
let records = [ let records = [
record(avg: 4.0, at: 1000), record(avg: 4.0, at: 1000),
record(avg: 5.0, at: 2000) record(avg: 5.0, at: 2000)
] ]
#expect(DriftAlert.compute(from: records) == nil) XCTAssertNil(DriftAlert.compute(from: records))
} }
@Test("Alert on distinct days") func testDistinctDays() {
func distinctDays() {
let day1 = record(avg: 4.0, at: 0) let day1 = record(avg: 4.0, at: 0)
let day2 = record(avg: 5.0, at: 86400 + 1000) 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 testNonPoor() {
func nonPoor() {
let records = [ let records = [
record(avg: 1.0, at: 0), record(avg: 1.0, at: 0),
record(avg: 1.5, at: 86400) 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 testNonPoorBreaksRun() {
func nonPoorBreaksRun() {
let records = [ let records = [
record(avg: 4.0, at: 0), // poor record(avg: 4.0, at: 0), // poor
record(avg: 4.5, at: 86400), // poor, far apart record(avg: 4.5, at: 86400), // poor, far apart
record(avg: 1.0, at: 90000), // good breaks the run record(avg: 1.0, at: 90000), // good breaks the run
record(avg: 4.0, at: 92000) // poor, recent but close to previous poor 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 testOnlySuffixRun() {
func onlySuffixRun() {
let records = [ let records = [
record(avg: 4.0, at: 0), // poor record(avg: 4.0, at: 0), // poor
record(avg: 4.5, at: 18000), // poor, > 1h from first 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.0, at: 25000), // poor
record(avg: 4.5, at: 26000) // poor, < 1h and same day 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 testSuffixRunAlerts() {
func suffixRunAlerts() {
let records = [ let records = [
record(avg: 1.0, at: 0), // good record(avg: 1.0, at: 0), // good
record(avg: 4.0, at: 1000), // poor record(avg: 4.0, at: 1000), // poor
record(avg: 4.5, at: 4600) // poor, 1h after previous 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 testSingleFinalPoor() {
func singleFinalPoor() {
let records = [ let records = [
record(avg: 1.0, at: 0), record(avg: 1.0, at: 0),
record(avg: 4.0, at: 86400) 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 { private func record(avg: Double, at offset: TimeInterval) -> VerificationRecord {
+35 -38
View File
@@ -1,4 +1,4 @@
import Testing import XCTest
import Foundation import Foundation
@testable import ICCeryCore @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) try contents.write(to: url, atomically: true, encoding: .utf8)
} }
@Suite("PathSecurity") final class PathSecurityTests: XCTestCase {
struct PathSecurityTests { func testRejectsTraversalAndSeparators() {
@Test func rejectsTraversalAndSeparators() {
for bad in ["a/b", "a\\b", "..", "a/../b", "", "..x"] { for bad in ["a/b", "a\\b", "..", "a/../b", "", "..x"] {
#expect(!PathSecurity.isValidBasename(bad)) XCTAssertFalse(PathSecurity.isValidBasename(bad))
#expect(throws: PathSecurity.Error.self) { XCTAssertThrowsError(try PathSecurity.sanitizeBasename(bad)) { error in
try PathSecurity.sanitizeBasename(bad) XCTAssertTrue(error is PathSecurity.Error)
} }
} }
} }
@Test func acceptsNormalNames() { func testAcceptsNormalNames() {
for good in ["target", "My Target 01", "écheneau-ümläut", "a.b"] { 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() 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 missing = URL(fileURLWithPath: "/nonexistent-\(UUID().uuidString)")
let resolved = PathSecurity.resolveSafeCwd(missing) let resolved = PathSecurity.resolveSafeCwd(missing)
#expect(FileManager.default.fileExists(atPath: resolved.path)) XCTAssertTrue(FileManager.default.fileExists(atPath: resolved.path))
} }
} }
@Suite("AtomicFileWriter") final class AtomicFileWriterTests: XCTestCase {
struct AtomicFileWriterTests { func testWritesAndLeavesNoTmp() throws {
@Test func writesAndLeavesNoTmp() throws {
let dir = try tempDir() let dir = try tempDir()
let url = dir.appendingPathComponent("state.json") let url = dir.appendingPathComponent("state.json")
try AtomicFileWriter.write(Data("{\"a\":1}".utf8), to: url) try AtomicFileWriter.write(Data("{\"a\":1}".utf8), to: url)
#expect(try String(contentsOf: url, encoding: .utf8) == "{\"a\":1}") XCTAssertEqual(try String(contentsOf: url, encoding: .utf8), "{\"a\":1}")
#expect(!FileManager.default.fileExists(atPath: url.appendingPathExtension("tmp").path)) XCTAssertFalse(FileManager.default.fileExists(atPath: url.appendingPathExtension("tmp").path))
} }
@Test func overwritesExistingAtomically() throws { func testOverwritesExistingAtomically() throws {
let dir = try tempDir() let dir = try tempDir()
let url = dir.appendingPathComponent("f.txt") let url = dir.appendingPathComponent("f.txt")
try AtomicFileWriter.write("one", to: url) try AtomicFileWriter.write("one", to: url)
try AtomicFileWriter.write("two-longer", 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 dir = try tempDir()
let url = dir.appendingPathComponent("a/b/c/deep.json") let url = dir.appendingPathComponent("a/b/c/deep.json")
try AtomicFileWriter.write("{}", to: url) try AtomicFileWriter.write("{}", to: url)
#expect(FileManager.default.fileExists(atPath: url.path)) XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
} }
} }
@Suite("ArtefactProbe") final class ArtefactProbeTests: XCTestCase {
struct ArtefactProbeTests { func testVerifyProgression() throws {
@Test func verifyProgression() throws {
let dir = try tempDir() let dir = try tempDir()
var v = ArtefactProbe.verify(basename: "t", cwd: dir) var v = ArtefactProbe.verify(basename: "t", cwd: dir)
#expect(v == StageArtefacts()) XCTAssertEqual(v, StageArtefacts())
try touch(dir.appendingPathComponent("t.ti1")) try touch(dir.appendingPathComponent("t.ti1"))
v = ArtefactProbe.verify(basename: "t", cwd: dir) 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.ti2"))
try touch(dir.appendingPathComponent("t.ti3")) try touch(dir.appendingPathComponent("t.ti3"))
v = ArtefactProbe.verify(basename: "t", cwd: dir) 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")) try touch(dir.appendingPathComponent("t.icc"))
v = ArtefactProbe.verify(basename: "t", cwd: dir) 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() let dir = try tempDir()
try touch(dir.appendingPathComponent("p.icc")) try touch(dir.appendingPathComponent("p.icc"))
try touch(dir.appendingPathComponent("p.icm")) try touch(dir.appendingPathComponent("p.icm"))
let profile = ArtefactProbe.resolveProfile(basename: "p", cwd: dir) 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() let dir = try tempDir()
for name in [ for name in [
"t.ti1", "t.ti2", "t.tif", "t.2.tif", "t_03.tif", "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.ti3", "t_pass1.ti3", "t_pass2.ti3",
"t.icc", "t.gam", "CAL_t.ti1", "CAL_t.cal", "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")) XCTAssertFalse(names.contains("other.ti1"))
#expect(!names.contains("t.txt")) XCTAssertFalse(names.contains("t.txt"))
#expect(!names.contains("CAL_other.ti1")) XCTAssertFalse(names.contains("CAL_other.ti1"))
} }
@Test func emptyDirReturnsEmpty() throws { func testEmptyDirReturnsEmpty() throws {
let dir = try tempDir() 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 SceneKit
import ICCeryCore import ICCeryCore
@testable import ICCery @testable import ICCery
/// ``GamutSceneGeometryBuilder`` edge-case tests. /// ``GamutSceneGeometryBuilder`` edge-case tests.
@Suite("Gamut scene geometry builder")
@MainActor @MainActor
struct GamutGeometryBuilderTests { final class GamutGeometryBuilderTests: XCTestCase {
@Test("Drops out-of-bounds faces from the element without crashing") func testDropsOutOfBoundsFaces() {
func dropsOutOfBoundsFaces() {
let white = GamutVertex( let white = GamutVertex(
lab: LabColor(l: 100, a: 0, b: 0), lab: LabColor(l: 100, a: 0, b: 0),
rgb: DisplayRGB(r: 1, g: 1, b: 1) rgb: DisplayRGB(r: 1, g: 1, b: 1)
@@ -28,6 +26,6 @@ struct GamutGeometryBuilderTests {
let (_, element) = GamutSceneGeometryBuilder.geometry(for: mesh) 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 Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
/// ``GamutMeshParser`` acceptance + edge-case tests. /// ``GamutMeshParser`` acceptance + edge-case tests.
@Suite("Gamut mesh parser") final class GamutMeshParserTests: XCTestCase {
struct GamutMeshParserTests {
/// Returns the bundled real `sRGB.gam` in `Resources/Argyll/reference_gamuts`. /// Returns the bundled real `sRGB.gam` in `Resources/Argyll/reference_gamuts`.
private var bundledSRGBGamURL: URL { private var bundledSRGBGamURL: URL {
@@ -13,16 +12,14 @@ struct GamutMeshParserTests {
return resource.appendingPathComponent("Argyll/reference_gamuts/sRGB.gam") return resource.appendingPathComponent("Argyll/reference_gamuts/sRGB.gam")
} }
@Test("Parses bundled sRGB.gam") func testParsesBundledSRGB() throws {
func parsesBundledSRGB() throws {
let mesh = try GamutMeshParser.parse(url: bundledSRGBGamURL) let mesh = try GamutMeshParser.parse(url: bundledSRGBGamURL)
#expect(mesh.vertices.count == 448, "sRGB.gam has 448 vertices") XCTAssertEqual(mesh.vertices.count, 448, "sRGB.gam has 448 vertices")
#expect(mesh.faces.count == 892, "sRGB.gam has 892 faces") XCTAssertEqual(mesh.faces.count, 892, "sRGB.gam has 892 faces")
} }
@Test("Discards VERTEX_NO and uses push-order indices") func testDiscardsVertexNo() throws {
func discardsVertexNo() throws {
let text = """ let text = """
GAMUT GAMUT
NUMBER_OF_FIELDS 4 NUMBER_OF_FIELDS 4
@@ -49,14 +46,13 @@ struct GamutMeshParserTests {
let mesh = try GamutMeshParser.parse(text: text) let mesh = try GamutMeshParser.parse(text: text)
#expect(mesh.vertices.count == 4) XCTAssertEqual(mesh.vertices.count, 4)
#expect(mesh.faces.count == 2) XCTAssertEqual(mesh.faces.count, 2)
#expect(mesh.vertices[0].lab == LabColor(l: 10, a: 20, b: 30)) XCTAssertEqual(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[3].lab, LabColor(l: 40, a: 50, b: 60))
} }
@Test("Ignores comments and blank lines") func testIgnoresComments() throws {
func ignoresComments() throws {
let text = """ let text = """
# Header comment # Header comment
NUMBER_OF_FIELDS 4 NUMBER_OF_FIELDS 4
@@ -81,12 +77,11 @@ struct GamutMeshParserTests {
""" """
let mesh = try GamutMeshParser.parse(text: text) let mesh = try GamutMeshParser.parse(text: text)
#expect(mesh.vertices.count == 2) XCTAssertEqual(mesh.vertices.count, 2)
#expect(mesh.faces.count == 1) XCTAssertEqual(mesh.faces.count, 1)
} }
@Test("Remaps coordinates to x=a*, y=L*, z=b*") func testRemapsCoordinates() throws {
func remapsCoordinates() throws {
let text = """ let text = """
NUMBER_OF_FIELDS 4 NUMBER_OF_FIELDS 4
BEGIN_DATA_FORMAT BEGIN_DATA_FORMAT
@@ -99,11 +94,10 @@ struct GamutMeshParserTests {
""" """
let mesh = try GamutMeshParser.parse(text: text) 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 testComputesVertexColor() throws {
func computesVertexColor() throws {
let text = """ let text = """
NUMBER_OF_FIELDS 4 NUMBER_OF_FIELDS 4
BEGIN_DATA_FORMAT BEGIN_DATA_FORMAT
@@ -116,14 +110,13 @@ struct GamutMeshParserTests {
""" """
let mesh = try GamutMeshParser.parse(text: text) let mesh = try GamutMeshParser.parse(text: text)
let white = try #require(mesh.vertices.first).rgb let white = try XCTUnwrap(mesh.vertices.first).rgb
#expect(white.r > 0.95) XCTAssertTrue(white.r > 0.95)
#expect(white.g > 0.95) XCTAssertTrue(white.g > 0.95)
#expect(white.b > 0.95) XCTAssertTrue(white.b > 0.95)
} }
@Test("Drops out-of-bounds face indices") func testDropsOutOfBoundsFaces() throws {
func dropsOutOfBoundsFaces() throws {
let text = """ let text = """
NUMBER_OF_FIELDS 4 NUMBER_OF_FIELDS 4
BEGIN_DATA_FORMAT BEGIN_DATA_FORMAT
@@ -146,21 +139,23 @@ struct GamutMeshParserTests {
""" """
let mesh = try GamutMeshParser.parse(text: text) let mesh = try GamutMeshParser.parse(text: text)
#expect(mesh.faces.count == 1) XCTAssertEqual(mesh.faces.count, 1)
} }
@Test("Throws on empty file") func testThrowsOnEmptyFile() {
func throwsOnEmptyFile() { XCTAssertThrowsError(try GamutMeshParser.parse(text: "")) { error in
#expect(throws: GamutMeshParseError.noDataBlock) { guard case GamutMeshParseError.noDataBlock = error else {
_ = try GamutMeshParser.parse(text: "") return XCTFail("Expected GamutMeshParseError.noDataBlock, got \(error)")
}
} }
} }
@Test("Throws when file is missing") func testThrowsWhenMissing() {
func throwsWhenMissing() {
let url = URL(fileURLWithPath: "/nonexistent/path/to/mesh.gam") let url = URL(fileURLWithPath: "/nonexistent/path/to/mesh.gam")
#expect(throws: GamutMeshParseError.missingFile) { XCTAssertThrowsError(try GamutMeshParser.parse(url: url)) { error in
_ = try GamutMeshParser.parse(url: url) guard case GamutMeshParseError.missingFile = error else {
return XCTFail("Expected GamutMeshParseError.missingFile, got \(error)")
}
} }
} }
} }
@@ -1,16 +1,14 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@Suite("IccgamutArgs") final class IccgamutArgsTests: XCTestCase {
struct IccgamutArgsTests {
@Test("Density is 10 and not a directory") func testDensityNotDirectory() throws {
func densityNotDirectory() throws {
let config = IccgamutConfig( let config = IccgamutConfig(
profileURL: URL(fileURLWithPath: "/tmp/MyProfile.icc") profileURL: URL(fileURLWithPath: "/tmp/MyProfile.icc")
) )
let args = try IccgamutArgs.build(config: config) 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
}
}
}
+44 -57
View File
@@ -1,12 +1,11 @@
import Testing import XCTest
import Foundation import Foundation
@testable import ICCeryCore @testable import ICCeryCore
/// Issue 15 `lp` argv goldens (docs/11 `build_lp_args`). /// Issue 15 `lp` argv goldens (docs/11 `build_lp_args`).
/// `-d`/`options`/`-t` handling is in `CupsService`; these tests cover /// `-d`/`options`/`-t` handling is in `CupsService`; these tests cover
/// flag order, captured-option precedence, and sanitisation. /// flag order, captured-option precedence, and sanitisation.
@Suite("LpArgs") final class LpArgsTests: XCTestCase {
struct LpArgsTests {
private let tiff = "/tmp/work/target_001.tif" private let tiff = "/tmp/work/target_001.tif"
private let queue = "EPSON_XP_55_Series" private let queue = "EPSON_XP_55_Series"
@@ -20,112 +19,100 @@ struct LpArgsTests {
options: options, optionKeys: optionKeys) options: options, optionKeys: optionKeys)
} }
@Test("Header: -d queue -t title, both AP_* first, TIFF last") func testHeader() throws {
func header() throws {
let argv = try build() let argv = try build()
#expect(Array(argv[0...1]) == ["-d", queue]) XCTAssertEqual(Array(argv[0...1]), ["-d", queue])
#expect(Array(argv[2...3]) == ["-t", "ICCery Target - target_001.tif"]) XCTAssertEqual(Array(argv[2...3]), ["-t", "ICCery Target - target_001.tif"])
#expect(Array(argv[4...5]) XCTAssertEqual(Array(argv[4...5]), ["-o", "AP_ColorMatchingMode=AP_ApplicationColorMatching"])
== ["-o", "AP_ColorMatchingMode=AP_ApplicationColorMatching"]) XCTAssertEqual(Array(argv[6...7]), ["-o", "AP.ColorMatchingMode=AP_ApplicationColorMatching"])
#expect(Array(argv[6...7]) XCTAssertEqual(argv.last, tiff)
== ["-o", "AP.ColorMatchingMode=AP_ApplicationColorMatching"]) XCTAssertFalse(argv.contains { $0 == "raw" || $0 == "-o raw" })
#expect(argv.last == tiff)
#expect(!argv.contains { $0 == "raw" || $0 == "-o raw" })
} }
@Test("Never emits -o raw; captured raw= is dropped") func testNeverRaw() throws {
func neverRaw() throws {
let argv = try build(options: PrintOptions( let argv = try build(options: PrintOptions(
cupsOptions: "raw=true MediaType=Photo")) cupsOptions: "raw=true MediaType=Photo"))
for (i, arg) in argv.enumerated() where arg == "-o" { for (i, arg) in argv.enumerated() where arg == "-o" {
#expect(argv[i + 1] != "raw") XCTAssertNotEqual(argv[i + 1], "raw")
#expect(argv[i + 1] != "raw=true") XCTAssertNotEqual(argv[i + 1], "raw=true")
} }
#expect(!argv.contains { $0.hasPrefix("raw=") }) XCTAssertFalse(argv.contains { $0.hasPrefix("raw=") })
#expect(argv.contains("MediaType=Photo")) XCTAssertTrue(argv.contains("MediaType=Photo"))
} }
@Test("Captured options replayed after AP_* headers") func testCapturedReplay() throws {
func capturedReplay() throws {
let argv = try build(options: PrintOptions( let argv = try build(options: PrintOptions(
cupsOptions: "InputSlot=Rear MediaType=Photo")) cupsOptions: "InputSlot=Rear MediaType=Photo"))
let rear = argv.firstIndex(of: "InputSlot=Rear")! let rear = argv.firstIndex(of: "InputSlot=Rear")!
let apFirst = argv.firstIndex(of: let apFirst = argv.firstIndex(of:
"AP_ColorMatchingMode=AP_ApplicationColorMatching")! "AP_ColorMatchingMode=AP_ApplicationColorMatching")!
#expect(rear > apFirst) XCTAssertTrue(rear > apFirst)
} }
@Test("Captured wins: media key present → derived media skipped") func testCapturedWinsMedia() throws {
func capturedWinsMedia() throws {
let argv = try build( let argv = try build(
options: PrintOptions( options: PrintOptions(
mediaType: "Plain", mediaType: "Plain",
cupsOptions: "MediaType=Glossy"), cupsOptions: "MediaType=Glossy"),
optionKeys: ["MediaType"]) optionKeys: ["MediaType"])
#expect(argv.contains("MediaType=Glossy")) XCTAssertTrue(argv.contains("MediaType=Glossy"))
#expect(!argv.contains("MediaType=Plain")) XCTAssertFalse(argv.contains("MediaType=Plain"))
} }
@Test("Media emitted via detected key when not captured") func testMediaDerived() throws {
func mediaDerived() throws {
let argv = try build( let argv = try build(
options: PrintOptions(mediaType: "SemiGloss"), options: PrintOptions(mediaType: "SemiGloss"),
optionKeys: ["CNIJMediaType", "MediaType"]) optionKeys: ["CNIJMediaType", "MediaType"])
// CNIJMediaType wins over MediaType in detection order. // CNIJMediaType wins over MediaType in detection order.
#expect(argv.contains("CNIJMediaType=SemiGloss")) XCTAssertTrue(argv.contains("CNIJMediaType=SemiGloss"))
#expect(!argv.contains("MediaType=SemiGloss")) XCTAssertFalse(argv.contains("MediaType=SemiGloss"))
} }
@Test("Driver bypass emitted when absent, skipped when captured") func testBypassRules() throws {
func bypassRules() throws {
let withBypass = try build( let withBypass = try build(
optionKeys: ["EPIJ_CMat"]) optionKeys: ["EPIJ_CMat"])
#expect(withBypass.contains("EPIJ_CMat=3")) XCTAssertTrue(withBypass.contains("EPIJ_CMat=3"))
let captured = try build( let captured = try build(
options: PrintOptions(cupsOptions: "EPIJ_CMat=1"), options: PrintOptions(cupsOptions: "EPIJ_CMat=1"),
optionKeys: ["EPIJ_CMat"]) optionKeys: ["EPIJ_CMat"])
// Captured value kept, detection not re-applied. // Captured value kept, detection not re-applied.
#expect(captured.filter { $0.hasPrefix("EPIJ_CMat") } XCTAssertEqual(captured.filter { $0.hasPrefix("EPIJ_CMat") }, ["EPIJ_CMat=1"])
== ["EPIJ_CMat=1"])
} }
@Test("Orientation: portrait=3 landscape=4; captured wins") func testOrientation() throws {
func orientation() throws { XCTAssertTrue(try build(options: PrintOptions(orientation: "portrait"))
#expect(try build(options: PrintOptions(orientation: "portrait"))
.contains("orientation-requested=3")) .contains("orientation-requested=3"))
#expect(try build(options: PrintOptions(orientation: "landscape")) XCTAssertTrue(try build(options: PrintOptions(orientation: "landscape"))
.contains("orientation-requested=4")) .contains("orientation-requested=4"))
let capturedOrients = try build(options: PrintOptions( let capturedOrients = try build(options: PrintOptions(
orientation: "landscape", orientation: "landscape",
cupsOptions: "orientation-requested=5")) cupsOptions: "orientation-requested=5"))
#expect(!capturedOrients.contains("orientation-requested=4")) XCTAssertFalse(capturedOrients.contains("orientation-requested=4"))
#expect(capturedOrients.contains("orientation-requested=5")) XCTAssertTrue(capturedOrients.contains("orientation-requested=5"))
} }
@Test("PageSize emitted unless captured") func testPageSize() throws {
func pageSize() throws { XCTAssertTrue(try build(options: PrintOptions(paperSize: "A4"))
#expect(try build(options: PrintOptions(paperSize: "A4"))
.contains("PageSize=A4")) .contains("PageSize=A4"))
let capturedSize = try build(options: PrintOptions( let capturedSize = try build(options: PrintOptions(
paperSize: "A4", cupsOptions: "PageSize=Letter")) paperSize: "A4", cupsOptions: "PageSize=Letter"))
#expect(!capturedSize.contains("PageSize=A4")) XCTAssertFalse(capturedSize.contains("PageSize=A4"))
#expect(capturedSize.contains("PageSize=Letter")) XCTAssertTrue(capturedSize.contains("PageSize=Letter"))
} }
@Test("Sanitise rejects `;`, newline, and shell metachars") func testSanitise() throws {
func sanitise() throws { XCTAssertThrowsError(try build(options: PrintOptions(
#expect(throws: LpArgsError.self) { cupsOptions: "InputSlot=Rear;rm -rf /"))) { error in
_ = try build(options: PrintOptions( XCTAssertTrue(error is LpArgsError)
cupsOptions: "InputSlot=Rear;rm -rf /"))
} }
#expect(throws: LpArgsError.self) { XCTAssertThrowsError(try build(options: PrintOptions(
_ = try build(options: PrintOptions( cupsOptions: "InputSlot=Rear\nMediaType=Photo"))) { error in
cupsOptions: "InputSlot=Rear\nMediaType=Photo")) XCTAssertTrue(error is LpArgsError)
} }
#expect(throws: LpArgsError.self) { XCTAssertThrowsError(try build(options: PrintOptions(
_ = try build(options: PrintOptions( cupsOptions: "InputSlot=$(whoami)"))) { error in
cupsOptions: "InputSlot=$(whoami)")) XCTAssertTrue(error is LpArgsError)
} }
} }
} }
+123 -113
View File
@@ -1,12 +1,10 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@Suite("InstrumentParser") final class InstrumentParserTests: XCTestCase {
struct InstrumentParserTests {
@Test("Parses pretty-printed instlist JSON") func testJson() throws {
func json() throws {
let json = """ let json = """
{ {
"event": "instruments", "event": "instruments",
@@ -18,134 +16,118 @@ struct InstrumentParserTests {
} }
""" """
let devices = try InstrumentParser.parse(json) let devices = try InstrumentParser.parse(json)
#expect(devices.count == 3) XCTAssertEqual(devices.count, 3)
#expect(devices[0].port == 1) XCTAssertEqual(devices[0].port, 1)
#expect(devices[0].name == "X-Rite i1Pro") XCTAssertEqual(devices[0].name, "X-Rite i1Pro")
#expect(devices[2].port == 3) XCTAssertEqual(devices[2].port, 3)
} }
@Test("Falls back to regex for legacy instlist text") func testRegexFallback() throws {
func regexFallback() throws {
let text = """ let text = """
1: 'X-Rite i1Pro' on usb 1: 'X-Rite i1Pro' on usb
2: 'ColorMunki Smile' 2: 'ColorMunki Smile'
""" + "\n" """ + "\n"
let devices = try InstrumentParser.parse(text) let devices = try InstrumentParser.parse(text)
#expect(devices.count == 2) XCTAssertEqual(devices.count, 2)
#expect(devices[0].port == 1) XCTAssertEqual(devices[0].port, 1)
#expect(devices[1].name == "ColorMunki Smile") XCTAssertEqual(devices[1].name, "ColorMunki Smile")
} }
@Test("Empty output returns no devices") func testEmpty() throws {
func empty() throws { XCTAssertTrue(try InstrumentParser.parse("").isEmpty)
#expect(try InstrumentParser.parse("").isEmpty)
} }
} }
@Suite("ChartreadArgs") final class ChartreadArgsTests: XCTestCase {
struct ChartreadArgsTests {
@Test("Baseline argv and port 1 omits -c") func testBaseline() throws {
func baseline() throws {
let config = ChartreadConfig(basename: "target", selectedPort: 1) let config = ChartreadConfig(basename: "target", selectedPort: 1)
let args = try ChartreadArgs.build(config: config) let args = try ChartreadArgs.build(config: config)
#expect(args == ["-v", "-u", "target"]) XCTAssertEqual(args, ["-v", "-u", "target"])
} }
@Test("Port > 1 emits -c") func testPortArgument() throws {
func portArgument() throws {
let config = ChartreadConfig(basename: "target", selectedPort: 3) let config = ChartreadConfig(basename: "target", selectedPort: 3)
let args = try ChartreadArgs.build(config: config) 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 testLeds() throws {
func leds() throws {
let config = ChartreadConfig( let config = ChartreadConfig(
basename: "target", basename: "target",
selectedPort: 2, selectedPort: 2,
enableLEDs: true enableLEDs: true
) )
let args = try ChartreadArgs.build(config: config) let args = try ChartreadArgs.build(config: config)
#expect(args.contains("-Y")) XCTAssertTrue(args.contains("-Y"))
#expect(args.contains("l")) XCTAssertTrue(args.contains("l"))
} }
@Test("Auto omits -c") func testAutoPort() throws {
func autoPort() throws {
let config = ChartreadConfig(basename: "target") let config = ChartreadConfig(basename: "target")
let args = try ChartreadArgs.build(config: config) let args = try ChartreadArgs.build(config: config)
#expect(!args.contains("-c")) XCTAssertFalse(args.contains("-c"))
} }
} }
@Suite("ChartreadClassifier") final class ChartreadClassifierTests: XCTestCase {
struct ChartreadClassifierTests {
@Test("Calibration prompt") func testCalibration() {
func calibration() {
let r = ChartreadClassifier.classify( let r = ChartreadClassifier.classify(
line: "Place instrument on calibration tile and hit [Space] to calibrate.", line: "Place instrument on calibration tile and hit [Space] to calibrate.",
previousState: .idle previousState: .idle
) )
#expect(r.state == .calibrating) XCTAssertEqual(r.state, .calibrating)
} }
@Test("Strip awaiting") func testAwaitingStrip() {
func awaitingStrip() {
let r = ChartreadClassifier.classify( let r = ChartreadClassifier.classify(
line: "Hit [Space] to read strip A", line: "Hit [Space] to read strip A",
previousState: .calibrating previousState: .calibrating
) )
#expect(r.state == .awaitingStrip) XCTAssertEqual(r.state, .awaitingStrip)
} }
@Test("Done prompt") func testDone() {
func done() {
let r = ChartreadClassifier.classify( let r = ChartreadClassifier.classify(
line: "'d' if/when done", line: "'d' if/when done",
previousState: .awaitingStrip previousState: .awaitingStrip
) )
#expect(r.state == .allStripsRead) XCTAssertEqual(r.state, .allStripsRead)
} }
@Test("XY place sheet") func testPlaceSheet() {
func placeSheet() {
let r = ChartreadClassifier.classify( let r = ChartreadClassifier.classify(
line: "Please place sheet 1 of 2 on the table", line: "Please place sheet 1 of 2 on the table",
previousState: .idle previousState: .idle
) )
#expect(r.state == .tablePlaceSheet) XCTAssertEqual(r.state, .tablePlaceSheet)
#expect(r.sheetNumber == 1) XCTAssertEqual(r.sheetNumber, 1)
#expect(r.sheetTotal == 2) XCTAssertEqual(r.sheetTotal, 2)
} }
@Test("XY locate patch") func testLocatePatch() {
func locatePatch() {
let r = ChartreadClassifier.classify( let r = ChartreadClassifier.classify(
line: "locate patch A1 with the sight,", line: "locate patch A1 with the sight,",
previousState: .tablePlaceSheet previousState: .tablePlaceSheet
) )
#expect(r.state == .tableAlign) XCTAssertEqual(r.state, .tableAlign)
#expect(r.alignmentPatch == "A1") XCTAssertEqual(r.alignmentPatch, "A1")
} }
@Test("Remove sheet notice preserves state") func testRemoveNotice() {
func removeNotice() {
let r = ChartreadClassifier.classify( let r = ChartreadClassifier.classify(
line: "Please remove last sheet from table", line: "Please remove last sheet from table",
previousState: .tablePlaceSheet previousState: .tablePlaceSheet
) )
#expect(r.state == .tablePlaceSheet) XCTAssertEqual(r.state, .tablePlaceSheet)
#expect(r.isRemoveSheetNotice == true) XCTAssertEqual(r.isRemoveSheetNotice, true)
} }
} }
@Suite("ChartreadRow") final class ChartreadRowTests: XCTestCase {
struct ChartreadRowTests {
@Test("Decodes row JSON") func testDecode() throws {
func decode() throws {
let json = """ let json = """
{"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2, {"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2,
"patch_count": 1, "patches": [ "patch_count": 1, "patches": [
@@ -155,59 +137,93 @@ struct ChartreadRowTests {
]} ]}
""" """
let row = try JSONDecoder().decode(ChartreadRow.self, from: Data(json.utf8)) let row = try JSONDecoder().decode(ChartreadRow.self, from: Data(json.utf8))
#expect(row.rowId == "A") XCTAssertEqual(row.rowId, "A")
#expect(row.patchCount == 1) XCTAssertEqual(row.patchCount, 1)
#expect(row.patches[0].measured.lab?.l == 51) 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") final class ColourMathTests: XCTestCase {
struct ColourMathTests {
@Test("White XYZ to Lab") func testWhiteLab() {
func whiteLab() {
let white = XYZColor(x: 96.4212, y: 100.0, z: 82.5188) let white = XYZColor(x: 96.4212, y: 100.0, z: 82.5188)
let lab = LabColorMath.xyzToLab(white) let lab = LabColorMath.xyzToLab(white)
#expect(abs(lab.l - 100) < 0.5) XCTAssertTrue(abs(lab.l - 100) < 0.5)
#expect(abs(lab.a) < 0.5) XCTAssertTrue(abs(lab.a) < 0.5)
#expect(abs(lab.b) < 0.5) XCTAssertTrue(abs(lab.b) < 0.5)
} }
@Test("Lab to sRGB roundtrip is clamped") func testLabToSRGB() {
func labToSRGB() {
let red = LabColor(l: 55, a: 80, b: 70) let red = LabColor(l: 55, a: 80, b: 70)
let rgb = LabColorMath.labToSRGB(red) let rgb = LabColorMath.labToSRGB(red)
#expect(rgb.r > 0.8) XCTAssertTrue(rgb.r > 0.8)
#expect(rgb.g < 0.2) XCTAssertTrue(rgb.g < 0.2)
#expect(rgb.b < 0.2) XCTAssertTrue(rgb.b < 0.2)
} }
@Test("Pad white returns DisplayRGB") func testPadWhite() {
func padWhite() {
let white = LabColor(l: 95, a: 0, b: 0) let white = LabColor(l: 95, a: 0, b: 0)
let rgb = LabColorMath.labToSRGB(white) let rgb = LabColorMath.labToSRGB(white)
#expect(rgb.r > 0.9) XCTAssertTrue(rgb.r > 0.9)
#expect(rgb.g > 0.9) XCTAssertTrue(rgb.g > 0.9)
#expect(rgb.b > 0.9) XCTAssertTrue(rgb.b > 0.9)
} }
@Test("Standard CIEDE2000 vector (Sharma)") func testCiede2000() {
func ciede2000() {
let a = LabColor(l: 50, a: -1.3802, b: -84.2814) let a = LabColor(l: 50, a: -1.3802, b: -84.2814)
let b = LabColor(l: 50, a: 0.0000, b: -82.7485) 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 testClassify() {
func classify() { XCTAssertEqual(ColorDifference.classify(deltaE: 0.5, goodMax: 2.0, warningMax: 5.0), .good)
#expect(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)
#expect(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)
#expect(ColorDifference.classify(deltaE: 6.0, goodMax: 2.0, warningMax: 5.0) == .bad)
} }
} }
@Suite("MeasurementArtefacts") final class MeasurementArtefactTests: XCTestCase {
struct MeasurementArtefactTests {
private func makeCwd() throws -> URL { private func makeCwd() throws -> URL {
let url = FileManager.default.temporaryDirectory let url = FileManager.default.temporaryDirectory
@@ -216,8 +232,7 @@ struct MeasurementArtefactTests {
return url return url
} }
@Test("Discovers passes in order") func testDiscovery() throws {
func discovery() throws {
let cwd = try makeCwd() let cwd = try makeCwd()
defer { try? FileManager.default.removeItem(at: cwd) } 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) try "C".write(to: cwd.appendingPathComponent("target_pass10.ti3"), atomically: true, encoding: .utf8)
let passes = MeasurementArtefacts.passSnapshots(basename: "target", cwd: cwd) 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 testSnapshotPromote() throws {
func snapshotPromote() throws {
let cwd = try makeCwd() let cwd = try makeCwd()
defer { try? FileManager.default.removeItem(at: cwd) } defer { try? FileManager.default.removeItem(at: cwd) }
@@ -238,16 +252,15 @@ struct MeasurementArtefactTests {
try "canonical".write(to: canonical, atomically: true, encoding: .utf8) try "canonical".write(to: canonical, atomically: true, encoding: .utf8)
let pass = try MeasurementArtefacts.snapshotPass(basename: "target", cwd: cwd) let pass = try MeasurementArtefacts.snapshotPass(basename: "target", cwd: cwd)
#expect(pass.lastPathComponent == "target_pass1.ti3") XCTAssertEqual(pass.lastPathComponent, "target_pass1.ti3")
#expect(!FileManager.default.fileExists(atPath: canonical.path)) XCTAssertFalse(FileManager.default.fileExists(atPath: canonical.path))
let promoted = try MeasurementArtefacts.promotePass(pass: pass, basename: "target", cwd: cwd) let promoted = try MeasurementArtefacts.promotePass(pass: pass, basename: "target", cwd: cwd)
#expect(promoted.lastPathComponent == "target.ti3") XCTAssertEqual(promoted.lastPathComponent, "target.ti3")
#expect(FileManager.default.fileExists(atPath: promoted.path)) XCTAssertTrue(FileManager.default.fileExists(atPath: promoted.path))
} }
@Test("Pass collisions handled") func testCollision() throws {
func collision() throws {
let cwd = try makeCwd() let cwd = try makeCwd()
defer { try? FileManager.default.removeItem(at: cwd) } defer { try? FileManager.default.removeItem(at: cwd) }
@@ -257,28 +270,25 @@ struct MeasurementArtefactTests {
try "v2".write(to: canonical, atomically: true, encoding: .utf8) try "v2".write(to: canonical, atomically: true, encoding: .utf8)
let pass2 = try MeasurementArtefacts.snapshotPass(basename: "target", cwd: cwd) let pass2 = try MeasurementArtefacts.snapshotPass(basename: "target", cwd: cwd)
#expect(pass2.lastPathComponent == "target_pass2.ti3") XCTAssertEqual(pass2.lastPathComponent, "target_pass2.ti3")
} }
} }
@Suite("AverageArgs") final class AverageArgsTests: XCTestCase {
struct AverageArgsTests {
@Test("Requires at least two pass files") func testPassCount() {
func passCount() {
let cwd = URL(fileURLWithPath: "/tmp") let cwd = URL(fileURLWithPath: "/tmp")
let config = AverageConfig( let config = AverageConfig(
workingDirectory: cwd, workingDirectory: cwd,
basename: "target", basename: "target",
passFiles: [URL(fileURLWithPath: "target_pass1.ti3")] passFiles: [URL(fileURLWithPath: "target_pass1.ti3")]
) )
#expect(throws: AverageArgError.self) { XCTAssertThrowsError(try AverageArgs.build(config: config)) { error in
_ = try AverageArgs.build(config: config) XCTAssertTrue(error is AverageArgError)
} }
} }
@Test("Output is last and inputs are relative") func testOrdering() throws {
func ordering() throws {
let cwd = URL(fileURLWithPath: "/tmp") let cwd = URL(fileURLWithPath: "/tmp")
let config = AverageConfig( let config = AverageConfig(
workingDirectory: cwd, workingDirectory: cwd,
@@ -289,8 +299,8 @@ struct AverageArgsTests {
] ]
) )
let args = try AverageArgs.build(config: config) let args = try AverageArgs.build(config: config)
#expect(args.first == "-v") XCTAssertEqual(args.first, "-v")
#expect(args.last == "target.ti3") XCTAssertEqual(args.last, "target.ti3")
#expect(args == ["-v", "target_pass1.ti3", "target_pass2.ti3", "target.ti3"]) XCTAssertEqual(args, ["-v", "target_pass1.ti3", "target_pass2.ti3", "target.ti3"])
} }
} }
+334 -93
View File
@@ -1,116 +1,98 @@
import Testing
import Foundation import Foundation
import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@Suite("ProfilingPreset") final class ProfilingPresetTests: XCTestCase {
struct ProfilingPresetTests {
@Test("snake_case keys round-trip through Codable") func testRoundTrip() throws {
func roundTrip() throws {
var p = PresetCatalog.highQualityCMYK var p = PresetCatalog.highQualityCMYK
p.colprofInputViewingCond = "D50_2" p.colprofInputViewingCond = "D50_2"
let data = try JSONEncoder().encode(p) let data = try JSONEncoder().encode(p)
let decoded = try JSONDecoder().decode(ProfilingPreset.self, from: data) let decoded = try JSONDecoder().decode(ProfilingPreset.self, from: data)
#expect(decoded == p) XCTAssertEqual(decoded, p)
// Spot-check the wire format. // Spot-check the wire format.
let obj = try JSONSerialization.jsonObject(with: data) as! [String: Any] let obj = try JSONSerialization.jsonObject(with: data) as! [String: Any]
#expect(obj["colour_space"] as? String == "cmyk") XCTAssertEqual(obj["colour_space"] as? String, "cmyk")
#expect(obj["patch_count"] as? Int == 1500) XCTAssertEqual(obj["patch_count"] as? Int, 1500)
#expect(obj["total_ink_limit"] as? Int == 320) XCTAssertEqual(obj["total_ink_limit"] as? Int, 320)
#expect(obj["bit_depth"] as? Int == 16) XCTAssertEqual(obj["bit_depth"] as? Int, 16)
#expect(obj["colprof_input_viewing_cond"] as? String == "D50_2") XCTAssertEqual(obj["colprof_input_viewing_cond"] as? String, "D50_2")
} }
@Test("Unknown keys ignored; missing required field fails") func testSchemaTolerance() throws {
func schemaTolerance() throws {
let json = """ let json = """
{"id":"x","name":"N","colour_space":"rgb","patch_count":10, {"id":"x","name":"N","colour_space":"rgb","patch_count":10,
"white_patches":1,"black_patches":1,"instrument":"i1", "white_patches":1,"black_patches":1,"instrument":"i1",
"page_size":"A4","bit_depth":8,"dpi":300,"future_key":42} "page_size":"A4","bit_depth":8,"dpi":300,"future_key":42}
""".data(using: .utf8)! """.data(using: .utf8)!
let ok = try JSONDecoder().decode(ProfilingPreset.self, from: json) let ok = try JSONDecoder().decode(ProfilingPreset.self, from: json)
#expect(ok.id == "x") XCTAssertEqual(ok.id, "x")
let missing = """ let missing = """
{"id":"x","name":"N","colour_space":"rgb"} {"id":"x","name":"N","colour_space":"rgb"}
""".data(using: .utf8)! """.data(using: .utf8)!
#expect(throws: DecodingError.self) { XCTAssertThrowsError(try JSONDecoder().decode(ProfilingPreset.self, from: missing)) { error in XCTAssertTrue(error is DecodingError) }
try JSONDecoder().decode(ProfilingPreset.self, from: missing)
}
} }
@Test("Validation rejects bad colour space / dpi / bit depth") func testValidation() {
func validation() { XCTAssertThrowsError(try ProfilingPreset(id: "a", name: "n", colourSpace: "lab").validated()) { error in XCTAssertTrue(error is ProfilingPreset.ValidationError) }
#expect(throws: ProfilingPreset.ValidationError.self) { XCTAssertThrowsError(try ProfilingPreset(id: "a", name: "n", dpi: 10).validated()) { error in XCTAssertTrue(error is ProfilingPreset.ValidationError) }
try ProfilingPreset(id: "a", name: "n", colourSpace: "lab").validated() 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) }
#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()
}
} }
} }
@Suite("PresetCatalog") final class PresetCatalogTests: XCTestCase {
struct PresetCatalogTests {
@Test("Four built-ins with the documented values") func testBuiltIns() {
func builtIns() { XCTAssertEqual(PresetCatalog.builtIns.count, 4)
#expect(PresetCatalog.builtIns.count == 4)
let byID = Dictionary(uniqueKeysWithValues: PresetCatalog.builtIns.map { ($0.id, $0) }) let byID = Dictionary(uniqueKeysWithValues: PresetCatalog.builtIns.map { ($0.id, $0) })
let std = byID["preset-std-rgb"]! 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.pageSize == "A4" && std.bitDepth == 8
&& std.dpi == 300 && std.colprofQuality == "m" && std.dpi == 300 && std.colprofQuality == "m"
&& std.whitePatches == 4 && std.blackPatches == 4) && std.whitePatches == 4 && std.blackPatches == 4)
let hq = byID["preset-hq-cmyk"]! 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.pageSize == "A3" && hq.bitDepth == 16
&& hq.dpi == 300 && hq.colprofQuality == "h" && hq.dpi == 300 && hq.colprofQuality == "h"
&& hq.totalInkLimit == 320 && hq.blackPatches == 8) && hq.totalInkLimit == 320 && hq.blackPatches == 8)
let draft = byID["preset-draft-rgb"]! 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.pageSize == "A4" && draft.bitDepth == 8
&& draft.dpi == 150 && draft.colprofQuality == "l") && draft.dpi == 150 && draft.colprofQuality == "l")
let ultra = byID["preset-ultra-rgb"]! 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.pageSize == "A3" && ultra.bitDepth == 16
&& ultra.dpi == 300 && ultra.colprofQuality == "u" && ultra.dpi == 300 && ultra.colprofQuality == "u"
&& ultra.ofpsHighQuality == true && ultra.ofpsHighQuality == true
&& ultra.whitePatches == 6 && ultra.blackPatches == 6) && ultra.whitePatches == 6 && ultra.blackPatches == 6)
for p in PresetCatalog.builtIns { for p in PresetCatalog.builtIns {
#expect(p.instrument == "i1") XCTAssertEqual(p.instrument, "i1")
#expect(p.colprofFwa == "D50") XCTAssertEqual(p.colprofFwa, "D50")
#expect(p.randomSeed == 1) XCTAssertEqual(p.randomSeed, 1)
#expect(p.noRandomize == false) XCTAssertEqual(p.noRandomize, false)
#expect(p.colprofAlgorithm == "l") XCTAssertEqual(p.colprofAlgorithm, "l")
} }
} }
@Test("Custom presets overlay by id; built-ins are not deletable") func testOverlay() {
func overlay() {
let custom = ProfilingPreset( let custom = ProfilingPreset(
id: "preset-std-rgb", name: "Shadowed", patchCount: 42) id: "preset-std-rgb", name: "Shadowed", patchCount: 42)
let all = PresetCatalog.all(custom: [custom]) let all = PresetCatalog.all(custom: [custom])
#expect(all.count == 4) XCTAssertEqual(all.count, 4)
#expect(all.first { $0.id == "preset-std-rgb" }?.patchCount == 42) XCTAssertEqual(all.first { $0.id == "preset-std-rgb" }?.patchCount, 42)
#expect(PresetCatalog.isBuiltIn("preset-std-rgb")) XCTAssertTrue(PresetCatalog.isBuiltIn("preset-std-rgb"))
#expect(!PresetCatalog.isBuiltIn("custom-1")) XCTAssertFalse(PresetCatalog.isBuiltIn("custom-1"))
} }
} }
@Suite("PresetStore") final class PresetStoreTests: XCTestCase {
struct PresetStoreTests {
private func tempSettingsURL() throws -> URL { private func tempSettingsURL() throws -> URL {
let dir = FileManager.default.temporaryDirectory let dir = FileManager.default.temporaryDirectory
@@ -119,58 +101,52 @@ struct PresetStoreTests {
return dir.appendingPathComponent("settings.json") return dir.appendingPathComponent("settings.json")
} }
@Test("CRUD + export/import round-trip") func testCrud() throws {
func crud() throws {
let url = try tempSettingsURL() let url = try tempSettingsURL()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let store = PresetStore(settingsStore: SettingsStore(fileURL: url)) let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
var p = ProfilingPreset(id: "custom-x", name: "Mine", patchCount: 999, dpi: 150) var p = ProfilingPreset(id: "custom-x", name: "Mine", patchCount: 999, dpi: 150)
try store.saveCustom(p) try store.saveCustom(p)
#expect(store.customs().count == 1) XCTAssertEqual(store.customs().count, 1)
#expect(store.all().count == 5) XCTAssertEqual(store.all().count, 5)
p.name = "Renamed" p.name = "Renamed"
try store.saveCustom(p) try store.saveCustom(p)
#expect(store.customs().count == 1) XCTAssertEqual(store.customs().count, 1)
#expect(store.customs()[0].name == "Renamed") XCTAssertEqual(store.customs()[0].name, "Renamed")
let data = try store.export(p) let data = try store.export(p)
let imported = try store.import(data) let imported = try store.import(data)
#expect(imported.name == "Renamed") XCTAssertEqual(imported.name, "Renamed")
#expect(imported.dpi == 150) XCTAssertEqual(imported.dpi, 150)
#expect(try store.deleteCustom(id: "custom-x")) XCTAssertTrue(try store.deleteCustom(id: "custom-x"))
#expect(store.customs().isEmpty) XCTAssertTrue(store.customs().isEmpty)
#expect(try !store.deleteCustom(id: "preset-std-rgb")) XCTAssertFalse(try store.deleteCustom(id: "preset-std-rgb"))
} }
@Test("Import rewrites a built-in id to a fresh custom id") func testImportBuiltinCollision() throws {
func importBuiltinCollision() throws {
let url = try tempSettingsURL() let url = try tempSettingsURL()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let store = PresetStore(settingsStore: SettingsStore(fileURL: url)) let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
let data = try store.export(PresetCatalog.standardRGB) let data = try store.export(PresetCatalog.standardRGB)
let imported = try store.import(data) let imported = try store.import(data)
#expect(imported.id.hasPrefix("custom-")) XCTAssertTrue(imported.id.hasPrefix("custom-"))
#expect(!PresetCatalog.isBuiltIn(imported.id)) XCTAssertFalse(PresetCatalog.isBuiltIn(imported.id))
} }
@Test("Built-ins are immutable through saveCustom") func testBuiltInImmutable() throws {
func builtInImmutable() throws {
let url = try tempSettingsURL() let url = try tempSettingsURL()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let store = PresetStore(settingsStore: SettingsStore(fileURL: url)) let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
var shadowed = PresetCatalog.standardRGB var shadowed = PresetCatalog.standardRGB
shadowed.name = "Hacked" shadowed.name = "Hacked"
#expect(throws: PresetStore.PresetStoreError.self) { XCTAssertThrowsError(try store.saveCustom(shadowed)) { error in XCTAssertTrue(error is PresetStore.PresetStoreError) }
try store.saveCustom(shadowed)
}
} }
} }
@Suite("AppSettings preset migration") final class PresetMigrationTests: XCTestCase {
struct PresetMigrationTests {
private func tempSettingsURL() throws -> URL { private func tempSettingsURL() throws -> URL {
let dir = FileManager.default.temporaryDirectory let dir = FileManager.default.temporaryDirectory
@@ -179,8 +155,7 @@ struct PresetMigrationTests {
return dir.appendingPathComponent("settings.json") return dir.appendingPathComponent("settings.json")
} }
@Test("Legacy M1 custom_presets migrate to typed schema") func testLegacyMigration() throws {
func legacyMigration() throws {
let url = try tempSettingsURL() let url = try tempSettingsURL()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let legacy = """ let legacy = """
@@ -194,20 +169,19 @@ struct PresetMigrationTests {
try legacy.write(to: url) try legacy.write(to: url)
let settings = SettingsStore(fileURL: url).load() let settings = SettingsStore(fileURL: url).load()
#expect(settings.customPresets.count == 1) XCTAssertEqual(settings.customPresets.count, 1)
let p = settings.customPresets[0] let p = settings.customPresets[0]
#expect(p.name == "Old One") XCTAssertEqual(p.name, "Old One")
#expect(p.id.hasPrefix("custom-0-")) XCTAssertTrue(p.id.hasPrefix("custom-0-"))
#expect(p.colourSpace == "cmyk") XCTAssertEqual(p.colourSpace, "cmyk")
#expect(p.patchCount == 900) XCTAssertEqual(p.patchCount, 900)
#expect(p.dpi == 150) XCTAssertEqual(p.dpi, 150)
#expect(p.bitDepth == 16) XCTAssertEqual(p.bitDepth, 16)
#expect(p.instrument == "p3") XCTAssertEqual(p.instrument, "p3")
#expect(p.pageSize == "A3") XCTAssertEqual(p.pageSize, "A3")
} }
@Test("Typed presets load and re-save as the typed schema") func testTypedRoundTrip() throws {
func typedRoundTrip() throws {
let url = try tempSettingsURL() let url = try tempSettingsURL()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let store = SettingsStore(fileURL: url) let store = SettingsStore(fileURL: url)
@@ -215,18 +189,285 @@ struct PresetMigrationTests {
s.customPresets = [ProfilingPreset(id: "c1", name: "C1", patchCount: 700)] s.customPresets = [ProfilingPreset(id: "c1", name: "C1", patchCount: 700)]
try store.save(s) try store.save(s)
let loaded = store.load() 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 testDraftDPI() throws {
func draftDPI() throws {
let url = try tempSettingsURL() let url = try tempSettingsURL()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let store = PresetStore(settingsStore: SettingsStore(fileURL: url)) let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
let data = try store.export(PresetCatalog.draftRGB) let data = try store.export(PresetCatalog.draftRGB)
let obj = try JSONSerialization.jsonObject(with: data) as! [String: Any] 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) 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)
}
}
+13 -17
View File
@@ -1,13 +1,12 @@
import Testing
import Foundation import Foundation
import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@testable import ICCery @testable import ICCery
/// Issue 13 panel outcome mapping (cancel nil, ok result). /// Issue 13 panel outcome mapping (cancel nil, ok result).
/// The real `NSPrintPanel` is never run in tests; these exercise the /// The real `NSPrintPanel` is never run in tests; these exercise the
/// `UITestHooks` seam the UI tests rely on. /// `UITestHooks` seam the UI tests rely on.
@Suite("PrintPanelStub") final class PrintPanelStubTests: XCTestCase {
struct PrintPanelStubTests {
private func withEnv( private func withEnv(
_ vars: [String: String?], _ vars: [String: String?],
@@ -28,19 +27,17 @@ struct PrintPanelStubTests {
try body() try body()
} }
@Test("Cancel returns nil — not an error") func testCancelIsNil() throws {
func cancelIsNil() throws {
try withEnv([ try withEnv([
"ICCERY_UI_TESTING": "1", "ICCERY_UI_TESTING": "1",
"ICCERY_TEST_PRINT_PANEL": "cancel", "ICCERY_TEST_PRINT_PANEL": "cancel",
]) { ]) {
#expect(UITestHooks.printPanelStubbed) XCTAssertTrue(UITestHooks.printPanelStubbed)
#expect(UITestHooks.printPanelResult(forQueue: "q") == nil) XCTAssertNil(UITestHooks.printPanelResult(forQueue: "q"))
} }
} }
@Test("OK returns captured options + selected printer") func testOkResult() throws {
func okResult() throws {
try withEnv([ try withEnv([
"ICCERY_UI_TESTING": "1", "ICCERY_UI_TESTING": "1",
"ICCERY_TEST_PRINT_PANEL": "ok", "ICCERY_TEST_PRINT_PANEL": "ok",
@@ -48,15 +45,14 @@ struct PrintPanelStubTests {
"ICCERY_TEST_PANEL_PRINTER": "Other_Queue", "ICCERY_TEST_PANEL_PRINTER": "Other_Queue",
]) { ]) {
let result = UITestHooks.printPanelResult(forQueue: "q") let result = UITestHooks.printPanelResult(forQueue: "q")
#expect(result?.selectedPrinter == "Other_Queue") XCTAssertEqual(result?.selectedPrinter, "Other_Queue")
#expect(result?.options.cupsOptions == "MediaType=Photo InputSlot=Rear") XCTAssertEqual(result?.options.cupsOptions, "MediaType=Photo InputSlot=Rear")
#expect(result?.options.mediaType == "Photo") XCTAssertEqual(result?.options.mediaType, "Photo")
#expect(result?.options.ppdUncorrectedPassthrough == true) XCTAssertEqual(result?.options.ppdUncorrectedPassthrough, true)
} }
} }
@Test("OK defaults selected printer to the opened queue") func testOkDefaultsPrinter() throws {
func okDefaultsPrinter() throws {
try withEnv([ try withEnv([
"ICCERY_UI_TESTING": "1", "ICCERY_UI_TESTING": "1",
"ICCERY_TEST_PRINT_PANEL": "ok", "ICCERY_TEST_PRINT_PANEL": "ok",
@@ -64,8 +60,8 @@ struct PrintPanelStubTests {
"ICCERY_TEST_PANEL_PRINTER": nil, "ICCERY_TEST_PANEL_PRINTER": nil,
]) { ]) {
let result = UITestHooks.printPanelResult(forQueue: "My_Queue") let result = UITestHooks.printPanelResult(forQueue: "My_Queue")
#expect(result?.selectedPrinter == "My_Queue") XCTAssertEqual(result?.selectedPrinter, "My_Queue")
#expect(result?.options.cupsOptions == nil) XCTAssertNil(result?.options.cupsOptions)
} }
} }
} }
+29 -14
View File
@@ -1,24 +1,21 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@Suite("PrintcalArgs") final class PrintcalArgsTests: XCTestCase {
struct PrintcalArgsTests {
private let tmp = URL(fileURLWithPath: "/tmp/out.cal") private let tmp = URL(fileURLWithPath: "/tmp/out.cal")
@Test("Default printcal argv") func testDefaults() throws {
func defaults() throws {
let config = PrintcalConfig( let config = PrintcalConfig(
ti3Basename: "CAL_demo", ti3Basename: "CAL_demo",
outputURL: tmp outputURL: tmp
) )
let args = try PrintcalArgs.build(config: config) 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 testAllOptions() throws {
func allOptions() throws {
let config = PrintcalConfig( let config = PrintcalConfig(
ti3Basename: "demo", ti3Basename: "demo",
outputURL: tmp, outputURL: tmp,
@@ -32,7 +29,7 @@ struct PrintcalArgsTests {
] ]
) )
let args = try PrintcalArgs.build(config: config) let args = try PrintcalArgs.build(config: config)
#expect(args == [ XCTAssertEqual(args, [
"-v", "-e", "-v", "-e",
"-I", "-z", "-I", "-z",
"-a", "/tmp/old.cal", "-a", "/tmp/old.cal",
@@ -44,16 +41,34 @@ struct PrintcalArgsTests {
]) ])
} }
@Test("Rejects invalid per-channel limit") func testWhitespacePreviousCal() throws {
func rejectsBadChannelLimit() { 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( let config = PrintcalConfig(
ti3Basename: "demo", ti3Basename: "demo",
outputURL: tmp, outputURL: tmp,
channelLimits: [PrintcalChannelLimit(channel: "K", percent: 150)] channelLimits: [PrintcalChannelLimit(channel: "K", percent: 150)]
) )
#expect(throws: (any Error).self) { XCTAssertThrowsError(try PrintcalArgs.build(config: config))
_ = try PrintcalArgs.build(config: config)
}
} }
} }
+107 -120
View File
@@ -1,9 +1,8 @@
import Testing import XCTest
import Foundation import Foundation
@testable import ICCeryCore @testable import ICCeryCore
@Suite("PrinttargArgs") final class PrinttargArgsTests: XCTestCase {
struct PrinttargArgsTests {
private func config( private func config(
instrument: PrintInstrument = .i1, instrument: PrintInstrument = .i1,
@@ -28,119 +27,121 @@ struct PrinttargArgsTests {
) )
} }
@Test("Baseline: -v -u -i i1 -p A4 -R 1 -t 300") func testBaseline() throws {
func baseline() throws {
let args = try PrinttargArgs.build(config: config()) 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"]) "-R", "1", "-t", "300", "target"])
} }
@Test("Default layout is deterministic -R 1, never bare") func testDeterministicDefault() throws {
func deterministicDefault() throws {
let args = try PrinttargArgs.build(config: config()) let args = try PrinttargArgs.build(config: config())
#expect(args.contains("-R")) XCTAssertTrue(args.contains("-R"))
#expect(!args.contains("-r")) XCTAssertFalse(args.contains("-r"))
#expect(args[args.firstIndex(of: "-R")! + 1] == "1") XCTAssertEqual(args[args.firstIndex(of: "-R")! + 1], "1")
} }
@Test("Custom seed -R N; seed < 1 throws") func testCustomSeed() throws {
func customSeed() throws {
let args = try PrinttargArgs.build(config: config(layout: .customSeed, seed: 42)) let args = try PrinttargArgs.build(config: config(layout: .customSeed, seed: 42))
#expect(args[args.firstIndex(of: "-R")! + 1] == "42") XCTAssertEqual(args[args.firstIndex(of: "-R")! + 1], "42")
#expect(throws: PrinttargArgError.self) { XCTAssertThrowsError(try PrinttargArgs.build(config: config(layout: .customSeed, seed: 0))) { error in
try PrinttargArgs.build(config: config(layout: .customSeed, seed: 0)) XCTAssertTrue(error is PrinttargArgError)
} }
} }
@Test("Raster emits -r and supersedes seed (printtarg -r, not targen -r)") func testRaster() throws {
func raster() throws {
let args = try PrinttargArgs.build(config: config(layout: .raster, seed: 9)) let args = try PrinttargArgs.build(config: config(layout: .raster, seed: 9))
#expect(args.contains("-r")) XCTAssertTrue(args.contains("-r"))
#expect(!args.contains("-R")) XCTAssertFalse(args.contains("-R"))
} }
@Test("Label: -d emits the resolved string, not a colour space") func testLabel() throws {
func label() throws {
let args = try PrinttargArgs.build( let args = try PrinttargArgs.build(
config: config(label: "ICCery - t - P - I - D - A - 01/02/2026 03:04")) config: config(label: "ICCery - t - P - I - D - A - 01/02/2026 03:04"))
let i = args.firstIndex(of: "-d")! 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 testBitDepthAndDPI() throws {
func bitDepthAndDPI() throws { XCTAssertTrue(try PrinttargArgs.build(config: config(bitDepth: .sixteen, dpi: 600))
#expect(try PrinttargArgs.build(config: config(bitDepth: .sixteen, dpi: 600))
.contains("-T")) .contains("-T"))
#expect(try PrinttargArgs.build(config: config(bitDepth: .eight, dpi: 72)) XCTAssertTrue(try PrinttargArgs.build(config: config(bitDepth: .eight, dpi: 72))
.contains("-t")) .contains("-t"))
#expect(throws: PrinttargArgError.self) { XCTAssertThrowsError(try PrinttargArgs.build(config: config(dpi: 71))) { error in
try PrinttargArgs.build(config: config(dpi: 71)) XCTAssertTrue(error is PrinttargArgError)
} }
#expect(throws: PrinttargArgError.self) { XCTAssertThrowsError(try PrinttargArgs.build(config: config(dpi: 601))) { error in
try PrinttargArgs.build(config: config(dpi: 601)) XCTAssertTrue(error is PrinttargArgError)
} }
} }
@Test("All instruments emit their Argyll code") func testInstruments() throws {
func instruments() throws {
let expected: [(PrintInstrument, String)] = [ let expected: [(PrintInstrument, String)] = [
(.i1, "i1"), (.p3, "p3"), (.cm, "CM"), (.ss, "SS"), (.i1, "i1"), (.p3, "p3"), (.cm, "CM"), (.ss, "SS"),
(.dtp20, "20"), (.dtp22, "22"), (.dtp41, "41"), (.dtp51, "51"), (.dtp20, "20"), (.dtp22, "22"), (.dtp41, "41"), (.dtp51, "51"),
] ]
for (inst, code) in expected { for (inst, code) in expected {
let args = try PrinttargArgs.build(config: config(instrument: inst)) 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 testPageSizes() throws {
func pageSizes() throws {
for size in PageSize.allCases where size != .custom { for size in PageSize.allCases where size != .custom {
let args = try PrinttargArgs.build(config: config(pageSize: size)) 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( let custom = try PrinttargArgs.build(config: config(
pageSize: .custom, customW: 150, customH: 220)) 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 testCustomPageTooSmall() {
func customPageTooSmall() { XCTAssertThrowsError(try PrinttargArgs.build(config: config(pageSize: .custom, customW: 49.9))) { error in
#expect(throws: PrinttargArgError.self) { XCTAssertTrue(error is PrinttargArgError)
try PrinttargArgs.build(config: config(pageSize: .custom, customW: 49.9))
} }
#expect(throws: PrinttargArgError.self) { XCTAssertThrowsError(try PrinttargArgs.build(config: config(pageSize: .custom, customH: 10))) { error in
try PrinttargArgs.build(config: config(pageSize: .custom, customH: 10)) XCTAssertTrue(error is PrinttargArgError)
} }
} }
@Test("Calibration: -K applies, -I embeds") func testCalibrationFlags() throws {
func calibrationFlags() throws {
let k = try PrinttargArgs.build(config: config(calFile: "/tmp/a.cal")) 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)) let i = try PrinttargArgs.build(config: config(calFile: "/tmp/a.cal", calEmbed: true))
#expect(i[i.firstIndex(of: "-I")! + 1] == "/tmp/a.cal") XCTAssertEqual(i[i.firstIndex(of: "-I")! + 1], "/tmp/a.cal")
#expect(!i.contains("-K")) XCTAssertFalse(i.contains("-K"))
} }
@Test("CAL_ basename never gets -K or -I") func testCalProtection() throws {
func calProtection() throws {
let args = try PrinttargArgs.build( let args = try PrinttargArgs.build(
config: config(calFile: "/tmp/a.cal", basename: "CAL_test")) config: config(calFile: "/tmp/a.cal", basename: "CAL_test"))
#expect(!args.contains("-K")) XCTAssertFalse(args.contains("-K"))
#expect(!args.contains("-I")) XCTAssertFalse(args.contains("-I"))
} }
@Test("Unsafe basename throws") func testWhitespaceOptions() throws {
func unsafeBasename() { let args = try PrinttargArgs.build(
#expect(throws: PathSecurity.Error.self) { config: config(label: " \n ", calFile: " \t "))
try PrinttargArgs.build(config: config(basename: "../x")) 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") final class PrinttargLabelTests: XCTestCase {
struct PrinttargLabelTests {
private var fixedDate: Date { private var fixedDate: Date {
var comps = DateComponents() var comps = DateComponents()
@@ -149,37 +150,33 @@ struct PrinttargLabelTests {
return Calendar(identifier: .gregorian).date(from: comps)! return Calendar(identifier: .gregorian).date(from: comps)!
} }
@Test("Automatic label: ICCery - basename - P - I - DP - AP - DD/MM/YYYY HH:MM") func testAutomatic() {
func automatic() {
let label = PrinttargLabel.automatic( let label = PrinttargLabel.automatic(
basename: "tgt", basename: "tgt",
metadata: TargetLabelMetadata( metadata: TargetLabelMetadata(
printer: "Epson", inkSet: "CMYK", printer: "Epson", inkSet: "CMYK",
driverPaper: "Photo", actualPaper: "Matte"), driverPaper: "Photo", actualPaper: "Matte"),
date: fixedDate, timeZone: .current) date: fixedDate, timeZone: .current)
#expect(label.hasPrefix("ICCery - tgt - Epson - CMYK - Photo - Matte - ")) XCTAssertTrue(label.hasPrefix("ICCery - tgt - Epson - CMYK - Photo - Matte - "))
#expect(label.hasSuffix("03/02/2026") || label.contains("/02/2026")) XCTAssertTrue(label.hasSuffix("03/02/2026") || label.contains("/02/2026"))
} }
@Test("Missing metadata becomes Unspecified") func testUnspecified() {
func unspecified() {
let label = PrinttargLabel.automatic( let label = PrinttargLabel.automatic(
basename: "tgt", metadata: TargetLabelMetadata(), basename: "tgt", metadata: TargetLabelMetadata(),
date: fixedDate, timeZone: .current) 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 testManualWins() {
func manualWins() {
let resolved = PrinttargLabel.resolved( let resolved = PrinttargLabel.resolved(
customLabel: " My Label ", basename: "tgt", customLabel: " My Label ", basename: "tgt",
metadata: TargetLabelMetadata(), date: fixedDate) metadata: TargetLabelMetadata(), date: fixedDate)
#expect(resolved == "My Label") XCTAssertEqual(resolved, "My Label")
} }
} }
@Suite("PrinttargManifest") final class PrinttargManifestTests: XCTestCase {
struct PrinttargManifestTests {
private let prettySingle = """ private let prettySingle = """
Some log line Some log line
@@ -208,66 +205,58 @@ struct PrinttargManifestTests {
} }
""" """
@Test("Decodes a single-page pretty manifest amid log noise") func testSinglePage() throws {
func singlePage() throws {
let m = try PrinttargManifestExtractor.manifest(from: prettySingle) let m = try PrinttargManifestExtractor.manifest(from: prettySingle)
#expect(m.event == "manifest") XCTAssertEqual(m.event, "manifest")
#expect(m.pages.count == 1) XCTAssertEqual(m.pages.count, 1)
#expect(m.pages[0].filename == "target.tif") XCTAssertEqual(m.pages[0].filename, "target.tif")
#expect(m.pages[0].patches == 800) XCTAssertEqual(m.pages[0].patches, 800)
} }
@Test("Multi-page manifest preserves order") func testMultiPage() throws {
func multiPage() throws {
let m = try PrinttargManifestExtractor.manifest(from: prettyMulti) 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 testNoJSON() {
func noJSON() { XCTAssertThrowsError(try PrinttargManifestExtractor.manifest(from: "plain text\nno json")) { error in
#expect(throws: ManifestError.self) { XCTAssertTrue(error is ManifestError)
try PrinttargManifestExtractor.manifest(from: "plain text\nno json")
} }
} }
@Test("Wrong event → wrongEvent") func testWrongEvent() {
func wrongEvent() {
let stdout = "{\n \"event\": \"row\",\n \"row\": 1\n}\n" let stdout = "{\n \"event\": \"row\",\n \"row\": 1\n}\n"
#expect(throws: ManifestError.self) { XCTAssertThrowsError(try PrinttargManifestExtractor.manifest(from: stdout)) { error in
try PrinttargManifestExtractor.manifest(from: stdout) XCTAssertTrue(error is ManifestError)
} }
} }
@Test("ROW_COLORS_JSON line is never treated as the manifest") func testRowColorsNotManifest() {
func rowColorsNotManifest() {
let stdout = "ROW_COLORS_JSON: {\"a\":1}\n{\"event\":\"manifest\",\"pages\":[]}" let stdout = "ROW_COLORS_JSON: {\"a\":1}\n{\"event\":\"manifest\",\"pages\":[]}"
// Extraction only starts at a '{' that begins a trimmed line, // Extraction only starts at a '{' that begins a trimmed line,
// so the ROW_COLORS_JSON line is skipped entirely. // so the ROW_COLORS_JSON line is skipped entirely.
let m = try? PrinttargManifestExtractor.manifest(from: stdout) let m = try? PrinttargManifestExtractor.manifest(from: stdout)
#expect(m != nil) XCTAssertNotNil(m)
#expect(m?.event == "manifest") XCTAssertEqual(m?.event, "manifest")
} }
@Test("Braces inside a quoted filename do not corrupt the scan") func testBracesInFilename() throws {
func bracesInFilename() throws {
let stdout = "log\n{\n\"event\": \"manifest\",\n\"pages\": [{\"filename\": \"a}b.tif\", \"patches\": 1, \"width_mm\": 50, \"height_mm\": 50}]\n}\n" let 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) 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 testUnsafeFilenames() {
func unsafeFilenames() {
for bad in ["../x.tif", "/abs/x.tif", "dir/x.tif", "x.txt", ""] { 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}" let stdout = "{\n\"event\":\"manifest\",\"pages\":[{\"filename\":\"\(bad)\",\"patches\":1,\"width_mm\":50,\"height_mm\":50}]\n}"
#expect(throws: ManifestError.self) { XCTAssertThrowsError(try PrinttargManifestExtractor.manifest(from: stdout)) { error in
try PrinttargManifestExtractor.manifest(from: stdout) XCTAssertTrue(error is ManifestError)
} }
} }
} }
} }
@Suite("ArgyllRunner Printtarg") final class ArgyllRunnerPrinttargTests: XCTestCase {
struct ArgyllRunnerPrinttargTests {
private func makeFixture(_ body: String, name: String = "printtarg") throws -> URL { private func makeFixture(_ body: String, name: String = "printtarg") throws -> URL {
let dir = FileManager.default.temporaryDirectory let dir = FileManager.default.temporaryDirectory
@@ -319,8 +308,7 @@ struct ArgyllRunnerPrinttargTests {
try Data(bytes).write(to: url) try Data(bytes).write(to: url)
} }
@Test("Successful printtarg emits .ti2 + manifest + PNG previews") func testSuccess() async throws {
func success() async throws {
let dir = try makeFixture(""" let dir = try makeFixture("""
#!/bin/sh #!/bin/sh
last="" last=""
@@ -339,18 +327,17 @@ struct ArgyllRunnerPrinttargTests {
processManager: ProcessManager(), binaryResolver: resolver) processManager: ProcessManager(), binaryResolver: resolver)
let config = PrinttargConfig(basename: "pt", workingDirectory: dir) let config = PrinttargConfig(basename: "pt", workingDirectory: dir)
let result = try await runner.runPrinttarg(config: config) let result = try await runner.runPrinttarg(config: config)
#expect(result.ti2URL.lastPathComponent == "pt.ti2") XCTAssertEqual(result.ti2URL.lastPathComponent, "pt.ti2")
#expect(result.manifest.pages.count == 1) XCTAssertEqual(result.manifest.pages.count, 1)
#expect(result.pages.count == 1) XCTAssertEqual(result.pages.count, 1)
let png = result.pages[0].previewPNG let png = result.pages[0].previewPNG
#expect(png != nil) XCTAssertNotNil(png)
if let 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 testFailure() async throws {
func failure() async throws {
let dir = try makeFixture(""" let dir = try makeFixture("""
#!/bin/sh #!/bin/sh
echo "oops" >&2 echo "oops" >&2
@@ -360,14 +347,16 @@ struct ArgyllRunnerPrinttargTests {
let runner = ArgyllRunner( let runner = ArgyllRunner(
processManager: ProcessManager(), processManager: ProcessManager(),
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir)) binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir))
await #expect(throws: ArgyllRunnerError.self) { await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
try await runner.runPrinttarg( try await runner.runPrinttarg(
config: PrinttargConfig(basename: "x", workingDirectory: dir)) 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 testNoManifest() async throws {
func noManifest() async throws {
let dir = try makeFixture(""" let dir = try makeFixture("""
#!/bin/sh #!/bin/sh
last="" last=""
@@ -380,14 +369,13 @@ struct ArgyllRunnerPrinttargTests {
let runner = ArgyllRunner( let runner = ArgyllRunner(
processManager: ProcessManager(), processManager: ProcessManager(),
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir)) binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir))
await #expect(throws: ArgyllRunnerError.self) { await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
try await runner.runPrinttarg( try await runner.runPrinttarg(
config: PrinttargConfig(basename: "x", workingDirectory: dir)) config: PrinttargConfig(basename: "x", workingDirectory: dir))
} }
} }
@Test("Exit 0 without .ti2 → missingArtefact") func testNoTi2() async throws {
func noTi2() async throws {
let dir = try makeFixture(""" let dir = try makeFixture("""
#!/bin/sh #!/bin/sh
printf '{\\n"event":"manifest",\\n"pages":[]\\n}\\n' printf '{\\n"event":"manifest",\\n"pages":[]\\n}\\n'
@@ -397,14 +385,13 @@ struct ArgyllRunnerPrinttargTests {
let runner = ArgyllRunner( let runner = ArgyllRunner(
processManager: ProcessManager(), processManager: ProcessManager(),
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir)) binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir))
await #expect(throws: ArgyllRunnerError.self) { await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
try await runner.runPrinttarg( try await runner.runPrinttarg(
config: PrinttargConfig(basename: "x", workingDirectory: dir)) config: PrinttargConfig(basename: "x", workingDirectory: dir))
} }
} }
@Test("Deterministic config produces byte-identical .ti2") func testDeterminism() async throws {
func determinism() async throws {
let dir = try makeFixture(""" let dir = try makeFixture("""
#!/bin/sh #!/bin/sh
last="" last=""
@@ -424,6 +411,6 @@ struct ArgyllRunnerPrinttargTests {
config: PrinttargConfig(basename: "b", workingDirectory: dir)) config: PrinttargConfig(basename: "b", workingDirectory: dir))
let d1 = try Data(contentsOf: dir.appendingPathComponent("a.ti2")) let d1 = try Data(contentsOf: dir.appendingPathComponent("a.ti2"))
let d2 = try Data(contentsOf: dir.appendingPathComponent("b.ti2")) let d2 = try Data(contentsOf: dir.appendingPathComponent("b.ti2"))
#expect(d1 == d2) XCTAssertEqual(d1, d2)
} }
} }
+286 -77
View File
@@ -1,11 +1,11 @@
import Testing import XCTest
import Foundation import Foundation
@testable import ICCeryCore @testable import ICCeryCore
/// Helpers shared across ProcessManager tests. Fixture binaries are shell /// Helpers shared across ProcessManager tests. Fixture binaries are shell
/// scripts written to a temp dir no resource bundling required. /// scripts written to a temp dir no resource bundling required.
@Suite("ProcessManager", .serialized) /// XCTest executes test methods serially by default.
struct ProcessManagerTests { final class ProcessManagerTests: XCTestCase {
// MARK: - Fixture plumbing // MARK: - Fixture plumbing
@@ -43,7 +43,7 @@ struct ProcessManagerTests {
if box.finish() { cont.resume(returning: box.events) } if box.finish() { cont.resume(returning: box.events) }
} }
Task { 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) } 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 } 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 // MARK: - Tests
@Test func streamsStdoutAndEmitsExit() async throws { func testStreamsStdoutAndEmitsExit() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let bin = try script("lines.sh", "#!/bin/sh\necho hello\necho world\n") let bin = try script("lines.sh", "#!/bin/sh\necho hello\necho world\n")
async let events = collect(pm, id: "t1") async let events = collect(pm, id: "t1")
@@ -69,21 +122,21 @@ struct ProcessManagerTests {
let lines = evs.compactMap { e -> String? in let lines = evs.compactMap { e -> String? in
if case .stdout(_, let l) = e { return l }; return nil if case .stdout(_, let l) = e { return l }; return nil
} }
#expect(lines == ["hello", "world"]) XCTAssertEqual(lines, ["hello", "world"])
#expect(evs.contains(.exit(id: "t1", code: 0))) XCTAssertTrue(evs.contains(.exit(id: "t1", code: 0)))
} }
@Test func routesStderrSeparately() async throws { func testRoutesStderrSeparately() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let bin = try script("err.sh", "#!/bin/sh\necho out\necho oops 1>&2\n") let bin = try script("err.sh", "#!/bin/sh\necho out\necho oops 1>&2\n")
async let evs = collect(pm, id: "t2") async let evs = collect(pm, id: "t2")
try await pm.runStreaming(id: "t2", binary: bin, arguments: []) try await pm.runStreaming(id: "t2", binary: bin, arguments: [])
let events = await evs let events = await evs
#expect(events.contains(.stdout(id: "t2", line: "out"))) XCTAssertTrue(events.contains(.stdout(id: "t2", line: "out")))
#expect(events.contains(.stderr(id: "t2", line: "oops"))) XCTAssertTrue(events.contains(.stderr(id: "t2", line: "oops")))
} }
@Test func stripsRowColorsJSONPrefix() async throws { func testStripsRowColorsJSONPrefix() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let bin = try script( let bin = try script(
"rows.sh", "rows.sh",
@@ -96,21 +149,22 @@ struct ProcessManagerTests {
if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) } if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) }
return nil return nil
} }
#expect(rows == ["{\"row\":1}"]) XCTAssertEqual(rows, ["{\"row\":1}"])
#expect(events.contains(.stdout(id: "t3", line: "plain"))) XCTAssertTrue(events.contains(.stdout(id: "t3", line: "plain")))
// Prefixed lines must not leak into stdout. // 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 pm = ProcessManager()
let bin = try script("tail.sh", "#!/bin/sh\nprintf 'no-newline'\n") let bin = try script("tail.sh", "#!/bin/sh\nprintf 'no-newline'\n")
async let evs = collect(pm, id: "t4") async let evs = collect(pm, id: "t4")
try await pm.runStreaming(id: "t4", binary: bin, arguments: []) 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() let pm = ProcessManager()
// Read two lines then exit naturally a killed sh would lose its // Read two lines then exit naturally a killed sh would lose its
// buffered stdio output, which is exactly the chartread pattern. // 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: " \n")
try await pm.sendStdin(id: "t5", text: "d\n") try await pm.sendStdin(id: "t5", text: "d\n")
let events = await evs let events = await evs
#expect(events.contains(.stdout(id: "t5", line: "got: "))) XCTAssertTrue(events.contains(.stdout(id: "t5", line: "got: ")))
#expect(events.contains(.stdout(id: "t5", line: "got:d"))) XCTAssertTrue(events.contains(.stdout(id: "t5", line: "got:d")))
} }
@Test func duplicateIDRejected() async throws { func testDuplicateIDRejected() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let bin = try script("slow.sh", "#!/bin/sh\nsleep 30\n") let bin = try script("slow.sh", "#!/bin/sh\nsleep 30\n")
try await pm.runStreaming(id: "t6", binary: bin, arguments: []) 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: []) try await pm.runStreaming(id: "t6", binary: bin, arguments: [])
} errorHandler: { error in
XCTAssertEqual(error, .duplicateID("t6"))
} }
await pm.kill(id: "t6") await pm.kill(id: "t6")
} }
@Test func killEmitsExitAndClosesStdin() async throws { func testKillEmitsExitAndClosesStdin() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let bin = try script("slow2.sh", "#!/bin/sh\ncat\n") let bin = try script("slow2.sh", "#!/bin/sh\ncat\n")
async let evs = collect(pm, id: "t7") async let evs = collect(pm, id: "t7")
@@ -146,49 +202,51 @@ struct ProcessManagerTests {
let events = await evs let events = await evs
// exit emitted exactly once // exit emitted exactly once
let exits = events.filter { if case .exit = $0 { return true }; return false } let exits = events.filter { if case .exit = $0 { return true }; return false }
#expect(exits.count == 1) XCTAssertEqual(exits.count, 1)
await #expect(throws: ProcessError.unknownID("t7")) { await assertAsyncThrows(expectedType: ProcessError.self) {
try await pm.sendStdin(id: "t7", text: "d\n") 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 pm = ProcessManager()
let bin = try script("slow3.sh", "#!/bin/sh\nsleep 30\n") 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: "a", binary: bin, arguments: [])
try await pm.runStreaming(id: "b", binary: bin, arguments: []) try await pm.runStreaming(id: "b", binary: bin, arguments: [])
let count = await pm.killAll() let count = await pm.killAll()
#expect(count == 2) XCTAssertEqual(count, 2)
} }
@Test func capturedRunReturnsBothStreams() async throws { func testCapturedRunReturnsBothStreams() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let bin = try script("cap.sh", "#!/bin/sh\necho out-data\necho err-data 1>&2\nexit 3\n") 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: []) let result = try await pm.runCaptured(id: "cap", binary: bin, arguments: [])
#expect(result.stdout.contains("out-data")) XCTAssertTrue(result.stdout.contains("out-data"))
#expect(result.stderr.contains("err-data")) XCTAssertTrue(result.stderr.contains("err-data"))
#expect(result.exitCode == 3) XCTAssertEqual(result.exitCode, 3)
} }
@Test func capturedRunFastExit() async throws { func testCapturedRunFastExit() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let bin = try script("fast.sh", "#!/bin/sh\nexit 7\n") let bin = try script("fast.sh", "#!/bin/sh\nexit 7\n")
let result = try await pm.runCaptured(id: "fast", binary: bin, arguments: []) let result = try await pm.runCaptured(id: "fast", binary: bin, arguments: [])
#expect(result.exitCode == 7) XCTAssertEqual(result.exitCode, 7)
#expect(result.stdout == "") XCTAssertEqual(result.stdout, "")
#expect(result.stderr == "") XCTAssertEqual(result.stderr, "")
} }
@Test func capturedRunStderrOnly() async throws { func testCapturedRunStderrOnly() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let bin = try script("stderr-only.sh", "#!/bin/sh\necho 'mock lp failure' 1>&2\nexit 1\n") 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: []) let result = try await pm.runCaptured(id: "stderr-only", binary: bin, arguments: [])
#expect(result.exitCode == 1) XCTAssertEqual(result.exitCode, 1)
#expect(result.stdout == "") XCTAssertEqual(result.stdout, "")
#expect(result.stderr.contains("mock lp failure")) XCTAssertTrue(result.stderr.contains("mock lp failure"))
} }
@Test func capturedRunDoesNotDeadlockOnLargeOutput() async throws { func testCapturedRunDoesNotDeadlockOnLargeOutput() async throws {
let pm = ProcessManager() let pm = ProcessManager()
// 5000 lines each stream exceeds the 64 KiB pipe buffer. // 5000 lines each stream exceeds the 64 KiB pipe buffer.
let bin = try script( 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" "#!/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: []) let result = try await pm.runCaptured(id: "big", binary: bin, arguments: [])
#expect(result.stdout.contains("out-4999")) XCTAssertTrue(result.stdout.contains("out-4999"))
#expect(result.stderr.contains("err-4999")) XCTAssertTrue(result.stderr.contains("err-4999"))
} }
@Test func argyllEnvVarIsSet() async throws { func testArgyllEnvVarIsSet() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let bin = try script("env.sh", "#!/bin/sh\necho \"ANI=$ARGYLL_NOT_INTERACTIVE\"\n") let bin = try script("env.sh", "#!/bin/sh\necho \"ANI=$ARGYLL_NOT_INTERACTIVE\"\n")
async let evs = collect(pm, id: "t10") async let evs = collect(pm, id: "t10")
try await pm.runStreaming(id: "t10", binary: bin, arguments: []) 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() let pm = ProcessManager()
await #expect(throws: ProcessError.unknownID("nope")) { await assertAsyncThrows(expectedType: ProcessError.self) {
try await pm.sendStdin(id: "nope", text: "d\n") 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") final class ProcessLineDecoderTests: XCTestCase {
struct ProcessLineDecoderTests { func testSplitsAcrossChunkBoundaries() {
@Test func splitsAcrossChunkBoundaries() {
var d = ProcessLineDecoder() var d = ProcessLineDecoder()
#expect(d.feed(Data("he".utf8)) == []) XCTAssertEqual(d.feed(Data("he".utf8)), [])
#expect(d.feed(Data("llo\nwor".utf8)) == ["hello"]) XCTAssertEqual(d.feed(Data("llo\nwor".utf8)), ["hello"])
#expect(d.feed(Data("ld\n".utf8)) == ["world"]) XCTAssertEqual(d.feed(Data("ld\n".utf8)), ["world"])
#expect(d.finish() == nil) XCTAssertNil(d.finish())
} }
@Test func crlfIsStripped() { func testCrlfIsStripped() {
var d = ProcessLineDecoder() 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() var d = ProcessLineDecoder()
_ = d.feed(Data("x".utf8)) _ = d.feed(Data("x".utf8))
#expect(d.finish() == "x") XCTAssertEqual(d.finish(), "x")
#expect(d.finish() == nil) XCTAssertNil(d.finish())
} }
} }
@Suite("JSONAccumulator") final class JSONAccumulatorTests: XCTestCase {
struct JSONAccumulatorTests { func testMultilinePrettyJSON() {
@Test func multilinePrettyJSON() {
var acc = JSONAccumulator() var acc = JSONAccumulator()
#expect(acc.feed(line: "{") == nil) XCTAssertNil(acc.feed(line: "{"))
#expect(acc.feed(line: " \"k\": 1") == nil) XCTAssertNil(acc.feed(line: " \"k\": 1"))
let done = acc.feed(line: "}") let done = acc.feed(line: "}")
#expect(done != nil) XCTAssertNotNil(done)
let obj = try? JSONSerialization.jsonObject(with: done!) as? [String: Int] 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() var acc = JSONAccumulator()
#expect(acc.feed(line: "Reading instrument...") == nil) XCTAssertNil(acc.feed(line: "Reading instrument..."))
#expect(acc.feed(line: "still text") == nil) XCTAssertNil(acc.feed(line: "still text"))
#expect(acc.completeData == nil) XCTAssertNil(acc.completeData)
} }
@Test func decodeTyped() { func testDecodeTyped() {
struct Doc: Decodable { let n: Int } struct Doc: Decodable { let n: Int }
var acc = JSONAccumulator() var acc = JSONAccumulator()
// Split so the doc completes on the second feed. // 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}") let data = acc.feed(line: "7}")
#expect(data != nil) XCTAssertNotNil(data)
let doc = data.flatMap { try? JSONDecoder().decode(Doc.self, from: $0) } let doc = data.flatMap { try? JSONDecoder().decode(Doc.self, from: $0) }
#expect(doc?.n == 7) XCTAssertEqual(doc?.n, 7)
#expect(acc.isEmpty) XCTAssertTrue(acc.isEmpty)
} }
} }
@Suite("LogSanitizer") final class LogSanitizerTests: XCTestCase {
struct LogSanitizerTests { func testHomeIsRewritten() {
@Test func homeIsRewritten() {
let path = "\(NSHomeDirectory())/Documents/foo.ti1" let path = "\(NSHomeDirectory())/Documents/foo.ti1"
#expect(LogSanitizer.sanitize(path) == "~/Documents/foo.ti1") XCTAssertEqual(LogSanitizer.sanitize(path), "~/Documents/foo.ti1")
} }
} }
@@ -0,0 +1,64 @@
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()
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 Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@Suite("ProfcheckArgs") final class ProfcheckArgsTests: XCTestCase {
struct ProfcheckArgsTests {
@Test("Hard-coded argv") func testArgv() throws {
func argv() throws {
let config = ProfcheckConfig( let config = ProfcheckConfig(
ti3URL: URL(fileURLWithPath: "/tmp/target.ti3"), ti3URL: URL(fileURLWithPath: "/tmp/target.ti3"),
iccURL: URL(fileURLWithPath: "/tmp/target.icc") iccURL: URL(fileURLWithPath: "/tmp/target.icc")
) )
let args = try ProfcheckArgs.build(config: config) 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 Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@Suite("ProfcheckParser") final class ProfcheckParserTests: XCTestCase {
struct ProfcheckParserTests {
@Test("Prefers JSON report with de2000 keys") func testJsonReport() {
func jsonReport() {
let output = """ let output = """
No of test patches = 52 No of test patches = 52
{"event": "report", "peak_de2000": 2.41, "avg_de2000": 0.85, "rms": 1.02} {"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 Profile check complete, errors(CIEDE2000): max. = 9.99, avg. = 9.99, RMS = 9.99
""" """
let report = ProfcheckParser.parse(output) let report = ProfcheckParser.parse(output)
#expect(report.isValid == true) XCTAssertEqual(report.isValid, true)
#expect(report.patchCount == 52) XCTAssertEqual(report.patchCount, 52)
#expect(report.avgDE == 0.85) XCTAssertEqual(report.avgDE, 0.85)
#expect(report.maxDE == 2.41) XCTAssertEqual(report.maxDE, 2.41)
#expect(report.rmsDE == 1.02) XCTAssertEqual(report.rmsDE, 1.02)
#expect(report.status == .excellent) XCTAssertEqual(report.status, .excellent)
} }
@Test("Falls back to legacy text") func testLegacyText() {
func legacyText() {
let output = """ let output = """
No of test patches = 120 No of test patches = 120
Profile check complete, errors(CIEDE2000): max. = 3.50, avg. = 1.80, RMS = 0.95 Profile check complete, errors(CIEDE2000): max. = 3.50, avg. = 1.80, RMS = 0.95
""" """
let report = ProfcheckParser.parse(output) let report = ProfcheckParser.parse(output)
#expect(report.isValid == true) XCTAssertEqual(report.isValid, true)
#expect(report.patchCount == 120) XCTAssertEqual(report.patchCount, 120)
#expect(report.avgDE == 1.80) XCTAssertEqual(report.avgDE, 1.80)
#expect(report.maxDE == 3.50) XCTAssertEqual(report.maxDE, 3.50)
#expect(report.rmsDE == 0.95) XCTAssertEqual(report.rmsDE, 0.95)
#expect(report.status == .good) XCTAssertEqual(report.status, .good)
} }
@Test("Broad regex fallback") func testRegexFallback() {
func regexFallback() {
let output = """ let output = """
No of test patches = 10 No of test patches = 10
avg = 4.25 avg = 4.25
@@ -45,27 +41,25 @@ struct ProfcheckParserTests {
rms = 2.30 rms = 2.30
""" """
let report = ProfcheckParser.parse(output) let report = ProfcheckParser.parse(output)
#expect(report.isValid == true) XCTAssertEqual(report.isValid, true)
#expect(report.avgDE == 4.25) XCTAssertEqual(report.avgDE, 4.25)
#expect(report.maxDE == 6.10) XCTAssertEqual(report.maxDE, 6.10)
#expect(report.rmsDE == 2.30) XCTAssertEqual(report.rmsDE, 2.30)
#expect(report.status == .poor) XCTAssertEqual(report.status, .poor)
} }
@Test("Unparseable output warns, not zeros") func testUnparseable() {
func unparseable() {
let output = "some random text without metrics" let output = "some random text without metrics"
let report = ProfcheckParser.parse(output) let report = ProfcheckParser.parse(output)
#expect(report.isValid == false) XCTAssertEqual(report.isValid, false)
#expect(report.warning != nil) XCTAssertNotNil(report.warning)
#expect(report.avgDE == nil) XCTAssertNil(report.avgDE)
} }
@Test("Status bands") func testStatusBands() {
func statusBands() { XCTAssertEqual(VerificationStatus.from(avgDE: 0.5), .excellent)
#expect(VerificationStatus.from(avgDE: 0.5) == .excellent) XCTAssertEqual(VerificationStatus.from(avgDE: 1.5), .good)
#expect(VerificationStatus.from(avgDE: 1.5) == .good) XCTAssertEqual(VerificationStatus.from(avgDE: 2.5), .acceptable)
#expect(VerificationStatus.from(avgDE: 2.5) == .acceptable) XCTAssertEqual(VerificationStatus.from(avgDE: 4.0), .poor)
#expect(VerificationStatus.from(avgDE: 4.0) == .poor)
} }
} }
@@ -1,5 +1,5 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
/// A `FileManager` subclass that reports a temporary directory as the /// A `FileManager` subclass that reports a temporary directory as the
@@ -18,8 +18,7 @@ private final class TestFileManager: FileManager {
} }
} }
@Suite("ProfileInstaller") final class ProfileInstallerTests: XCTestCase {
struct ProfileInstallerTests {
private func makeTempDir() throws -> URL { private func makeTempDir() throws -> URL {
let fm = FileManager.default let fm = FileManager.default
@@ -39,8 +38,7 @@ struct ProfileInstallerTests {
return url return url
} }
@Test("Installs .icc to user ColorSync folder") func testUserInstall() throws {
func userInstall() throws {
let fm = FileManager.default let fm = FileManager.default
let tmp = try makeTempDir() let tmp = try makeTempDir()
let testFM = TestFileManager(home: tmp) let testFM = TestFileManager(home: tmp)
@@ -51,15 +49,14 @@ struct ProfileInstallerTests {
fileManager: testFM fileManager: testFM
) )
#expect(result.registered) XCTAssertTrue(result.registered)
#expect(!result.overwritten) XCTAssertFalse(result.overwritten)
#expect(!result.renamed) XCTAssertFalse(result.renamed)
#expect(result.destPath.hasSuffix("test.icc")) XCTAssertTrue(result.destPath.hasSuffix("test.icc"))
#expect(fm.fileExists(atPath: result.destPath)) XCTAssertTrue(fm.fileExists(atPath: result.destPath))
} }
@Test("Overwrite succeeds and replaces the existing file") func testOverwriteSucceeds() throws {
func overwriteSucceeds() throws {
let fm = FileManager.default let fm = FileManager.default
let tmp = try makeTempDir() let tmp = try makeTempDir()
let testFM = TestFileManager(home: tmp) let testFM = TestFileManager(home: tmp)
@@ -70,7 +67,7 @@ struct ProfileInstallerTests {
config: InstallProfileConfig(sourceURL: source), config: InstallProfileConfig(sourceURL: source),
fileManager: testFM fileManager: testFM
) )
#expect(!first.overwritten) XCTAssertFalse(first.overwritten)
// Change the source contents. // Change the source contents.
let newBytes: [UInt8] = (0..<256).map { UInt8(($0 + 100) % 256) } let newBytes: [UInt8] = (0..<256).map { UInt8(($0 + 100) % 256) }
@@ -87,15 +84,14 @@ struct ProfileInstallerTests {
fileManager: testFM fileManager: testFM
) )
#expect(second.overwritten) XCTAssertTrue(second.overwritten)
#expect(!second.renamed) XCTAssertFalse(second.renamed)
#expect(fm.fileExists(atPath: second.destPath)) XCTAssertTrue(fm.fileExists(atPath: second.destPath))
let installed = try Data(contentsOf: URL(fileURLWithPath: 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 testPreservesIcmExtension() throws {
func preservesIcmExtension() throws {
let tmp = try makeTempDir() let tmp = try makeTempDir()
let testFM = TestFileManager(home: tmp) let testFM = TestFileManager(home: tmp)
let source = try makeSource(at: tmp, name: "m5_profile.icm") let source = try makeSource(at: tmp, name: "m5_profile.icm")
@@ -105,12 +101,11 @@ struct ProfileInstallerTests {
fileManager: testFM fileManager: testFM
) )
#expect(URL(fileURLWithPath: result.destPath).pathExtension == "icm") XCTAssertEqual(URL(fileURLWithPath: result.destPath).pathExtension, "icm")
#expect(result.destPath.hasSuffix("m5_profile.icm")) XCTAssertTrue(result.destPath.hasSuffix("m5_profile.icm"))
} }
@Test("Rejects parent traversal in source path") func testRejectsParentTraversal() throws {
func rejectsParentTraversal() throws {
let fm = FileManager.default let fm = FileManager.default
let tmp = try makeTempDir() let tmp = try makeTempDir()
@@ -125,20 +120,19 @@ struct ProfileInstallerTests {
let sourceURL = tmp let sourceURL = tmp
.appendingPathComponent("..") .appendingPathComponent("..")
.appendingPathComponent(naughtyName) .appendingPathComponent(naughtyName)
#expect(fm.fileExists(atPath: sourceURL.path)) XCTAssertTrue(fm.fileExists(atPath: sourceURL.path))
do { do {
_ = try ProfileInstaller.install(config: InstallProfileConfig(sourceURL: sourceURL)) _ = try ProfileInstaller.install(config: InstallProfileConfig(sourceURL: sourceURL))
Issue.record("Expected unsafeStem error") XCTFail("Expected unsafeStem error")
} catch let error as ProfileInstallError { } 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 { } catch {
Issue.record("Unexpected error type: \(error)") XCTFail("Unexpected error type: \(error)")
} }
} }
@Test("Allows stems with consecutive dots like foo..bar") func testAllowsDoubleDotStem() throws {
func allowsDoubleDotStem() throws {
let fm = FileManager.default let fm = FileManager.default
let tmp = try makeTempDir() let tmp = try makeTempDir()
let testFM = TestFileManager(home: tmp) let testFM = TestFileManager(home: tmp)
@@ -149,23 +143,22 @@ struct ProfileInstallerTests {
fileManager: testFM fileManager: testFM
) )
#expect(result.destPath.hasSuffix("foo..bar.icc")) XCTAssertTrue(result.destPath.hasSuffix("foo..bar.icc"))
#expect(fm.fileExists(atPath: result.destPath)) XCTAssertTrue(fm.fileExists(atPath: result.destPath))
} }
@Test("Rejects source files that are too small") func testRejectsSmallSource() throws {
func rejectsSmallSource() throws {
let tmp = try makeTempDir() let tmp = try makeTempDir()
let source = tmp.appendingPathComponent("tiny.icc") let source = tmp.appendingPathComponent("tiny.icc")
try Data(repeating: 0, count: 64).write(to: source) try Data(repeating: 0, count: 64).write(to: source)
do { do {
_ = try ProfileInstaller.install(config: InstallProfileConfig(sourceURL: source)) _ = try ProfileInstaller.install(config: InstallProfileConfig(sourceURL: source))
Issue.record("Expected sourceTooSmall error") XCTFail("Expected sourceTooSmall error")
} catch let error as ProfileInstallError { } 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 { } catch {
Issue.record("Unexpected error type: \(error)") XCTFail("Unexpected error type: \(error)")
} }
} }
} }
+71 -52
View File
@@ -1,5 +1,5 @@
import Testing
import Foundation import Foundation
import XCTest
@testable import ICCeryCore @testable import ICCeryCore
private func tempStoreURL() -> URL { private func tempStoreURL() -> URL {
@@ -8,92 +8,112 @@ private func tempStoreURL() -> URL {
.appendingPathComponent("settings.json") .appendingPathComponent("settings.json")
} }
@Suite("AppSettings") final class AppSettingsTests: XCTestCase {
struct AppSettingsTests { func testDefaults() {
@Test func defaults() {
let s = AppSettings.default let s = AppSettings.default
#expect(s.argyllBinaryDir == nil) XCTAssertNil(s.argyllBinaryDir)
#expect(s.defaultInstrument == nil) XCTAssertNil(s.defaultInstrument)
#expect(s.logLevel == nil) XCTAssertNil(s.logLevel)
#expect(s.deltaEGoodMax == 2.0) XCTAssertEqual(s.deltaEGoodMax, 2.0)
#expect(s.deltaEWarningMax == 5.0) XCTAssertEqual(s.deltaEWarningMax, 5.0)
#expect(s.customPresets.isEmpty) XCTAssertTrue(s.customPresets.isEmpty)
#expect(!s.enableI1Pro2Leds) XCTAssertFalse(s.enableI1Pro2Leds)
#expect(s.calibrationStaleDays == 30) XCTAssertEqual(s.calibrationStaleDays, 30)
#expect(s.defaultInstallLocation == .user) XCTAssertEqual(s.defaultInstallLocation, .user)
#expect(s.askBeforeOverwriteProfile) XCTAssertTrue(s.askBeforeOverwriteProfile)
#expect(!s.openColorPanelAfterInstall) XCTAssertFalse(s.openColorPanelAfterInstall)
#expect(s.isValid) XCTAssertTrue(s.isValid)
} }
@Test func negativeThresholds() { func testNegativeThresholds() {
var s = AppSettings.default var s = AppSettings.default
s.deltaEGoodMax = -1 s.deltaEGoodMax = -1
#expect(s.validate() == [AppSettings.errorNegativeDeltaE]) XCTAssertEqual(s.validate(), [AppSettings.errorNegativeDeltaE])
s.deltaEGoodMax = 2.0 s.deltaEGoodMax = 2.0
s.deltaEWarningMax = -0.5 s.deltaEWarningMax = -0.5
// -0.5 < 0 negative error; good(2.0) >= warn(-0.5) order error too // -0.5 < 0 negative error; good(2.0) >= warn(-0.5) order error too
#expect(s.validate() == [ XCTAssertTrue(s.validate() == [
AppSettings.errorNegativeDeltaE, AppSettings.errorNegativeDeltaE,
AppSettings.errorThresholdOrder, AppSettings.errorThresholdOrder,
]) ])
} }
@Test func goodMustBeStrictlyLessThanWarning() { func testGoodMustBeStrictlyLessThanWarning() {
var s = AppSettings.default var s = AppSettings.default
s.deltaEGoodMax = 5.0 s.deltaEGoodMax = 5.0
#expect(s.validate() == [AppSettings.errorThresholdOrder]) XCTAssertEqual(s.validate(), [AppSettings.errorThresholdOrder])
s.deltaEGoodMax = 6.0 s.deltaEGoodMax = 6.0
#expect(s.validate() == [AppSettings.errorThresholdOrder]) XCTAssertEqual(s.validate(), [AppSettings.errorThresholdOrder])
s.deltaEGoodMax = 4.9 s.deltaEGoodMax = 4.9
#expect(s.isValid) XCTAssertTrue(s.isValid)
} }
@Test func snakeCaseKeys() throws { func testSnakeCaseKeys() throws {
let s = AppSettings.default let s = AppSettings.default
let data = try JSONEncoder().encode(s) let data = try JSONEncoder().encode(s)
let json = String(data: data, encoding: .utf8)! let json = String(data: data, encoding: .utf8)!
#expect(json.contains("\"delta_e_good_max\"")) XCTAssertTrue(json.contains("\"delta_e_good_max\""))
#expect(json.contains("\"default_install_location\"")) XCTAssertTrue(json.contains("\"default_install_location\""))
#expect(json.contains("\"enable_i1pro2_leds\"")) XCTAssertTrue(json.contains("\"enable_i1pro2_leds\""))
} }
} }
@Suite("SettingsStore") final class SettingsStoreTests: XCTestCase {
struct SettingsStoreTests { func testRoundTrip() throws {
@Test func roundTrip() throws {
let url = tempStoreURL() let url = tempStoreURL()
let store = SettingsStore(fileURL: url) let store = SettingsStore(fileURL: url)
var s = AppSettings.default var s = AppSettings.default
s.deltaEGoodMax = 1.5 s.deltaEGoodMax = 1.5
s.defaultInstrument = "p3" s.defaultInstrument = "p3"
try store.save(s) try store.save(s)
#expect(store.load() == s) XCTAssertEqual(store.load(), s)
} }
@Test func corruptJsonFallsBackToDefaults() throws { func testCorruptJsonFallsBackToDefaults() throws {
let url = tempStoreURL() let url = tempStoreURL()
try FileManager.default.createDirectory( try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true at: url.deletingLastPathComponent(), withIntermediateDirectories: true
) )
try "{ not json".write(to: url, atomically: true, encoding: .utf8) 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() { func testMissingFileReturnsDefaults() {
#expect(SettingsStore(fileURL: tempStoreURL()).load() == .default) XCTAssertEqual(SettingsStore(fileURL: tempStoreURL()).load(), .default)
} }
@Test func invalidSettingsNotPersisted() throws { func testInvalidSettingsNotPersisted() throws {
let url = tempStoreURL() let url = tempStoreURL()
let store = SettingsStore(fileURL: url) let store = SettingsStore(fileURL: url)
var s = AppSettings.default var s = AppSettings.default
s.deltaEGoodMax = 9.0 // >= warning 5.0 s.deltaEGoodMax = 9.0 // >= warning 5.0
#expect(throws: SettingsStore.SettingsError.self) { try store.save(s) } XCTAssertThrowsError(try store.save(s)) { error in XCTAssertTrue(error is SettingsStore.SettingsError) }
#expect(!FileManager.default.fileExists(atPath: url.path)) 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 url = tempStoreURL()
let store = SettingsStore(fileURL: url) let store = SettingsStore(fileURL: url)
var fired = false var fired = false
@@ -102,12 +122,11 @@ struct SettingsStoreTests {
) { _ in fired = true } ) { _ in fired = true }
defer { NotificationCenter.default.removeObserver(token) } defer { NotificationCenter.default.removeObserver(token) }
try store.save(.default) try store.save(.default)
#expect(fired) XCTAssertTrue(fired)
} }
} }
@Suite("LogSink") final class LogSinkTests: XCTestCase {
struct LogSinkTests {
private func tempLog() -> (URL, LogSink) { private func tempLog() -> (URL, LogSink) {
let url = FileManager.default.temporaryDirectory let url = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-log-\(UUID().uuidString)") .appendingPathComponent("iccery-log-\(UUID().uuidString)")
@@ -115,26 +134,26 @@ struct LogSinkTests {
return (url, LogSink(fileURL: url)) return (url, LogSink(fileURL: url))
} }
@Test func writesFormattedLines() { func testWritesFormattedLines() {
let (url, sink) = tempLog() let (url, sink) = tempLog()
sink.setLevel(.debug) sink.setLevel(.debug)
sink.write(level: .info, category: "test", message: "hello") sink.write(level: .info, category: "test", message: "hello")
let content = (try? String(contentsOf: url, encoding: .utf8)) ?? "" 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() let (url, sink) = tempLog()
sink.setLevel(.error) sink.setLevel(.error)
sink.write(level: .info, category: "t", message: "hidden") sink.write(level: .info, category: "t", message: "hidden")
sink.setLevel(.info) // runtime change, no restart (#158) sink.setLevel(.info) // runtime change, no restart (#158)
sink.write(level: .info, category: "t", message: "shown") sink.write(level: .info, category: "t", message: "shown")
let content = (try? String(contentsOf: url, encoding: .utf8)) ?? "" let content = (try? String(contentsOf: url, encoding: .utf8)) ?? ""
#expect(!content.contains("hidden")) XCTAssertFalse(content.contains("hidden"))
#expect(content.contains("shown")) XCTAssertTrue(content.contains("shown"))
} }
@Test func rotatesAt5MiBKeeping5Segments() throws { func testRotatesAt5MiBKeeping5Segments() throws {
let (url, sink) = tempLog() let (url, sink) = tempLog()
sink.setLevel(.trace) sink.setLevel(.trace)
// Pre-fill the active log just under the cap, then cross it. // 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) try big.write(to: url, atomically: true, encoding: .utf8)
sink.write(level: .info, category: "t", message: "trigger rotation") sink.write(level: .info, category: "t", message: "trigger rotation")
#expect(FileManager.default.fileExists( XCTAssertTrue(FileManager.default.fileExists(
atPath: url.appendingPathExtension("1").path atPath: url.appendingPathExtension("1").path
)) ))
// Active log is small again. // Active log is small again.
let size = try FileManager.default.attributesOfItem( let size = try FileManager.default.attributesOfItem(
atPath: url.path atPath: url.path
)[.size] as? UInt64 )[.size] as? UInt64
#expect((size ?? 0) < 1024) XCTAssertTrue((size ?? 0) < 1024)
} }
@Test func tailExcerptCaps() throws { func testTailExcerptCaps() throws {
let (url, sink) = tempLog() let (url, sink) = tempLog()
sink.setLevel(.debug) sink.setLevel(.debug)
sink.write(level: .info, category: "t", message: "line") sink.write(level: .info, category: "t", message: "line")
#expect(sink.tailExcerpt(maxBytes: 8).count <= 8) XCTAssertTrue(sink.tailExcerpt(maxBytes: 8).count <= 8)
} }
} }
+75 -60
View File
@@ -1,12 +1,10 @@
import Testing import XCTest
import Foundation import Foundation
@testable import ICCeryCore @testable import ICCeryCore
@Suite("TargenArgs") final class TargenArgsTests: XCTestCase {
struct TargenArgsTests {
@Test("RGB baseline: -v -d 2 -f 800 -e 4 -B 4") func testRgbBaseline() throws {
func rgbBaseline() throws {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .rgb, colourSpace: .rgb,
patchCount: 800, patchCount: 800,
@@ -15,12 +13,11 @@ struct TargenArgsTests {
basename: "test_rgb" basename: "test_rgb"
) )
let args = try TargenArgs.build(config: config) let args = try TargenArgs.build(config: config)
#expect(args == ["-v", "-d", "2", "-f", "800", "-e", "4", "-B", "4", "test_rgb"]) XCTAssertEqual(args, ["-v", "-d", "2", "-f", "800", "-e", "4", "-B", "4", "test_rgb"])
#expect(!args.contains("-u")) XCTAssertFalse(args.contains("-u"))
} }
@Test("CMYK baseline: -v -d 4 -f 1500 -e 4 -B 0") func testCmykBaseline() throws {
func cmykBaseline() throws {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .cmyk, colourSpace: .cmyk,
patchCount: 1500, patchCount: 1500,
@@ -29,11 +26,10 @@ struct TargenArgsTests {
basename: "test_cmyk" basename: "test_cmyk"
) )
let args = try TargenArgs.build(config: config) 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 testCustomPatchCount() throws {
func customPatchCount() throws {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .rgb, colourSpace: .rgb,
patchCount: 2500, patchCount: 2500,
@@ -42,12 +38,11 @@ struct TargenArgsTests {
basename: "custom_patches" basename: "custom_patches"
) )
let args = try TargenArgs.build(config: config) let args = try TargenArgs.build(config: config)
#expect(args.contains("-f")) XCTAssertTrue(args.contains("-f"))
#expect(args[args.firstIndex(of: "-f")! + 1] == "2500") XCTAssertEqual(args[args.firstIndex(of: "-f")! + 1], "2500")
} }
@Test("All advanced flags in stable order") func testAllAdvancedFlags() throws {
func allAdvancedFlags() throws {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .cmyk, colourSpace: .cmyk,
patchCount: 1200, patchCount: 1200,
@@ -85,11 +80,10 @@ struct TargenArgsTests {
"-p", "2.00", "-p", "2.00",
"advanced_cmyk" "advanced_cmyk"
] ]
#expect(args == expected) XCTAssertEqual(args, expected)
} }
@Test("RGB ignores total ink limit") func testRgbIgnoresInkLimit() throws {
func rgbIgnoresInkLimit() throws {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .rgb, colourSpace: .rgb,
patchCount: 800, patchCount: 800,
@@ -99,11 +93,10 @@ struct TargenArgsTests {
basename: "rgb_no_ink" basename: "rgb_no_ink"
) )
let args = try TargenArgs.build(config: config) let args = try TargenArgs.build(config: config)
#expect(!args.contains("-l")) XCTAssertFalse(args.contains("-l"))
} }
@Test("Neutral concentration omitted when approximately 0.50") func testNeutralConcentrationOmittedWhenDefault() throws {
func neutralConcentrationOmittedWhenDefault() throws {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .rgb, colourSpace: .rgb,
patchCount: 800, patchCount: 800,
@@ -113,11 +106,10 @@ struct TargenArgsTests {
basename: "n_default" basename: "n_default"
) )
let args = try TargenArgs.build(config: config) 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 testAdaptationEmittedAtPointOne() throws {
func adaptationEmittedAtPointOne() throws {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .rgb, colourSpace: .rgb,
patchCount: 800, patchCount: 800,
@@ -127,12 +119,11 @@ struct TargenArgsTests {
basename: "a_flag" basename: "a_flag"
) )
let args = try TargenArgs.build(config: config) let args = try TargenArgs.build(config: config)
#expect(args.contains("-A")) XCTAssertTrue(args.contains("-A"))
#expect(args[args.firstIndex(of: "-A")! + 1] == "0.10") XCTAssertEqual(args[args.firstIndex(of: "-A")! + 1], "0.10")
} }
@Test("OFPS full spread algorithm emits no flag") func testOfpsEmitsNoFlag() throws {
func ofpsEmitsNoFlag() throws {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .rgb, colourSpace: .rgb,
patchCount: 800, patchCount: 800,
@@ -142,12 +133,11 @@ struct TargenArgsTests {
basename: "ofps_test" basename: "ofps_test"
) )
let args = try TargenArgs.build(config: config) let args = try TargenArgs.build(config: config)
#expect(!args.contains("ofps")) XCTAssertFalse(args.contains("ofps"))
#expect(!args.contains("-t")) XCTAssertFalse(args.contains("-t"))
} }
@Test("Dark emphasis and device power omitted when 1.0") func testDarkEmphasisAndPowerOmittedWhenOne() throws {
func darkEmphasisAndPowerOmittedWhenOne() throws {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .rgb, colourSpace: .rgb,
patchCount: 800, patchCount: 800,
@@ -158,12 +148,37 @@ struct TargenArgsTests {
basename: "defaults_omitted" basename: "defaults_omitted"
) )
let args = try TargenArgs.build(config: config) let args = try TargenArgs.build(config: config)
#expect(!args.contains("-V")) XCTAssertFalse(args.contains("-V"))
#expect(!args.contains("-p")) XCTAssertFalse(args.contains("-p"))
} }
@Test("Invalid basename throws") func testWhitespacePreconditioner() throws {
func invalidBasenameThrows() { 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( let config = TargenConfig(
colourSpace: .rgb, colourSpace: .rgb,
patchCount: 800, patchCount: 800,
@@ -171,13 +186,12 @@ struct TargenArgsTests {
blackPatches: 4, blackPatches: 4,
basename: "../bad_name" basename: "../bad_name"
) )
#expect(throws: PathSecurity.Error.self) { XCTAssertThrowsError(try TargenArgs.build(config: config)) { error in
try TargenArgs.build(config: config) XCTAssertTrue(error is PathSecurity.Error)
} }
} }
@Test("Invalid patch count throws") func testInvalidPatchCountThrows() {
func invalidPatchCountThrows() {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .rgb, colourSpace: .rgb,
patchCount: 0, patchCount: 0,
@@ -185,13 +199,12 @@ struct TargenArgsTests {
blackPatches: 4, blackPatches: 4,
basename: "bad_count" basename: "bad_count"
) )
#expect(throws: TargenArgError.self) { XCTAssertThrowsError(try TargenArgs.build(config: config)) { error in
try TargenArgs.build(config: config) XCTAssertTrue(error is TargenArgError)
} }
} }
@Test("Invalid ink limit throws for CMYK") func testInvalidInkLimitThrows() {
func invalidInkLimitThrows() {
let config = TargenConfig( let config = TargenConfig(
colourSpace: .cmyk, colourSpace: .cmyk,
patchCount: 800, patchCount: 800,
@@ -200,17 +213,15 @@ struct TargenArgsTests {
totalInkLimit: 450, totalInkLimit: 450,
basename: "bad_ink" basename: "bad_ink"
) )
#expect(throws: TargenArgError.self) { XCTAssertThrowsError(try TargenArgs.build(config: config)) { error in
try TargenArgs.build(config: config) XCTAssertTrue(error is TargenArgError)
} }
} }
} }
@Suite("ArgyllRunner Targen") final class ArgyllRunnerTargenTests: XCTestCase {
struct ArgyllRunnerTargenTests {
@Test("Successful targen execution creates .ti1 and returns URL") func testSuccessfulTargenExecution() async throws {
func successfulTargenExecution() async throws {
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) } defer { try? FileManager.default.removeItem(at: tempDir) }
@@ -256,14 +267,13 @@ struct ArgyllRunnerTargenTests {
box.append(batch) box.append(batch)
} }
logLines = box.lines logLines = box.lines
#expect(logLines.contains("Generating patches...")) XCTAssertTrue(logLines.contains("Generating patches..."))
#expect(FileManager.default.fileExists(atPath: ti1URL.path)) XCTAssertTrue(FileManager.default.fileExists(atPath: ti1URL.path))
#expect(ti1URL.lastPathComponent == "mock_test.ti1") XCTAssertEqual(ti1URL.lastPathComponent, "mock_test.ti1")
} }
@Test("Failed targen execution throws processFailed") func testFailedTargenExecution() async throws {
func failedTargenExecution() async throws {
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) } defer { try? FileManager.default.removeItem(at: tempDir) }
@@ -290,13 +300,15 @@ struct ArgyllRunnerTargenTests {
workingDirectory: tempDir workingDirectory: tempDir
) )
await #expect(throws: ArgyllRunnerError.self) { await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
try await runner.runTargen(config: config) 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 testMissingArtefactThrows() async throws {
func missingArtefactThrows() async throws {
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) } defer { try? FileManager.default.removeItem(at: tempDir) }
@@ -323,8 +335,11 @@ struct ArgyllRunnerTargenTests {
workingDirectory: tempDir workingDirectory: tempDir
) )
await #expect(throws: ArgyllRunnerError.self) { await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
try await runner.runTargen(config: config) 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 Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@Suite("VerificationHistoryStore") final class VerificationHistoryStoreTests: XCTestCase {
struct VerificationHistoryStoreTests {
@Test("Append and cap") func testAppendAndCap() async throws {
func appendAndCap() async throws {
let fm = FileManager.default let fm = FileManager.default
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try fm.createDirectory(at: tmp, withIntermediateDirectories: true) try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
@@ -29,12 +27,11 @@ struct VerificationHistoryStoreTests {
} }
let all = await store.all() let all = await store.all()
#expect(all.count == 3) XCTAssertEqual(all.count, 3)
#expect(all.first?.avgDE == 2.0) XCTAssertEqual(all.first?.avgDE, 2.0)
} }
@Test("Parse failure preserves file") func testParseFailurePreservesFile() async {
func parseFailurePreservesFile() async {
let fm = FileManager.default let fm = FileManager.default
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try? fm.createDirectory(at: tmp, withIntermediateDirectories: true) try? fm.createDirectory(at: tmp, withIntermediateDirectories: true)
@@ -45,14 +42,13 @@ struct VerificationHistoryStoreTests {
let store = VerificationHistoryStore(url: url) let store = VerificationHistoryStore(url: url)
do { do {
_ = try await store.load() _ = try await store.load()
Issue.record("load() should throw on invalid JSON") XCTFail("load() should throw on invalid JSON")
} catch { } catch {
#expect(fm.fileExists(atPath: url.path)) XCTAssertTrue(fm.fileExists(atPath: url.path))
} }
} }
@Test("Append loads existing records first") func testAppendLoadsExisting() async throws {
func appendLoadsExisting() async throws {
let fm = FileManager.default let fm = FileManager.default
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try fm.createDirectory(at: tmp, withIntermediateDirectories: true) try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
@@ -89,13 +85,12 @@ struct VerificationHistoryStoreTests {
_ = try await store2.append(new) _ = try await store2.append(new)
let all = await store2.all() let all = await store2.all()
#expect(all.count == 2) XCTAssertEqual(all.count, 2)
#expect(all.contains { $0.id == "vr-existing" }) XCTAssertTrue(all.contains { $0.id == "vr-existing" })
#expect(all.contains { $0.id == "vr-new" }) XCTAssertTrue(all.contains { $0.id == "vr-new" })
} }
@Test("Append does not overwrite an unparseable file") func testAppendPreservesUnparseableFile() async {
func appendPreservesUnparseableFile() async {
let fm = FileManager.default let fm = FileManager.default
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try? fm.createDirectory(at: tmp, withIntermediateDirectories: true) try? fm.createDirectory(at: tmp, withIntermediateDirectories: true)
@@ -119,20 +114,68 @@ struct VerificationHistoryStoreTests {
do { do {
_ = try await store.append(record) _ = try await store.append(record)
Issue.record("append() should propagate the load error") XCTFail("append() should propagate the load error")
} catch { } catch {
#expect(fm.fileExists(atPath: url.path)) XCTAssertTrue(fm.fileExists(atPath: url.path))
if let data = try? Data(contentsOf: url), if let data = try? Data(contentsOf: url),
let contents = String(data: data, encoding: .utf8) { let contents = String(data: data, encoding: .utf8) {
#expect(contents == badJSON) XCTAssertEqual(contents, badJSON)
} else { } else {
Issue.record("Could not read preserved file") XCTFail("Could not read preserved file")
} }
} }
} }
@Test("CSV export quoting") func testClearPreservesUnparseableFile() async {
func csvQuoting() 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 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 fm = FileManager.default
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try fm.createDirectory(at: tmp, withIntermediateDirectories: true) try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
@@ -153,7 +196,7 @@ struct VerificationHistoryStoreTests {
_ = try await store.append(record) _ = try await store.append(record)
let csv = await store.exportCSV() let csv = await store.exportCSV()
#expect(csv.contains("\"a,b\"")) XCTAssertTrue(csv.contains("\"a,b\""))
#expect(csv.contains("\"\"quoted\"\"")) XCTAssertTrue(csv.contains("\"\"quoted\"\""))
} }
} }
@@ -1,13 +1,12 @@
import Testing
import Foundation import Foundation
import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@testable import ICCery @testable import ICCery
/// Issue #29 `CAL_` basename must be restored on relaunch and on any /// Issue #29 `CAL_` basename must be restored on relaunch and on any
/// attempt to navigate to a non-calibration stage that would use it. /// attempt to navigate to a non-calibration stage that would use it.
@Suite("WizardCalibrationSession")
@MainActor @MainActor
struct WizardCalibrationSessionTests { final class WizardCalibrationSessionTests: XCTestCase {
private func tempURL() -> URL { private func tempURL() -> URL {
FileManager.default.temporaryDirectory FileManager.default.temporaryDirectory
@@ -24,8 +23,7 @@ struct WizardCalibrationSessionTests {
return url return url
} }
@Test("Persist and restore calibrationOriginalBasename across a relaunch") func testRelaunchRestoresOriginal() throws {
func relaunchRestoresOriginal() throws {
let url = tempURL() let url = tempURL()
let store = WizardStateStore(fileURL: url) let store = WizardStateStore(fileURL: url)
var saved = WizardState( var saved = WizardState(
@@ -39,14 +37,13 @@ struct WizardCalibrationSessionTests {
let model = WizardViewModel(stateStore: store) let model = WizardViewModel(stateStore: store)
#expect(model.basename == "DemoTarget") XCTAssertEqual(model.basename, "DemoTarget")
#expect(model.calibrationOriginalBasename == "") XCTAssertEqual(model.calibrationOriginalBasename, "")
#expect(model.sessionMode == .profile) XCTAssertEqual(model.sessionMode, .profile)
#expect(model.stage == .generate) XCTAssertEqual(model.stage, .generate)
} }
@Test("go(to: .buildProfile) while basename is CAL_ refuses and restores the original") func testGoToBuildProfileRefusesAndRestores() throws {
func goToBuildProfileRefusesAndRestores() throws {
let dir = try tempDir() let dir = try tempDir()
let url = tempURL() let url = tempURL()
let store = WizardStateStore(fileURL: url) let store = WizardStateStore(fileURL: url)
@@ -60,14 +57,13 @@ struct WizardCalibrationSessionTests {
model.go(to: .buildProfile) model.go(to: .buildProfile)
#expect(model.basename == "DemoTarget") XCTAssertEqual(model.basename, "DemoTarget")
#expect(model.calibrationOriginalBasename == "") XCTAssertEqual(model.calibrationOriginalBasename, "")
#expect(model.sessionMode == .profile) XCTAssertEqual(model.sessionMode, .profile)
#expect(model.stage == .calibrate) XCTAssertEqual(model.stage, .calibrate)
} }
@Test("go(to: .layOutPrint) while basename is CAL_ stays in calibration") func testGoToLayoutStaysCal() throws {
func goToLayoutStaysCal() throws {
let dir = try tempDir() let dir = try tempDir()
let url = tempURL() let url = tempURL()
let store = WizardStateStore(fileURL: url) let store = WizardStateStore(fileURL: url)
@@ -81,9 +77,9 @@ struct WizardCalibrationSessionTests {
model.go(to: .layOutPrint) model.go(to: .layOutPrint)
#expect(model.basename == "CAL_DemoTarget") XCTAssertEqual(model.basename, "CAL_DemoTarget")
#expect(model.calibrationOriginalBasename == "DemoTarget") XCTAssertEqual(model.calibrationOriginalBasename, "DemoTarget")
#expect(model.sessionMode == .calibration) XCTAssertEqual(model.sessionMode, .calibration)
#expect(model.stage == .layOutPrint) XCTAssertEqual(model.stage, .layOutPrint)
} }
} }
+55 -46
View File
@@ -1,5 +1,5 @@
import Testing
import Foundation import Foundation
import XCTest
@testable import ICCeryCore @testable import ICCeryCore
private func artefacts( private func artefacts(
@@ -16,77 +16,75 @@ private func artefacts(
return a return a
} }
@Suite("WizardGating matrix") final class WizardGatingTests: XCTestCase {
struct WizardGatingTests {
@Test func emptyProjectOnlyStage1() { func testEmptyProjectOnlyStage1() {
let a = artefacts() let a = artefacts()
#expect(WizardGating.isUnlocked(.generate, artefacts: a)) XCTAssertTrue(WizardGating.isUnlocked(.generate, artefacts: a))
#expect(WizardGating.isUnlocked(.calibrate, artefacts: a)) XCTAssertTrue(WizardGating.isUnlocked(.calibrate, artefacts: a))
for s in [WizardStage.layOutPrint, .measure, .buildProfile, .verifyInstall] { 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) let a = artefacts(ti1: true)
#expect(WizardGating.isUnlocked(.layOutPrint, artefacts: a)) XCTAssertTrue(WizardGating.isUnlocked(.layOutPrint, artefacts: a))
#expect(!WizardGating.isUnlocked(.measure, artefacts: a)) XCTAssertFalse(WizardGating.isUnlocked(.measure, artefacts: a))
#expect(!WizardGating.isUnlocked(.buildProfile, artefacts: a)) XCTAssertFalse(WizardGating.isUnlocked(.buildProfile, artefacts: a))
#expect(!WizardGating.isUnlocked(.verifyInstall, artefacts: a)) XCTAssertFalse(WizardGating.isUnlocked(.verifyInstall, artefacts: a))
} }
@Test func stage3NeedsTi1AndTi2() { func testStage3NeedsTi1AndTi2() {
#expect(!WizardGating.isUnlocked(.measure, artefacts: artefacts(ti2: true))) XCTAssertFalse(WizardGating.isUnlocked(.measure, artefacts: artefacts(ti2: true)))
#expect(WizardGating.isUnlocked(.measure, artefacts: artefacts(ti1: true, 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. // #109/#110: .ti2 alone must never unlock Stage 4.
let a = artefacts(ti1: true, ti2: true) let a = artefacts(ti1: true, ti2: true)
#expect(!WizardGating.isUnlocked(.buildProfile, artefacts: a)) XCTAssertFalse(WizardGating.isUnlocked(.buildProfile, artefacts: a))
#expect(WizardGating.isUnlocked(.buildProfile, artefacts: artefacts(ti3: true))) XCTAssertTrue(WizardGating.isUnlocked(.buildProfile, artefacts: artefacts(ti3: true)))
} }
@Test func stage5NeedsTi3AndProfile() { func testStage5NeedsTi3AndProfile() {
#expect(!WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(ti3: true))) XCTAssertFalse(WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(ti3: true)))
#expect(!WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(profile: true))) XCTAssertFalse(WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(profile: true)))
#expect(WizardGating.isUnlocked( XCTAssertTrue(WizardGating.isUnlocked(
.verifyInstall, artefacts: artefacts(ti3: true, profile: true) .verifyInstall, artefacts: artefacts(ti3: true, profile: true)
)) ))
} }
@Test func forwardGatedBackwardFree() { func testForwardGatedBackwardFree() {
let a = artefacts() 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. // 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. // 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. // 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() { func testDeepestUnlocked() {
#expect(WizardGating.deepestUnlocked(artefacts: artefacts()) == .generate) XCTAssertEqual(WizardGating.deepestUnlocked(artefacts: artefacts()), .generate)
#expect(WizardGating.deepestUnlocked( XCTAssertEqual(WizardGating.deepestUnlocked(
artefacts: artefacts(ti1: true, ti2: true) artefacts: artefacts(ti1: true, ti2: true)
) == .measure) ), .measure)
#expect(WizardGating.deepestUnlocked( XCTAssertEqual(WizardGating.deepestUnlocked(
artefacts: artefacts(ti3: true, profile: true) artefacts: artefacts(ti3: true, profile: true)
) == .verifyInstall) ), .verifyInstall)
} }
} }
@Suite("WizardStateStore") final class WizardStateStoreTests: XCTestCase {
struct WizardStateStoreTests {
private func tempURL() -> URL { private func tempURL() -> URL {
FileManager.default.temporaryDirectory FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-wiz-\(UUID().uuidString)") .appendingPathComponent("iccery-wiz-\(UUID().uuidString)")
.appendingPathComponent("wizard_state.json") .appendingPathComponent("wizard_state.json")
} }
@Test func roundTrip() throws { func testRoundTrip() throws {
let url = tempURL() let url = tempURL()
let store = WizardStateStore(fileURL: url) let store = WizardStateStore(fileURL: url)
var s = WizardState() var s = WizardState()
@@ -97,32 +95,43 @@ struct WizardStateStoreTests {
s.profileBasename = "imported" s.profileBasename = "imported"
s.calibrationOriginalBasename = "pre-cal" s.calibrationOriginalBasename = "pre-cal"
try store.save(s) try store.save(s)
#expect(store.load() == s) XCTAssertEqual(store.load(), s)
} }
@Test func missingFileDefaults() { func testMissingFileDefaults() {
let s = WizardStateStore(fileURL: tempURL()).load() let s = WizardStateStore(fileURL: tempURL()).load()
#expect(s == .default) XCTAssertEqual(s, .default)
#expect(s.stage == .generate) XCTAssertEqual(s.stage, .generate)
#expect(s.sessionMode == .profile) XCTAssertEqual(s.sessionMode, .profile)
} }
@Test func corruptStageFallsBackToGenerate() throws { func testCorruptStageFallsBackToGenerate() throws {
let url = tempURL() let url = tempURL()
try FileManager.default.createDirectory( try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true at: url.deletingLastPathComponent(), withIntermediateDirectories: true
) )
try #"{"current_stage": 99, "basename": "", "cwd": "", "session_mode": "profile"}"# try #"{"current_stage": 99, "basename": "", "cwd": "", "session_mode": "profile"}"#
.write(to: url, atomically: true, encoding: .utf8) .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) var s = WizardState(sessionMode: .calibration)
let data = try JSONEncoder().encode(s) let data = try JSONEncoder().encode(s)
let decoded = try JSONDecoder().decode(WizardState.self, from: data) let decoded = try JSONDecoder().decode(WizardState.self, from: data)
#expect(decoded.sessionMode == .calibration) XCTAssertEqual(decoded.sessionMode, .calibration)
s.sessionMode = .profile 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)) 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. /// Fixture-backed targen run creates .ti1 and unlocks Stage 2.
func testTargenFixtureUnlocksStage2() throws { func testTargenFixtureUnlocksStage2() throws {
launchApp() launchApp()
@@ -146,6 +146,8 @@ final class Milestone3UITests: XCTestCase {
XCTAssertTrue(notice.waitForExistence(timeout: 10)) XCTAssertTrue(notice.waitForExistence(timeout: 10))
XCTAssertTrue((notice.value as? String ?? "") XCTAssertTrue((notice.value as? String ?? "")
.contains("cancelled")) .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 /// Preferences OK captured options are replayed verbatim in the
@@ -214,6 +216,8 @@ final class Milestone3UITests: XCTestCase {
let notice = app.staticTexts.containing(predicate).firstMatch let notice = app.staticTexts.containing(predicate).firstMatch
XCTAssertTrue(notice.waitForExistence(timeout: 10)) XCTAssertTrue(notice.waitForExistence(timeout: 10))
XCTAssertTrue(notice.label.contains("Print failed")) 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). /// wizardState.printerName records the queue used for spooling (#95).
@@ -140,4 +140,57 @@ final class Milestone4UITests: XCTestCase {
} }
XCTAssertTrue(FileManager.default.fileExists(atPath: ti3.path)) 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)'" "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 "ICCERY_TEST_WORKDIR": testWorkDir.path
] ]
app.launch() app.launch()
app.activate()
} }
override func tearDown() async throws { override func tearDown() async throws {
@@ -77,4 +78,60 @@ final class Milestone6CalibrationUITests: XCTestCase {
let layout = app.buttons["btnCreateLayout"] let layout = app.buttons["btnCreateLayout"]
XCTAssertTrue(layout.waitForExistence(timeout: 25)) 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
View File
@@ -2,7 +2,7 @@ name: ICCery
options: options:
bundleIdPrefix: com.gronod bundleIdPrefix: com.gronod
deploymentTarget: deploymentTarget:
macOS: "14.0" macOS: "12.0"
groupSortPosition: top groupSortPosition: top
packages: packages:
@@ -13,7 +13,7 @@ targets:
ICCery: ICCery:
type: application type: application
platform: macOS platform: macOS
deploymentTarget: "14.0" deploymentTarget: "12.0"
sources: sources:
- path: Sources/ICCery - path: Sources/ICCery
- path: Resources - path: Resources
@@ -46,7 +46,7 @@ targets:
PRODUCT_BUNDLE_PACKAGE_TYPE: APPL PRODUCT_BUNDLE_PACKAGE_TYPE: APPL
GENERATE_INFOPLIST_FILE: YES GENERATE_INFOPLIST_FILE: YES
INFOPLIST_KEY_CFBundleDisplayName: ICCery INFOPLIST_KEY_CFBundleDisplayName: ICCery
INFOPLIST_KEY_LSMinimumSystemVersion: "14.0" INFOPLIST_KEY_LSMinimumSystemVersion: "12.0"
INFOPLIST_KEY_NSPrincipalClass: NSApplication INFOPLIST_KEY_NSPrincipalClass: NSApplication
INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 Gronod. AGPLv3." INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 Gronod. AGPLv3."
MARKETING_VERSION: "2.0.0" MARKETING_VERSION: "2.0.0"
@@ -58,15 +58,15 @@ targets:
ENABLE_APP_SANDBOX: NO ENABLE_APP_SANDBOX: NO
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
SWIFT_VERSION: "6.0" SWIFT_VERSION: "5.0"
SWIFT_STRICT_CONCURRENCY: complete OTHER_SWIFT_FLAGS: ["$(inherited)", "-strict-concurrency=minimal"]
MACOSX_DEPLOYMENT_TARGET: "14.0" MACOSX_DEPLOYMENT_TARGET: "12.0"
ARCHS: "$(ARCHS_STANDARD)" ARCHS: "$(ARCHS_STANDARD)"
ICCeryCoreTests: ICCeryCoreTests:
type: bundle.unit-test type: bundle.unit-test
platform: macOS platform: macOS
deploymentTarget: "14.0" deploymentTarget: "12.0"
sources: sources:
- path: Tests/ICCeryCoreTests - path: Tests/ICCeryCoreTests
dependencies: dependencies:
@@ -79,13 +79,13 @@ targets:
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/ICCery.app/Contents/MacOS/ICCery" TEST_HOST: "$(BUILT_PRODUCTS_DIR)/ICCery.app/Contents/MacOS/ICCery"
GENERATE_INFOPLIST_FILE: YES GENERATE_INFOPLIST_FILE: YES
CODE_SIGN_IDENTITY: "-" CODE_SIGN_IDENTITY: "-"
SWIFT_VERSION: "6.0" SWIFT_VERSION: "5.0"
MACOSX_DEPLOYMENT_TARGET: "14.0" MACOSX_DEPLOYMENT_TARGET: "12.0"
ICCeryUITests: ICCeryUITests:
type: bundle.ui-testing type: bundle.ui-testing
platform: macOS platform: macOS
deploymentTarget: "14.0" deploymentTarget: "12.0"
sources: sources:
- path: Tests/ICCeryUITests - path: Tests/ICCeryUITests
dependencies: dependencies:
@@ -95,8 +95,8 @@ targets:
TEST_TARGET_NAME: ICCery TEST_TARGET_NAME: ICCery
GENERATE_INFOPLIST_FILE: YES GENERATE_INFOPLIST_FILE: YES
CODE_SIGN_IDENTITY: "-" CODE_SIGN_IDENTITY: "-"
SWIFT_VERSION: "6.0" SWIFT_VERSION: "5.0"
MACOSX_DEPLOYMENT_TARGET: "14.0" MACOSX_DEPLOYMENT_TARGET: "12.0"
schemes: schemes:
ICCery: ICCery:
+103
View File
@@ -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"