Compare commits

..
Author SHA1 Message Date
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 398d9b4756 feat(gamut): second-profile compare and click-inspect (#147)
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-13 10:23:25 +01:00
gronod 538ef1e05f Merge pull request 'feat(spotread): spot-read console for live Lab (#148)' (#152) from feat/148-spot-read into milestone/m10-studio 2026-09-13 01:04:45 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> d40932f4cf feat(spotread): spot-read console for live Lab (#148)
Refs #148

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-13 01:03:57 +01:00
gronod 1931da8448 Merge pull request 'feat(media): media recipe library bound to presets (#146)' (#151) from feat/146-media-library into milestone/m10-studio 2026-09-12 23:00:21 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> f896f60d1a feat(media): media recipe library bound to presets (#146)
Refs #146

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-12 22:59:35 +01:00
gronod 2f305c481e Merge pull request 'docs(m10): record studio-workflow milestone tracking' (#150) from chore/m10-tracking into milestone/m10-studio 2026-09-12 22:18:55 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 4249067345 docs(m10): record studio-workflow milestone tracking
Refs #146

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-12 22:18:21 +01:00
gronod 800c980137 Merge pull request 'Milestone/m9 monterey' (#145) from milestone/m9-monterey into develop
macOS CI / build-and-test (push) Successful in 18m50s
macOS CI / package (push) Successful in 3m22s
Reviewed-on: #145
2026-09-12 18:43:44 +01:00
gronod 67fb3a9b37 Merge pull request 'test(m9): collapse ArgyllRunner waits under ICCERY_UI_TESTING' (#144) from feat/141-ui-test-timing into milestone/m9-monterey
macOS CI / build-and-test (push) Successful in 19m52s
macOS CI / package (push) Successful in 3m12s
macOS CI / build-and-test (pull_request) Successful in 18m23s
macOS CI / package (pull_request) Skipped
2026-09-12 17:00:47 +01:00
gronod eb1503aba9 Merge pull request 'test(m9): replace full-timeout negative waits with dwell assertion' (#143) from feat/140-ui-test-absence-waits into milestone/m9-monterey 2026-09-12 17:00:40 +01:00
gronod db47a975f0 Merge pull request 'ci(m9): skip probe test in full UI suite leg' (#142) from feat/139-skip-probe-rerun into milestone/m9-monterey 2026-09-12 17:00:32 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 990c49a436 test(m9): collapse ArgyllRunner waits under ICCERY_UI_TESTING (#141)
Add a DEBUG-gated testAwareDelay helper (same ICCERY_UI_TESTING=1
convention as AppPaths.testRoot) and apply it to the ensureNotRunning
100ms poll and the XY chartread 500ms pre-kill wait. Under UI tests the
intervals shrink ~10x; release builds compile the branch out entirely,
so production timing is unchanged. No current UI test exercises XY
cancel, so the 500ms site is future-proofing at zero risk today.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-12 16:55:56 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 60972a0704 test(m9): replace full-timeout negative waits with dwell assertion (#140)
Two XCTAssertFalse(el.waitForExistence(timeout: 3)) sites in
Milestone2UITests always burned the full 3s on the expected-absent
path. Add assertAbsent(_:dwell:) (0.5s dwell + .exists) and use it at
both sites — same coverage, ~6s back per suite run.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-12 16:53:30 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> aa32cc45d3 ci(m9): skip probe test in full UI suite leg (#139)
The attach probe already runs testAboutDialogShowsVersionAndBuildDate;
the full-suite leg reran it for ~30s of zero extra coverage. Add
-skip-testing so it only runs in the probe leg.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-12 16:50:57 +01:00
gronod 17c47af9f1 ci(m9): build tests for host arch only; reserve universal for release packaging
macOS CI / build-and-test (push) Successful in 20m59s
macOS CI / package (push) Successful in 3m24s
The CI runner only executes tests on its own architecture, so building
both slices wastes time. Override ARCHS="$(uname -m)" in the workflow
and update all docs to match. Universal builds remain required for
release verification and dmgbuild packaging.
2026-09-12 15:59:20 +01:00
gronod 5bea928042 Merge pull request 'test(m9): query presetSelect as PopUpButton to avoid duplicate match (#137)' (#138) from feat/137-presetselect-dup into milestone/m9-monterey
macOS CI / build-and-test (push) Successful in 19m6s
macOS CI / package (push) Successful in 3m4s
2026-09-12 14:33:41 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 5c8973de73 test(m9): query presetSelect as PopUpButton to avoid duplicate match (#137)
On the runner SDK the Picker label inherits the control accessibility
identifier, so the .any query for presetSelect matches both a
StaticText and the PopUpButton and .frame resolution fails.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-12 14:33:26 +01:00
gronod 29ecc79585 Merge pull request 'test(m9): drive scroller + state-driven triggers in UI suite (#135)' (#136) from feat/135-uitest-scroller-trigger into milestone/m9-monterey
macOS CI / build-and-test (push) Failing after 20m7s
macOS CI / package (push) Skipped
2026-09-12 14:04:30 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 7c5a5e7798 test(m9): drive scroller + state-driven triggers in UI suite (#135)
btnPrintPage-0 reports an in-viewport a11y frame but stays unhittable
and scroll-wheel events on the LazyVGrid are inert; drag the vertical
AXScrollBar thumb to perform a real scroll before clicking.

The handheld chartread test could drop a btnTrigger click racing the
strip state transition; poll btnTrigger until btnDoneRead appears
instead of assuming exactly two strips.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-12 14:03:59 +01:00
gronod 607a4e0df4 Merge pull request 'test(m9): fall back to coordinate click for per-page print button (#132)' (#134) from feat/132-perpage-scroll2 into milestone/m9-monterey
macOS CI / build-and-test (push) Failing after 20m28s
macOS CI / package (push) Skipped
2026-09-12 13:30:19 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 67199877a2 test(m9): fall back to coordinate click for per-page print button (#132)
Explicit stage-2 scrolls are synthesized but never move the LazyVGrid
cell's a11y frame (run 32235). If still not hittable, dump the a11y
tree and click the reported point directly — the lp argv assertion
verifies the click landed.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-12 13:30:04 +01:00
gronod 9a6da4648f Merge pull request 'test(m9): scroll stage-2 explicitly before per-page print click (#132)' (#133) from feat/132-perpage-scroll into milestone/m9-monterey
macOS CI / build-and-test (push) Failing after 22m43s
macOS CI / package (push) Skipped
2026-09-12 13:02:38 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> b5250591ea test(m9): scroll stage-2 explicitly before per-page print click (#132)
btnPrintPage-0 sits at the window's bottom edge on the 1280x800 runner;
XCUI's automatic scroll-to-visible runs but never moves the frame
(run 32209). Scroll the stage-2 ScrollView manually until hittable.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-12 13:02:24 +01:00
gronod c1ec07f726 Merge pull request 'fix(m9): revert sidebar container a11y id; measure presetSelect frame (#130)' (#131) from feat/130-sidebar-a11y-revert into milestone/m9-monterey
macOS CI / build-and-test (push) Failing after 18m59s
macOS CI / package (push) Skipped
2026-09-12 12:37:39 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> a975f1a4e4 fix(m9): revert sidebar container a11y id; measure presetSelect frame (#130)
On SDK 13.1 the 'sidebar' identifier on the root VStack propagates to
every child, erasing openSettingsBtn/openAboutBtn/btnToggleAllHelp
(a11y dump, run 32201). Revert it; the test now compares presetSelect
frame before/after toggling help overlays.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-12 12:37:21 +01:00
gronod 7c2282a7dd Merge pull request 'test(m9): dump a11y hierarchy to log on UI probe failure (#128)' (#129) from feat/128-axtree-dump into milestone/m9-monterey
macOS CI / build-and-test (push) Failing after 3m43s
macOS CI / package (push) Skipped
2026-09-12 12:31:00 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> caeafb22be test(m9): dump a11y hierarchy to log on UI probe failure (#128)
The probe fails on the runner with an apparently empty accessibility
tree; the xcresult artifact is not retrievable via the API. Print
app.debugDescription + window count so the tree lands in the job log.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-12 12:30:44 +01:00
gronod aac4f10d23 Merge pull request 'ci(m9): upload UI-test xcresult on failure for a11y triage (#126)' (#127) from feat/126-xcresult-on-failure into milestone/m9-monterey
macOS CI / build-and-test (push) Failing after 4m8s
macOS CI / package (push) Skipped
2026-09-12 12:23:12 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> b7ac4f92ed ci(m9): upload UI-test xcresult on failure for a11y triage (#126)
UI failures on the self-hosted runner are hard to triage from stdout
alone — the xcresult already contains the a11y hierarchy snapshot and
screenshots. Upload Logs/Test when build-and-test fails.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-12 12:22:53 +01:00
gronod 3244efd366 Merge pull request 'ci(m9): accept Xcode >= 14 in toolchain assert (#123)' (#124) from feat/123-xcode-ge-14 into milestone/m9-monterey
macOS CI / build-and-test (push) Failing after 5m0s
macOS CI / package (push) Skipped
2026-09-12 11:39:47 +01:00
gronod 7a05da3a35 Merge pull request 'fix(m9): stable 'sidebar' a11y identifier for AboutHelp UI test (#122)' (#125) from feat/122-sidebar-a11y-id into milestone/m9-monterey 2026-09-12 11:39:35 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 5134e82e63 fix(m9): stable 'sidebar' a11y identifier for AboutHelp UI test (#122)
Xcode 14.2 / SDK 13.1 does not surface the sidebar root as an AXGroup,
so the groups-containing query in testHelpOverlaysDoNotChangeSidebarHeight
found nothing (run 32029). Give the sidebar root a stable identifier and
query it directly.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-12 11:39:16 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 7fd93247cb ci(m9): accept Xcode >= 14 in toolchain assert (#123)
The grep on "Xcode 14." pinned CI to the 14.x series, blocking builds
on macOS 14 / Xcode 16 runners. Parse the major version and require
>= 14 instead.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-12 11:31:43 +01:00
gronod a0fcede454 Merge pull request 'fix(m9): Debug entitlements disable library validation for test host (#119)' (#121) from feat/119-debug-entitlements into milestone/m9-monterey
macOS CI / build-and-test (push) Failing after 21m3s
macOS CI / package (push) Skipped
2026-09-12 09:42:16 +01:00
gronod 97eafd11fb fix(m9): Debug entitlements file to disable library validation for test host (#119) 2026-09-12 09:41:11 +01:00
gronod ed7487c2b6 Merge pull request 'fix(m9): ad-hoc sign embedded ICCeryCore framework so test host launches (#119)' (#120) from feat/119-debug-disable-lib-validation into milestone/m9-monterey
macOS CI / build-and-test (push) Failing after 3m15s
macOS CI / package (push) Skipped
Reviewed-on: #120
2026-09-12 09:30:54 +01:00
gronod a2e3f11e70 fix(m9): ad-hoc sign embedded ICCeryCore framework so test host launches (#119) 2026-09-12 09:29:40 +01:00
gronod c3c9bbc5ba Merge pull request 'fix(m9): link ICCeryCore statically so the app launches standalone (#117)' (#118) from feat/117-static-iccerycore into milestone/m9-monterey
macOS CI / build-and-test (push) Failing after 26s
macOS CI / package (push) Skipped
Reviewed-on: #118
2026-09-12 09:17:22 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> b5683aa36a fix(m9): link ICCeryCore statically so the app launches standalone (#117)
When the app and test bundles share the local ICCeryCore package, Xcode
builds it as a dynamic package-product framework that is not embedded
in ICCery.app — the app crashes in dyld at launch when run outside the
test host (macOS 12 VM crash, run 31985 products). Declare the product
.static so each consumer links it directly.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-12 09:14:54 +01:00
gronod 707455dcfb Merge pull request 'test(m9): replace MainActor.assertIsolated for Swift 5.7 (#115)' (#116) from feat/115-assertisolated-swift57 into milestone/m9-monterey
macOS CI / build-and-test (push) Failing after 2m53s
macOS CI / package (push) Skipped
Reviewed-on: #116
2026-09-12 09:07:06 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 17ee5d6717 test(m9): replace MainActor.assertIsolated for Swift 5.7 (#115)
MainActor.assertIsolated() requires Swift 5.9 (Xcode 15); the macOS 12
CI runner compiles the test bundle with Swift 5.7 and failed run 31985.
Assert Thread.isMainThread instead — it checks the same contract (the
coalesced log sink hops back to the main actor).

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-12 09:06:45 +01:00
gronod 2295bcdbae Merge pull request 'fix(m9): clear printTask without defer for Swift 5.7 (#113)' (#114) from feat/113-printtask-defer-swift57 into milestone/m9-monterey
macOS CI / build-and-test (push) Failing after 1m59s
macOS CI / package (push) Skipped
Reviewed-on: #114
2026-09-12 09:01:37 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> c9bff50a1a fix(m9): clear printTask without defer for Swift 5.7 (#113)
Swift 5.7 on the macOS 12 CI runner (Xcode 14.2) rejects mutation of
actor-isolated properties inside defer bodies, failing run 31978 at
PrintSessionViewModel.swift:113 and :144. Clear printTask explicitly at
each exit point of the print task closures.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-12 09:00:48 +01:00
gronod 1b3d3dd821 Merge pull request 'fix(m9): keep Stage4View under Swift 5.7 ViewBuilder 10-child limit (#111)' (#112) from feat/111-stage4-viewbuilder-limit into milestone/m9-monterey
macOS CI / build-and-test (push) Failing after 1m28s
macOS CI / package (push) Skipped
Reviewed-on: #112
2026-09-12 08:55:57 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 55dee0f0cd fix(m9): keep Stage4View under Swift 5.7 ViewBuilder 10-child limit (#111)
The formSection VStack had 11 direct children; Xcode 14.2's ViewBuilder
only provides buildBlock overloads up to 10 arguments, so the CI build
on the macOS 12 runner failed at line 144 with "extra argument in call"
(run 31958). Nest the calibration toggle and conditional file row in an
inner VStack with identical alignment and spacing.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-12 08:55:12 +01:00
gronod 45fd2b988f Merge pull request 'fix(m9): pin prebuilt xcodegen for macOS 12 CI (#109)' (#110) from feat/109-ci-xcodegen-pin into milestone/m9-monterey
macOS CI / build-and-test (push) Failing after 2m34s
macOS CI / package (push) Skipped
Reviewed-on: #110
2026-09-12 08:35:51 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 7c6ad1e6ce fix(m9): pin prebuilt xcodegen for macOS 12 CI (#109)
brew install xcodegen resolves to a formula requiring Xcode 15.3, which
cannot be installed on the macOS 12 runner (Xcode 14.2), failing run
31390 before project generation. Install a pinned prebuilt XcodeGen
2.38.0 release via scripts/ensure-host-tools.sh, shared by the
build-and-test and package jobs (package-release.sh also runs xcodegen).

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-12 08:34:37 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 9857ddb4d0 test(m9): retry dropped calibration generate tap in UI test
macOS CI / build-and-test (push) Failing after 48s
macOS CI / package (push) Skipped
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-11 22:38:30 +01:00
gronod 7a6b82816b Merge pull request 'feat(m9): state management and view demotions for macOS 12 (Slice 4)' (#108) from feat/m9-slice4-state-and-views into milestone/m9-monterey 2026-09-11 21:57:55 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 32d2184d2e feat(m9): migrate state to Combine and demote SwiftUI views for macOS 12
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-11 21:51:18 +01:00
gronod a76120d9f7 Merge pull request 'test(m9): complete migration of stateful and async suites to XCTest (Slice 3)' (#107) from feat/m9-slice3-xctest-async-stateful into milestone/m9-monterey 2026-09-11 21:34:15 +01:00
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
138 changed files with 9987 additions and 2599 deletions
+146 -5
View File
@@ -14,29 +14,60 @@ jobs:
build-and-test:
# Prefer a self-hosted Mac runner if your Gitea has one. If not,
# macos-14 works for this pipeline.
runs-on: macos-14
runs-on: macos-12
env:
DERIVED: build/DerivedData-test
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Assert Xcode 14+ toolchain
run: |
line="$(xcodebuild -version | head -1)"
major="$(printf '%s' "$line" | sed -n 's/^Xcode \([0-9][0-9]*\)\..*/\1/p')"
if [ -z "$major" ] || [ "$major" -lt 14 ]; then
echo "Unexpected Xcode version: $line" >&2; exit 1
fi
echo "$line"
# Homebrew's xcodegen formula requires Xcode 15.3, which cannot be
# installed on macOS 12 (#109). The script installs a pinned
# prebuilt release instead.
- name: Ensure host tools
run: scripts/ensure-host-tools.sh
- name: Generate Xcode project
run: xcodegen generate --spec project.yml
- name: Build for testing (universal)
# Tests only ever run on the runner's own architecture; build
# just that slice. Packaging (scripts/package-release.sh) still
# produces the universal Release binary.
- name: Build for testing (host arch)
run: |
xcodebuild build-for-testing \
-scheme ICCery \
-destination 'platform=macOS' \
-derivedDataPath "$DERIVED" \
-configuration Debug \
ARCHS='arm64 x86_64' \
ARCHS="$(uname -m)" \
ONLY_ACTIVE_ARCH=NO \
CODE_SIGNING_ALLOWED=YES \
CODE_SIGN_IDENTITY='-'
- name: Test (universal)
# Xcode embeds the shared ICCeryCore package framework into the app
# and the test bundle without signing it. Ad-hoc hosts still require
# every loaded dylib to carry a cdhash — dyld killed the test host at
# launch (run 31992) — so sign every embedded copy once the build is
# done (embed steps run after any build script phase) (#119).
- name: Sign package product frameworks
run: |
find "$DERIVED/Build/Products/Debug" -depth -name '*_PackageProduct.framework' -print0 \
| while IFS= read -r -d '' fw; do
echo "signing $fw"
codesign --force --sign - --timestamp=none "$fw"
done
- name: Test unit (ICCeryCoreTests)
run: |
XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)"
if [ -z "$XCTESTRUN" ] || [ ! -f "$XCTESTRUN" ]; then
@@ -46,17 +77,116 @@ jobs:
echo "xctestrun: $XCTESTRUN"
xcodebuild test-without-building \
-xctestrun "$XCTESTRUN" \
-only-testing:ICCeryCoreTests \
-destination 'platform=macOS' \
-derivedDataPath "$DERIVED"
# UI tests need macOS Automation / Accessibility permission on the
# runner. The self-hosted Mac intermittently times out enabling
# that mode (run 29700) or launches the app into
# `.runningBackground` without ever activating it (run 29804).
# Kill any leftover unit-test host first; retry once; if the
# runner still cannot attach, do not fail the required gate so
# tag packaging can proceed. Real XCTest assertion failures
# still fail the job.
- name: Test UI (ICCeryUITests)
run: |
set -o pipefail
XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)"
LOG="$DERIVED/ui-test.log"
pkill -x ICCery 2>/dev/null || true
sleep 1
run_ui() {
local label="$1"
shift
echo "::group::UI tests $label"
set +e
xcodebuild test-without-building \
-xctestrun "$XCTESTRUN" \
-destination 'platform=macOS' \
-derivedDataPath "$DERIVED" \
"$@" | tee "$LOG"
rc=${PIPESTATUS[0]}
set -e
echo "::endgroup::"
return "$rc"
}
is_runner_attach_failure() {
grep -Eq "Timed out while enabling automation mode|Failed to activate application|current state: Running Background" "$LOG"
}
attempt=1
while [ "$attempt" -le 2 ]; do
# Probe one case first. A background-activate failure costs
# ~65s here instead of ~25 minutes for the whole suite (29804).
if ! run_ui "probe attempt $attempt" \
-only-testing:ICCeryUITests/AboutHelpUITests/testAboutDialogShowsVersionAndBuildDate; then
if is_runner_attach_failure; then
echo "warning: UI runner could not attach/activate the app (attempt $attempt)"
pkill -x ICCery 2>/dev/null || true
attempt=$((attempt + 1))
sleep 8
continue
fi
echo "error: UI probe failed with a real test error" >&2
exit 1
fi
if run_ui "full suite attempt $attempt" -only-testing:ICCeryUITests \
-skip-testing:ICCeryUITests/AboutHelpUITests/testAboutDialogShowsVersionAndBuildDate; 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
# XCTest stores the a11y hierarchy snapshot and screenshots in the
# xcresult on failure — upload it so UI failures can be triaged
# without access to the runner (#126).
- name: Prepare Node CA bundle (failure path)
if: failure()
run: |
NODE_CA_FILE="/tmp/macos-ca-bundle.pem"
security find-certificate -a -p \
/System/Library/Keychains/SystemRootCertificates.keychain \
/Library/Keychains/System.keychain \
> "$NODE_CA_FILE" 2>/dev/null || true
if [ ! -s "$NODE_CA_FILE" ] && [ -f /etc/ssl/cert.pem ]; then
cp /etc/ssl/cert.pem "$NODE_CA_FILE"
fi
- name: Upload UI test xcresult
if: failure()
uses: actions/upload-artifact@v3
env:
NODE_EXTRA_CA_CERTS: /tmp/macos-ca-bundle.pem
with:
name: ui-test-xcresult
path: build/DerivedData-test/Logs/Test
package:
needs: build-and-test
runs-on: macos-14
runs-on: macos-12
if: github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/tags/v')
steps:
- name: Checkout
uses: actions/checkout@v4
# scripts/package-release.sh runs `xcodegen generate` and dmgbuild;
# see build-and-test for why brew is not used on macOS 12 (#109).
- name: Ensure host tools
run: scripts/ensure-host-tools.sh
- name: Package release
run: scripts/package-release.sh
env:
@@ -85,3 +215,14 @@ jobs:
with:
name: iccery-dmg
path: ICCery-*.dmg
- name: Attach DMG to Gitea release
if: startsWith(github.ref, 'refs/tags/v')
run: scripts/attach-release-asset.sh
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITHUB_TOKEN: ${{ github.token }}
GITEA_SERVER_URL: ${{ github.server_url }}
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_REF_NAME: ${{ github.ref_name }}
GITHUB_SHA: ${{ github.sha }}
+2 -1
View File
@@ -27,4 +27,5 @@ ICCery.xcodeproj/
Release/
notarization/
build/
docs/megaplans/
docs/megaplans/*
docs/megaplans
+2 -1
View File
@@ -45,9 +45,10 @@ PRs via Gitea MCP. Every issue/PR: `Project/ICCery-v2` + `Feature/*` or `Bug/*`
## Verify
```
xcodebuild test -scheme ICCery -destination 'platform=macOS' ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
xcodebuild test -scheme ICCery -destination 'platform=macOS' ARCHS="$(uname -m)"
codesign -dvv <sidecar>
```
Universal (`ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO`) is still required for release verification / packaging.
## Private ColorSync SPI
2-arg `(PMPrintSession, CFStringRef) -> OSStatus`. Never pass integer `1`.
+8 -2
View File
@@ -1,6 +1,6 @@
# BUILD-PLAN.md — ICCery v2 Mac
Spec snapshot: `docs/`. Source of tickets: Gitea milestones M1M6 + Later.
Spec snapshot: `docs/`. Source of tickets: Gitea milestones M1M6 + M10 (id 32) + Later.
## Sprint rule
Do not start milestone N+1 implementation until milestone N **CI/mock gate** is green.
@@ -16,9 +16,15 @@ 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` |
| M5 | Profile / verify / install | 2327 | colprof → `.icc`; profcheck parse; atomic history; install into temp dir | Full `.ti1``.icc`; profile visible in ColorSync Utility |
| M6 | Gamut, Stage 0, CGATS, release | 2832 | `.gam` fixtures; cal argv; CGATS round-trip; signed sidecars; dmgbuild | Stage 0 on a real printer; gamut of a real profile |
| M7 | Deduplicate & consolidate | 7986 | Shared runner loop; JSONFileStore; preset↔config maps; Notice/log helper; ProcessManager factory; PrintSession VM; identity + colour-type cleanup | N/A |
| M8 | Deduplicate & consolidate | 7986 | (already shipped on `develop`) | N/A |
| M9 | macOS 12 / Xcode 14.2 retarget | (milestone/m9-monterey, PR #145) | XCTest + ObservableObject + macos-12 CI | N/A |
| M10 | Studio workflow | 146149 | Media library + spot-read + gamut compare + project file unit/UI smoke | Real printer+paper+.cal; live spot-read; two `.gam`; reopen `.icceryproj` |
| Later | Quartz / TargetPrint | 16 | `ICCeryPrintKit` standalone + seam test | 1:1 on paper vs TIFF |
Issue **16 is not an M3 or M6 exit gate.**
M8 and M9 merged to `develop` via PR #104 / #145; M10 starts from `800c980`.
Issue **16 is not an M3, M6, or M10 exit gate.**
## Branch taxonomy
+2 -2
View File
@@ -1,9 +1,9 @@
// swift-tools-version: 6.0
// swift-tools-version: 5.7
import PackageDescription
let package = Package(
name: "ICCeryCore",
platforms: [.macOS(.v14)],
platforms: [.macOS(.v12)],
products: [
.library(name: "ICCeryCore", targets: ["ICCeryCore"]),
],
@@ -0,0 +1,35 @@
import Foundation
/// Tiny argv helpers. Each Argyll tool keeps its own `*Args` enum
/// `-d` / `-u` / `-r` still mean different things per binary.
public enum ArgsBuilder {
/// `["-f", value]` when `value` is non-nil.
public static func option(_ flag: String, _ value: String?) -> [String] {
guard let value else { return [] }
return [flag, value]
}
/// `["-f", trimmed]` when trimmed is non-empty.
public static func optionIfNonEmpty(_ flag: String, _ value: String?) -> [String] {
guard let raw = value?.trimmingCharacters(in: .whitespacesAndNewlines),
!raw.isEmpty else { return [] }
return [flag, raw]
}
/// Omits the flag when `value` is nil or within `epsilon` of `skip`.
public static func optionUnlessApprox(
_ flag: String,
_ value: Double?,
skip: Double,
epsilon: Double = 0.001,
format: String = "%.2f"
) -> [String] {
guard let value, abs(value - skip) >= epsilon else { return [] }
return [flag, String(format: format, locale: Locale(identifier: "en_US_POSIX"), value)]
}
/// Bare flag when `when` is true.
public static func flag(_ flag: String, when: Bool) -> [String] {
when ? [flag] : []
}
}
@@ -2,43 +2,41 @@ import Foundation
/// Errors from `ArgyllRunner` executions.
public enum ArgyllRunnerError: LocalizedError, Equatable, Sendable {
case processFailed(code: Int32, logs: [String])
case toolFailed(tool: String, code: Int32, logs: [String])
case missingArtefact(String)
case malformedManifest(String)
case instrumentDetectionFailed(String)
case chartreadFailed(String)
case averageFailed(String)
case colprofFailed(String)
case printcalFailed(String)
case applycalFailed(String)
case iccgamutFailed(String)
case profcheckFailed(String)
case profcheckUnparseable
public var errorDescription: String? {
switch self {
case .processFailed(let code, _):
return "Process exited with code \(code)"
case .toolFailed(let tool, let code, let logs):
let detail = logs.last.flatMap { $0.isEmpty ? nil : $0 }
?? "exited with code \(code)"
switch tool {
case "chartread":
return "Chartread failed: \(detail)"
case "average":
return "Averaging failed: \(detail)"
case "colprof":
return "Profile creation failed: \(detail)"
case "printcal":
return "Calibration curve computation failed: \(detail)"
case "applycal":
return "Apply calibration failed: \(detail)"
case "iccgamut":
return "Gamut extraction failed: \(detail)"
case "profcheck":
return "Profile verification failed: \(detail)"
default:
return "Process exited with code \(code)"
}
case .missingArtefact(let path):
return "Expected output file was not created: \(path)"
case .malformedManifest(let reason):
return "Failed to parse printtarg manifest: \(reason)"
case .instrumentDetectionFailed(let reason):
return "Instrument detection failed: \(reason)"
case .chartreadFailed(let reason):
return "Chartread failed: \(reason)"
case .averageFailed(let reason):
return "Averaging failed: \(reason)"
case .colprofFailed(let reason):
return "Profile creation failed: \(reason)"
case .printcalFailed(let reason):
return "Calibration curve computation failed: \(reason)"
case .applycalFailed(let reason):
return "Apply calibration failed: \(reason)"
case .iccgamutFailed(let reason):
return "Gamut extraction failed: \(reason)"
case .profcheckFailed(let reason):
return "Profile verification failed: \(reason)"
case .profcheckUnparseable:
return "Profile verification produced unparseable output"
}
@@ -76,6 +74,48 @@ public struct ArgyllRunner: Sendable {
self.binaryResolver = binaryResolver
}
// MARK: - Shared streaming loop (issue #79)
private func runStreamingTool(
name: String,
id: String,
arguments: [String],
workingDirectory: URL?,
flushPartialLines: Bool = false,
onLogBatch: (@Sendable ([String]) -> Void)? = nil
) async throws -> CollectedRun {
let binaryURL = binaryResolver.resolve(name)
await ensureNotRunning(id: id)
let events = processManager.events()
try await processManager.runStreaming(
id: id,
binary: binaryURL,
arguments: arguments,
workingDirectory: workingDirectory
)
let run = await collect(
id: id,
events: events,
onLogBatch: onLogBatch,
flushPartialLines: flushPartialLines
)
guard run.exitCode == 0 else {
throw ArgyllRunnerError.toolFailed(
tool: name,
code: run.exitCode ?? -1,
logs: run.lines
)
}
return run
}
private func requireArtefact(_ url: URL) throws -> URL {
guard FileManager.default.fileExists(atPath: url.path) else {
throw ArgyllRunnerError.missingArtefact(url.path)
}
return url
}
// MARK: - targen (Stage 1)
/// Runs `targen` streaming, collecting logs and verifying `.ti1`
@@ -87,27 +127,16 @@ public struct ArgyllRunner: Sendable {
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
let args = try TargenArgs.build(config: config)
let binaryURL = binaryResolver.resolve("targen")
let processId = ProcessID.targen(cleanBasename)
await ensureNotRunning(id: processId)
let events = processManager.events()
try await processManager.runStreaming(
_ = try await runStreamingTool(
name: "targen",
id: processId,
binary: binaryURL,
arguments: args,
workingDirectory: cwd
workingDirectory: cwd,
onLogBatch: onLogBatch
)
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
guard run.exitCode == 0 else {
throw ArgyllRunnerError.processFailed(code: run.exitCode ?? -1, logs: run.lines)
}
let ti1URL = cwd.appendingPathComponent("\(cleanBasename).ti1")
guard FileManager.default.fileExists(atPath: ti1URL.path) else {
throw ArgyllRunnerError.missingArtefact(ti1URL.path)
}
return ti1URL
return try requireArtefact(ti1URL)
}
// MARK: - printtarg (Stage 2)
@@ -122,26 +151,15 @@ public struct ArgyllRunner: Sendable {
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
let args = try PrinttargArgs.build(config: config)
let binaryURL = binaryResolver.resolve("printtarg")
let processId = ProcessID.printtarg(cleanBasename)
await ensureNotRunning(id: processId)
let events = processManager.events()
try await processManager.runStreaming(
let run = try await runStreamingTool(
name: "printtarg",
id: processId,
binary: binaryURL,
arguments: args,
workingDirectory: cwd
workingDirectory: cwd,
onLogBatch: onLogBatch
)
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
guard run.exitCode == 0 else {
throw ArgyllRunnerError.processFailed(code: run.exitCode ?? -1, logs: run.lines)
}
let ti2URL = cwd.appendingPathComponent("\(cleanBasename).ti2")
guard FileManager.default.fileExists(atPath: ti2URL.path) else {
throw ArgyllRunnerError.missingArtefact(ti2URL.path)
}
let ti2URL = try requireArtefact(cwd.appendingPathComponent("\(cleanBasename).ti2"))
let manifest: PrinttargManifest
do {
@@ -174,6 +192,18 @@ public struct ArgyllRunner: Sendable {
// MARK: - Shared collection
/// DEBUG-only fast path: under `ICCERY_UI_TESTING=1` polling/wait
/// intervals shrink ~10x same env convention as `AppPaths.testRoot`.
/// Release builds compile the branch out entirely; no static state.
private static func testAwareDelay(_ nanos: UInt64) -> UInt64 {
#if DEBUG
if ProcessInfo.processInfo.environment["ICCERY_UI_TESTING"] == "1" {
return nanos / 10
}
#endif
return nanos
}
/// Cancels any previous child with the same id and waits for it to
/// finalize, so `runStreaming` / `runCaptured` never sees a
/// `duplicateID` from a leftover process (#50, #52).
@@ -182,7 +212,7 @@ public struct ArgyllRunner: Sendable {
await processManager.kill(id: id)
var attempts = 0
while await processManager.isRunning(id), attempts < 30 {
try? await Task.sleep(for: .milliseconds(100))
try? await Task.sleep(nanoseconds: Self.testAwareDelay(100_000_000))
attempts += 1
}
}
@@ -225,7 +255,7 @@ public struct ArgyllRunner: Sendable {
if flushPartialLines {
dotFlushTask = Task { [processManager] in
while !Task.isCancelled {
try? await Task.sleep(for: .milliseconds(500))
try? await Task.sleep(nanoseconds: 500_000_000)
if Task.isCancelled { break }
await processManager.flushPartialLine(id: processId)
}
@@ -339,28 +369,16 @@ public struct ArgyllRunner: Sendable {
) async throws -> URL {
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
let args = try AverageArgs.build(config: config)
let binaryURL = binaryResolver.resolve("average")
let processId = ProcessID.average(config.basename)
await ensureNotRunning(id: processId)
let events = processManager.events()
try await processManager.runStreaming(
_ = try await runStreamingTool(
name: "average",
id: processId,
binary: binaryURL,
arguments: args,
workingDirectory: cwd
workingDirectory: cwd,
onLogBatch: onLogBatch
)
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
guard run.exitCode == 0 else {
throw ArgyllRunnerError.averageFailed("average exited with code \(run.exitCode ?? -1)")
}
let canonical = cwd.appendingPathComponent("\(config.basename).ti3")
guard FileManager.default.fileExists(atPath: canonical.path) else {
throw ArgyllRunnerError.missingArtefact(canonical.path)
}
return canonical
return try requireArtefact(canonical)
}
// MARK: - colprof (Stage 4)
@@ -374,29 +392,15 @@ public struct ArgyllRunner: Sendable {
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
let args = try ColprofArgs.build(config: config)
let binaryURL = binaryResolver.resolve("colprof")
let processId = ProcessID.colprof(cleanBasename)
await ensureNotRunning(id: processId)
let events = processManager.events()
try await processManager.runStreaming(
_ = try await runStreamingTool(
name: "colprof",
id: processId,
binary: binaryURL,
arguments: args,
workingDirectory: cwd
workingDirectory: cwd,
flushPartialLines: true,
onLogBatch: onLogBatch
)
let run = await collect(
id: processId,
events: events,
onLogBatch: onLogBatch,
flushPartialLines: true
)
guard run.exitCode == 0 else {
throw ArgyllRunnerError.colprofFailed(
"colprof exited with code \(run.exitCode ?? -1)"
)
}
// Argyll may produce `.icm` on Windows, but on macOS we expect `.icc`.
// `resolveProfile` checks `.icm` first, then `.icc`, matching #69.
@@ -456,16 +460,20 @@ public struct ArgyllRunner: Sendable {
if Task.isCancelled {
throw CancellationError()
}
throw ArgyllRunnerError.applycalFailed(
result.stderr.isEmpty
throw ArgyllRunnerError.toolFailed(
tool: "applycal",
code: result.exitCode,
logs: [result.stderr.isEmpty
? "applycal exited with code \(result.exitCode)"
: result.stderr
: result.stderr]
)
}
guard fm.fileExists(atPath: tmpURL.path) else {
throw ArgyllRunnerError.applycalFailed(
"applycal did not create temp profile"
throw ArgyllRunnerError.toolFailed(
tool: "applycal",
code: -1,
logs: ["applycal did not create temp profile"]
)
}
@@ -473,8 +481,10 @@ public struct ArgyllRunner: Sendable {
let size = attrs?[.size] as? UInt64 ?? 0
guard size >= 128 else {
try? fm.removeItem(at: tmpURL)
throw ArgyllRunnerError.applycalFailed(
"calibrated profile is too small (\(size) bytes)"
throw ArgyllRunnerError.toolFailed(
tool: "applycal",
code: -1,
logs: ["calibrated profile is too small (\(size) bytes)"]
)
}
@@ -486,7 +496,11 @@ public struct ArgyllRunner: Sendable {
}
} catch {
try? fm.removeItem(at: tmpURL)
throw ArgyllRunnerError.applycalFailed(error.localizedDescription)
throw ArgyllRunnerError.toolFailed(
tool: "applycal",
code: -1,
logs: [error.localizedDescription]
)
}
return inputURL
@@ -503,30 +517,16 @@ public struct ArgyllRunner: Sendable {
let cwd = profileURL.deletingLastPathComponent()
let stem = profileURL.deletingPathExtension().lastPathComponent
let args = try IccgamutArgs.build(config: config)
let binaryURL = binaryResolver.resolve("iccgamut")
let processId = ProcessID.iccgamut(stem: stem)
await ensureNotRunning(id: processId)
let events = processManager.events()
try await processManager.runStreaming(
_ = try await runStreamingTool(
name: "iccgamut",
id: processId,
binary: binaryURL,
arguments: args,
workingDirectory: cwd
workingDirectory: cwd,
onLogBatch: onLogBatch
)
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
guard run.exitCode == 0 else {
throw ArgyllRunnerError.iccgamutFailed(
"iccgamut exited with code \(run.exitCode ?? -1)"
)
}
let gamURL = cwd.appendingPathComponent("\(stem).gam")
guard FileManager.default.fileExists(atPath: gamURL.path) else {
throw ArgyllRunnerError.missingArtefact(gamURL.path)
}
return gamURL
return try requireArtefact(gamURL)
}
// MARK: - profcheck (Stage 5 verification)
@@ -539,30 +539,18 @@ public struct ArgyllRunner: Sendable {
let cwd = config.ti3URL.deletingLastPathComponent()
let ti3Path = config.ti3URL.path
let iccURL = Self.resolveProfileForVerification(config.iccURL)
let iccURL = ArtefactProbe.resolveProfile(config.iccURL)
let config = ProfcheckConfig(ti3URL: config.ti3URL, iccURL: iccURL)
let args = try ProfcheckArgs.build(config: config)
let binaryURL = binaryResolver.resolve("profcheck")
let processId = ProcessID.profcheck(ti3Path: ti3Path)
await ensureNotRunning(id: processId)
let events = processManager.events()
try await processManager.runStreaming(
let run = try await runStreamingTool(
name: "profcheck",
id: processId,
binary: binaryURL,
arguments: args,
workingDirectory: cwd
workingDirectory: cwd,
onLogBatch: onLogBatch
)
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
guard run.exitCode == 0 else {
throw ArgyllRunnerError.profcheckFailed(
run.stderr.isEmpty
? "profcheck exited with code \(run.exitCode ?? -1)"
: run.stderr
)
}
let output = (run.stdout + "\n" + run.stderr).trimmingCharacters(in: .whitespacesAndNewlines)
let report = ProfcheckParser.parse(output)
@@ -572,15 +560,6 @@ public struct ArgyllRunner: Sendable {
return report
}
private static func resolveProfileForVerification(_ url: URL) -> URL {
let fm = FileManager.default
if fm.fileExists(atPath: url.path) { return url }
let alt = url.pathExtension.lowercased() == "icc"
? url.deletingPathExtension().appendingPathExtension("icm")
: url.deletingPathExtension().appendingPathExtension("icc")
return fm.fileExists(atPath: alt.path) ? alt : url
}
// MARK: - chartread (Stage 3 interactive)
/// Runs `chartread` and returns an `AsyncStream` of typed events.
@@ -596,7 +575,7 @@ public struct ArgyllRunner: Sendable {
cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
} catch {
return AsyncStream { continuation in
continuation.yield(.failed(ArgyllRunnerError.chartreadFailed(error.localizedDescription)))
continuation.yield(.failed(ArgyllRunnerError.toolFailed(tool: "chartread", code: -1, logs: [error.localizedDescription])))
continuation.finish()
}
}
@@ -606,7 +585,7 @@ public struct ArgyllRunner: Sendable {
args = try ChartreadArgs.build(config: config)
} catch {
return AsyncStream { continuation in
continuation.yield(.failed(ArgyllRunnerError.chartreadFailed(error.localizedDescription)))
continuation.yield(.failed(ArgyllRunnerError.toolFailed(tool: "chartread", code: -1, logs: [error.localizedDescription])))
continuation.finish()
}
}
@@ -625,7 +604,7 @@ public struct ArgyllRunner: Sendable {
await processManager.setPreKillHook(id: processId) { [processManager] in
if isXY {
try? await processManager.sendStdin(id: processId, bytes: ChartreadInput.quit.bytes)
try? await Task.sleep(for: .milliseconds(500))
try? await Task.sleep(nanoseconds: Self.testAwareDelay(500_000_000))
}
}
@@ -637,7 +616,7 @@ public struct ArgyllRunner: Sendable {
workingDirectory: cwd
)
} catch {
continuation.yield(.failed(ArgyllRunnerError.chartreadFailed(error.localizedDescription)))
continuation.yield(.failed(ArgyllRunnerError.toolFailed(tool: "chartread", code: -1, logs: [error.localizedDescription])))
continuation.finish()
return
}
@@ -724,7 +703,11 @@ public struct ArgyllRunner: Sendable {
continuation.yield(.failed(ArgyllRunnerError.missingArtefact(canonical.path)))
}
} else {
continuation.yield(.failed(ArgyllRunnerError.chartreadFailed("chartread exited with code \(exitCode ?? -1)")))
continuation.yield(.failed(ArgyllRunnerError.toolFailed(
tool: "chartread",
code: exitCode ?? -1,
logs: ["chartread exited with code \(exitCode ?? -1)"]
)))
}
continuation.finish()
}
@@ -759,6 +742,148 @@ public struct ArgyllRunner: Sendable {
}
}
// MARK: - spotread (spot-read console, issue #148)
/// Runs `spotread` and returns an `AsyncStream` of typed events.
///
/// Same subscribe-before-spawn shape as `runChartread`, but there is
/// no artefact: the stream ends with `.exit(code)`. The single-lease
/// process id is `ProcessID.spotread` never `chartread_{basename}`.
/// Missing sidecar surfaces as `.failed`; there is no `$PATH` or
/// `chartread` fallback (#116, R14/R21).
public func runSpotread(config: SpotReadConfig) -> AsyncStream<SpotReadEvent> {
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
let args = SpotReadArgs.build(config: config)
let binaryURL = binaryResolver.resolve("spotread")
let processId = ProcessID.spotread
let processManager = self.processManager
let isXY = config.isXY
let instrumentName = config.instrumentName
let instrumentPort = config.instrumentPort
return AsyncStream { continuation in
let task = Task {
await ensureNotRunning(id: processId)
let events = processManager.events()
// XY parking hook before any kill, same as chartread.
await processManager.setPreKillHook(id: processId) { [processManager] in
if isXY {
try? await processManager.sendStdin(id: processId, bytes: ChartreadInput.quit.bytes)
try? await Task.sleep(nanoseconds: Self.testAwareDelay(500_000_000))
}
}
guard binaryResolver.exists(binaryURL) else {
continuation.yield(.failed(ArgyllRunnerError.toolFailed(
tool: "spotread", code: -1,
logs: ["spotread sidecar missing — run fetch-argyll"])))
continuation.finish()
return
}
do {
try await processManager.runStreaming(
id: processId,
binary: binaryURL,
arguments: args,
workingDirectory: cwd
)
} catch {
continuation.yield(.failed(ArgyllRunnerError.toolFailed(
tool: "spotread", code: -1, logs: [error.localizedDescription])))
continuation.finish()
return
}
var state: ChartreadState = .idle
var pendingLogs: [String] = []
var lastFlush = Date()
var exitCode: Int32?
func flushLogs() {
guard !pendingLogs.isEmpty else { return }
let batch = pendingLogs
pendingLogs.removeAll(keepingCapacity: true)
continuation.yield(.log(batch))
}
for await event in events {
guard event.id == processId else { continue }
switch event {
case .stdout(_, let line):
if let parsed = SpotReadParser.parse(line: line) {
continuation.yield(.sample(SpotReadSample(
lab: parsed.lab,
xyz: parsed.xyz,
instrumentName: instrumentName,
port: instrumentPort,
rawLine: line
)))
}
let classified = SpotReadClassifier.classify(
line: line, previousState: state)
if classified.state != state
|| classified.requestedWarningKey != nil {
state = classified.state
continuation.yield(.prompt(classified))
}
pendingLogs.append(line)
case .stderr(_, let line):
pendingLogs.append(line)
case .jsonRow:
// spotread is never run with `-u`.
break
case .error(_, let message):
pendingLogs.append("Error: \(message)")
case .exit(_, let code):
exitCode = code
}
if exitCode == nil,
pendingLogs.count >= 20 || Date().timeIntervalSince(lastFlush) >= 0.1 {
flushLogs()
lastFlush = Date()
}
if exitCode != nil {
flushLogs()
break
}
}
continuation.yield(.exit(exitCode ?? -1))
continuation.finish()
}
continuation.onTermination = { _ in
task.cancel()
Task {
await processManager.kill(id: processId)
}
}
}
}
/// Send input bytes to the running `spotread` child. Reuses
/// `ChartreadInput` the stdin protocol is identical.
public func sendSpotreadInput(_ input: ChartreadInput) async throws {
try await processManager.sendStdin(id: ProcessID.spotread, bytes: input.bytes)
}
/// Terminate a running `spotread` child. The XY park (`q\n` +
/// ~500 ms) runs in the pre-kill hook registered by `runSpotread`.
public func cancelSpotread() {
Task {
await processManager.kill(id: ProcessID.spotread)
}
}
// MARK: - Stage 0 calibration
/// Generates a calibration wedge `.ti1`.
@@ -768,30 +893,19 @@ public struct ArgyllRunner: Sendable {
) async throws -> URL {
let args = try CalibrationTargenArgs.build(config: config)
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
let calBasename = config.basename.hasPrefix("CAL_") ? config.basename : "CAL_\(config.basename)"
let cleanBasename = try PathSecurity.sanitizeBasename(calBasename)
let binaryURL = binaryResolver.resolve("targen")
let processId = ProcessID.targen(cleanBasename)
await ensureNotRunning(id: processId)
let events = processManager.events()
try await processManager.runStreaming(
id: processId,
binary: binaryURL,
arguments: args,
workingDirectory: cwd
let cleanBasename = try PathSecurity.sanitizeBasename(
CalibrationIdentity.prefix(config.basename)
)
let processId = ProcessID.targen(cleanBasename)
_ = try await runStreamingTool(
name: "targen",
id: processId,
arguments: args,
workingDirectory: cwd,
onLogBatch: onLogBatch
)
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
guard run.exitCode == 0 else {
throw ArgyllRunnerError.processFailed(code: run.exitCode ?? -1, logs: run.lines)
}
let ti1URL = cwd.appendingPathComponent("\(cleanBasename).ti1")
guard FileManager.default.fileExists(atPath: ti1URL.path) else {
throw ArgyllRunnerError.missingArtefact(ti1URL.path)
}
return ti1URL
return try requireArtefact(ti1URL)
}
/// Computes a `.cal` curve from a measured `CAL_*.ti3`.
@@ -805,7 +919,7 @@ public struct ArgyllRunner: Sendable {
let args = try PrintcalArgs.build(config: config)
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
let binaryURL = binaryResolver.resolve("printcal")
let calBasename = config.ti3Basename.hasPrefix("CAL_") ? config.ti3Basename : "CAL_\(config.ti3Basename)"
let calBasename = CalibrationIdentity.prefix(config.ti3Basename)
let processId = ProcessID.printcal(calBasename)
await ensureNotRunning(id: processId)
@@ -821,10 +935,12 @@ public struct ArgyllRunner: Sendable {
}
guard result.exitCode == 0 else {
throw ArgyllRunnerError.printcalFailed(
result.stderr.isEmpty
throw ArgyllRunnerError.toolFailed(
tool: "printcal",
code: result.exitCode,
logs: [result.stderr.isEmpty
? "printcal exited with code \(result.exitCode)"
: result.stderr
: result.stderr]
)
}
@@ -854,6 +970,20 @@ public enum ChartreadEvent: Sendable {
case failed(ArgyllRunnerError)
}
/// Events emitted by a running `spotread` session (issue #148).
public enum SpotReadEvent: Sendable {
/// Classified prompt / state update (reuses `ChartreadState`).
case prompt(ChartreadClassifyResult)
/// A parsed `Result is ` sample line.
case sample(SpotReadSample)
/// A batched log chunk (stdout + stderr lines).
case log([String])
/// Process exited with the given code.
case exit(Int32)
/// Failure (missing sidecar, spawn error).
case failed(ArgyllRunnerError)
}
/// Exact bytes sent to `chartread` stdin.
public enum ChartreadInput: Sendable {
case trigger // " \n"
@@ -56,23 +56,19 @@ public enum PrinttargArgs {
}
args.append(contentsOf: ["-R", "\(config.customSeed)"])
case .raster:
args.append("-r")
args.append(contentsOf: ArgsBuilder.flag("-r", when: true))
}
if let label = config.label?.trimmingCharacters(in: .whitespacesAndNewlines),
!label.isEmpty {
args.append(contentsOf: ["-d", label])
}
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-d", config.label))
guard (72...600).contains(config.dpi) else {
throw PrinttargArgError.invalidDPI(config.dpi)
}
args.append(contentsOf: [config.bitDepth.flag, "\(config.dpi)"])
if !cleanBasename.hasPrefix("CAL_"),
let cal = config.calibrationFile?.trimmingCharacters(in: .whitespacesAndNewlines),
!cal.isEmpty {
args.append(contentsOf: [config.calibrationEmbedOnly ? "-I" : "-K", cal])
if !CalibrationIdentity.isCalibration(cleanBasename) {
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty(
config.calibrationEmbedOnly ? "-I" : "-K", config.calibrationFile))
}
args.append(cleanBasename)
@@ -0,0 +1,28 @@
import Foundation
/// Pure argv builder for Argyll's `spotread` tool (issue #148).
public enum SpotReadArgs {
/// Builds `spotread` argv per the Gronod fork protocol.
///
/// - Always `-v -e` (paper / reflective; never display `-d`).
/// - `-c N` is emitted only for `selectedPort != nil` and `N > 1`
/// (Auto and port 1 omit it, #111).
/// - `-Y l` (letter L) is emitted only when `enableLEDs` is `true` (#204).
/// - Never `-u`: the v2.0 `-u` policy covers printtarg + chartread +
/// profcheck only.
/// - No basename `spotread` writes no artefact.
public static func build(config: SpotReadConfig) -> [String] {
var args: [String] = ["-v", "-e"]
if let port = config.selectedPort, port > 1 {
args.append(contentsOf: ["-c", "\(port)"])
}
if config.enableLEDs {
args.append(contentsOf: ["-Y", "l"])
}
return args
}
}
@@ -0,0 +1,37 @@
import Foundation
/// Configuration for a `spotread` invocation (issue #148).
///
/// `spotread` writes no artefact; `workingDirectory` is still required
/// for the spawn (#59 empty cwd is illegal).
public struct SpotReadConfig: Codable, Equatable, Sendable {
public var workingDirectory: URL?
/// Communication port for `spotread -c`.
/// `nil` means omit `-c` (Auto or port 1, #111). Never an array index.
public var selectedPort: Int?
/// Enable i1Pro 2 visual LEDs (`-Y l`, #204).
public var enableLEDs: Bool
/// Whether the selected instrument is an XY table controls the
/// `q\n` + ~500 ms park before kill on cancel.
public var isXY: Bool
/// Display name stamped onto each `SpotReadSample`.
public var instrumentName: String
/// Instrument port stamped onto each sample (nil for Auto).
public var instrumentPort: Int?
public init(
workingDirectory: URL? = nil,
selectedPort: Int? = nil,
enableLEDs: Bool = false,
isXY: Bool = false,
instrumentName: String = "",
instrumentPort: Int? = nil
) {
self.workingDirectory = workingDirectory
self.selectedPort = selectedPort
self.enableLEDs = enableLEDs
self.isXY = isXY
self.instrumentName = instrumentName
self.instrumentPort = instrumentPort
}
}
@@ -70,18 +70,12 @@ public enum TargenArgs {
if let n = config.neutralSteps, n > 0 {
args.append(contentsOf: ["-n", "\(n)"])
}
if let nConc = config.neutralConcentration, abs(nConc - 0.50) >= 0.001 {
args.append(contentsOf: ["-N", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), nConc)])
}
if let c = config.preconditioningProfile?.trimmingCharacters(in: .whitespacesAndNewlines), !c.isEmpty {
args.append(contentsOf: ["-c", c])
}
if config.ofpsHighQuality == true {
args.append("-G")
}
if let a = config.ofpsAdaptation {
args.append(contentsOf: ["-A", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), a)])
}
args.append(contentsOf: ArgsBuilder.optionUnlessApprox("-N", config.neutralConcentration, skip: 0.50))
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-c", config.preconditioningProfile))
args.append(contentsOf: ArgsBuilder.flag("-G", when: config.ofpsHighQuality == true))
args.append(contentsOf: ArgsBuilder.option("-A", config.ofpsAdaptation.map {
String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), $0)
}))
if let algFlag = config.fullSpreadAlgorithm?.flag {
args.append(algFlag)
}
@@ -91,11 +85,9 @@ public enum TargenArgs {
}
args.append(contentsOf: ["-l", "\(inkLimit)"])
}
if let v = config.darkEmphasis, abs(v - 1.0) >= 0.001 {
args.append(contentsOf: ["-V", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), v)])
}
if let p = config.devicePower, p > 0, abs(p - 1.0) >= 0.001 {
args.append(contentsOf: ["-p", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), p)])
args.append(contentsOf: ArgsBuilder.optionUnlessApprox("-V", config.darkEmphasis, skip: 1.0))
if let p = config.devicePower, p > 0 {
args.append(contentsOf: ArgsBuilder.optionUnlessApprox("-p", p, skip: 1.0))
}
args.append(cleanBasename)
@@ -72,8 +72,8 @@ public enum CGATSParser {
public static func parse(
_ contents: String,
sourceURL: URL? = nil
) throws(CGATSParseError) -> CGATSDataset {
guard !contents.isEmpty else { throw .emptyFile }
) throws -> CGATSDataset {
guard !contents.isEmpty else { throw CGATSParseError.emptyFile }
let ext = sourceURL?.pathExtension.lowercased() ?? ""
let isCSV = ext == "csv" || contents.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -101,10 +101,10 @@ public enum CGATSParser {
}
guard let formatStart, let formatEnd, formatEnd > formatStart + 1 else {
throw .missingBeginDataFormat
throw CGATSParseError.missingBeginDataFormat
}
guard let dataStart, let dataEnd, dataEnd > dataStart + 1 else {
throw .missingBeginData
throw CGATSParseError.missingBeginData
}
let rawFieldNames = splitFields(lines[formatStart + 1])
@@ -139,7 +139,7 @@ public enum CGATSParser {
let lineIndex = dataStart + offset
let rawRow = splitFields(lines[lineIndex])
guard rawRow.count == fieldNames.count else {
throw .incorrectArity(line: lineIndex + 1, expected: fieldNames.count, got: rawRow.count)
throw CGATSParseError.incorrectArity(line: lineIndex + 1, expected: fieldNames.count, got: rawRow.count)
}
var sample = RawSample(id: String(offset), lineIndex: lineIndex)
@@ -153,7 +153,7 @@ public enum CGATSParser {
groupMax[group, default: 0] = max(groupMax[group, default: 0], number)
}
} else if !cleaned.isEmpty {
throw .nonNumericValue(field: name, value: raw, line: lineIndex + 1)
throw CGATSParseError.nonNumericValue(field: name, value: raw, line: lineIndex + 1)
}
} else {
sample.strings[name] = raw
@@ -213,7 +213,7 @@ public enum CGATSParser {
private static func preprocess(
_ contents: String,
isCSV: Bool
) throws(CGATSParseError) -> (CGATSFormat, [String]) {
) throws -> (CGATSFormat, [String]) {
let allLines = contents.components(separatedBy: .newlines)
var lines = [String]()
@@ -239,7 +239,7 @@ public enum CGATSParser {
lines.append(line)
}
guard !lines.isEmpty else { throw .emptyFile }
guard !lines.isEmpty else { throw CGATSParseError.emptyFile }
// Wrap a bare CSV / ISO28178 file in the canonical CGATS block
// structure so the boundary-based parser below can handle it.
@@ -78,6 +78,21 @@ public enum ArtefactProbe {
return nil
}
/// Resolve an explicit profile URL, flipping `.icc` `.icm` when the
/// requested path is missing (#69 / issue #83). Any other extension
/// (`.mpp`, `.txt`, ) is returned unchanged never rewritten.
public static func resolveProfile(
_ url: URL,
fileManager: FileManager = .default
) -> URL {
if fileManager.fileExists(atPath: url.path) { return url }
let ext = url.pathExtension.lowercased()
guard ext == "icc" || ext == "icm" else { return url }
let alt = url.deletingPathExtension()
.appendingPathExtension(ext == "icc" ? "icm" : "icc")
return fileManager.fileExists(atPath: alt.path) ? alt : url
}
/// Default extension for a *new* profile on macOS (#69).
public static let defaultProfileExtension = "icc"
@@ -0,0 +1,85 @@
import Foundation
/// Policy when a JSON file exists but cannot be decoded.
public enum JSONCorruptPolicy: Sendable {
/// Return `defaultValue` and leave the file untouched.
case replaceWithDefault
/// Throw the decode error. Callers must not overwrite the file.
case throwCorrupt
}
/// Shared pretty-printed JSON file façade used by settings, wizard state,
/// and verification history.
public struct JSONFileStore<T: Codable & Sendable>: Sendable {
public let fileURL: URL
public let corrupt: JSONCorruptPolicy
private let defaultValue: @Sendable () -> T
private let encoder: JSONEncoder
private let decoder: JSONDecoder
public init(
fileURL: URL,
corrupt: JSONCorruptPolicy,
defaultValue: @escaping @Sendable () -> T,
dateEncoding: JSONEncoder.DateEncodingStrategy = .deferredToDate,
dateDecoding: JSONDecoder.DateDecodingStrategy = .deferredToDate
) {
self.fileURL = fileURL
self.corrupt = corrupt
self.defaultValue = defaultValue
self.encoder = JSONEncoder.icceryPretty(dateEncoding: dateEncoding)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = dateDecoding
self.decoder = decoder
}
/// Encodes `value` with the shared pretty / sorted-keys encoder.
public func encodePretty(_ value: T) throws -> Data {
try encoder.encode(value)
}
public func load() throws -> T {
let fm = FileManager.default
guard fm.fileExists(atPath: fileURL.path) else {
return defaultValue()
}
let data: Data
do {
data = try Data(contentsOf: fileURL)
} catch {
switch corrupt {
case .replaceWithDefault:
return defaultValue()
case .throwCorrupt:
throw error
}
}
do {
return try decoder.decode(T.self, from: data)
} catch {
switch corrupt {
case .replaceWithDefault:
return defaultValue()
case .throwCorrupt:
throw error
}
}
}
public func save(_ value: T) throws {
try AtomicFileWriter.write(try encodePretty(value), to: fileURL)
}
}
extension JSONEncoder {
/// Shared pretty-printed, sorted-keys encoder used by `JSONFileStore`
/// and preset export.
static func icceryPretty(
dateEncoding: JSONEncoder.DateEncodingStrategy = .deferredToDate
) -> JSONEncoder {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
encoder.dateEncodingStrategy = dateEncoding
return encoder
}
}
@@ -0,0 +1,64 @@
import Foundation
/// Approximate sRGB CIELab D50 conversion for the gamut inspect panel
/// (issue #147).
///
/// This is a fixed-matrix helper, **not** a colour-management module: it
/// never touches ICC profiles, ColorSync, or lcms. The UI labels its
/// output "approx. Lab, not ColorSync".
public enum ApproximateLab {
/// linear-sRGB XYZ (D65) matrix, IEC 61966-2-1.
private static let srgbToXYZ: [[Double]] = [
[0.4124, 0.3576, 0.1805],
[0.2126, 0.7152, 0.0722],
[0.0193, 0.1192, 0.9505],
]
/// 8-bit sRGB triple Lab D50 (approximate).
public static func srgb8ToLab(r: Int, g: Int, b: Int) -> LabColor {
srgbToLab(DisplayRGB(
r: Double(r) / 255.0,
g: Double(g) / 255.0,
b: Double(b) / 255.0))
}
/// 01 sRGB triple Lab D50 (approximate).
public static func srgbToLab(_ rgb: DisplayRGB) -> LabColor {
func linear(_ c: Double) -> Double {
c <= 0.04045 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4)
}
let v = [linear(rgb.r), linear(rgb.g), linear(rgb.b)]
// 01 XYZ D65 the 0100 scale `LabColorMath` works in.
let xyz65 = XYZColor(
x: (srgbToXYZ[0][0] * v[0] + srgbToXYZ[0][1] * v[1] + srgbToXYZ[0][2] * v[2]) * 100,
y: (srgbToXYZ[1][0] * v[0] + srgbToXYZ[1][1] * v[1] + srgbToXYZ[1][2] * v[2]) * 100,
z: (srgbToXYZ[2][0] * v[0] + srgbToXYZ[2][1] * v[1] + srgbToXYZ[2][2] * v[2]) * 100)
return LabColorMath.xyzToLab(adaptD65ToD50(xyz65))
}
/// Bradford D65 D50 chromatic adaptation the mirror of
/// `LabColorMath.adaptD50ToD65`.
private static func adaptD65ToD50(_ xyz: XYZColor) -> XYZColor {
let m = LabColorMath.bradford
let inv = LabColorMath.bradfordInv
let d65 = LabColorMath.d65White
let d50 = LabColorMath.d50White
let source = multiply(m, [xyz.x, xyz.y, xyz.z])
let srcWhite = multiply(m, [d65.X, d65.Y, d65.Z])
let dstWhite = multiply(m, [d50.X, d50.Y, d50.Z])
let scaled = [
source[0] * (dstWhite[0] / srcWhite[0]),
source[1] * (dstWhite[1] / srcWhite[1]),
source[2] * (dstWhite[2] / srcWhite[2]),
]
return XYZColor(
x: inv[0][0] * scaled[0] + inv[0][1] * scaled[1] + inv[0][2] * scaled[2],
y: inv[1][0] * scaled[0] + inv[1][1] * scaled[1] + inv[1][2] * scaled[2],
z: inv[2][0] * scaled[0] + inv[2][1] * scaled[1] + inv[2][2] * scaled[2])
}
private static func multiply(_ m: [[Double]], _ v: [Double]) -> [Double] {
m.map { row in zip(row, v).reduce(0) { $0 + $1.0 * $1.1 } }
}
}
@@ -0,0 +1,115 @@
import Foundation
import simd
/// Result of a point-in-gamut test (issue #147).
public enum GamutContainment: String, Sendable, Equatable {
/// The point lies inside the mesh volume.
case inside
/// The point lies outside the mesh volume.
case outside
/// The mesh has no faces to test against.
case unknown
}
/// Point-in-mesh containment and volume estimation for ``GamutMesh``.
///
/// Both tests run on the scene-space positions stored on
/// ``GamutVertex/position`` (`x = a*`, `y = L*`, `z = b*`), the same
/// mapping the SceneKit viewer uses. No colour management is involved.
public enum GamutGeometry {
/// Whether `lab` is inside `mesh`.
///
/// Ray-casts through the face list: an odd crossing count means the
/// point is inside a closed surface. A ray that grazes a vertex or
/// edge gives an ambiguous count, so the test retries with off-axis
/// directions before answering. Meshes without faces report
/// ``GamutContainment/unknown``.
public static func containment(of lab: LabColor, in mesh: GamutMesh) -> GamutContainment {
guard !mesh.faces.isEmpty else { return .unknown }
let origin = SIMD3<Double>(lab.a, lab.l, lab.b)
for direction in rayDirections {
if let inside = castRay(from: origin, direction: direction, mesh: mesh) {
return inside ? .inside : .outside
}
}
return .unknown
}
/// Approximate mesh volume in Lab-cubic units.
///
/// Sums signed tetrahedra from the vertex centroid to each face; for
/// a closed surface the magnitude equals the enclosed volume
/// regardless of face winding. Returns 0 for empty or face-less
/// meshes.
public static func volume(of mesh: GamutMesh) -> Double {
guard !mesh.faces.isEmpty, !mesh.vertices.isEmpty else { return 0 }
var centroid = SIMD3<Double>.zero
for vertex in mesh.vertices {
centroid += SIMD3<Double>(vertex.position)
}
centroid /= Double(mesh.vertices.count)
var sum = 0.0
let count = mesh.vertices.count
for face in mesh.faces {
guard Int(face.a) < count, Int(face.b) < count, Int(face.c) < count else {
continue
}
let a = SIMD3<Double>(mesh.vertices[Int(face.a)].position) - centroid
let b = SIMD3<Double>(mesh.vertices[Int(face.b)].position) - centroid
let c = SIMD3<Double>(mesh.vertices[Int(face.c)].position) - centroid
sum += simd_dot(a, simd_cross(b, c)) / 6.0
}
return abs(sum)
}
// MARK: - Ray casting
/// Primary +X ray, then off-axis retries for degenerate edge hits.
private static let rayDirections: [SIMD3<Double>] = [
SIMD3(1, 0, 0),
simd_normalize(SIMD3<Double>(0.71, 1.0, 0.53)),
simd_normalize(SIMD3<Double>(0.53, 0.71, 1.0)),
]
/// MöllerTrumbore crossing count. Returns `nil` when a crossing
/// lands on a triangle edge or vertex (ambiguous parity) so the
/// caller can retry with a different direction.
private static func castRay(
from origin: SIMD3<Double>,
direction dir: SIMD3<Double>,
mesh: GamutMesh
) -> Bool? {
let epsilon = 1e-9
var crossings = 0
let count = mesh.vertices.count
for face in mesh.faces {
guard Int(face.a) < count, Int(face.b) < count, Int(face.c) < count else {
continue
}
let va = SIMD3<Double>(mesh.vertices[Int(face.a)].position)
let vb = SIMD3<Double>(mesh.vertices[Int(face.b)].position)
let vc = SIMD3<Double>(mesh.vertices[Int(face.c)].position)
let e1 = vb - va
let e2 = vc - va
let p = simd_cross(dir, e2)
let det = simd_dot(e1, p)
if abs(det) < 1e-12 { continue } // ray parallel to face
let inv = 1.0 / det
let tvec = origin - va
let u = simd_dot(tvec, p) * inv
let q = simd_cross(tvec, e1)
let v = simd_dot(dir, q) * inv
let t = simd_dot(e2, q) * inv
guard t > epsilon else { continue }
if u < -epsilon || v < -epsilon || u + v > 1 + epsilon { continue }
// Crossing on an edge or vertex parity is ambiguous.
if u < epsilon || v < epsilon || u + v > 1 - epsilon { return nil }
crossings += 1
}
return crossings % 2 == 1
}
}
@@ -0,0 +1,39 @@
import Foundation
/// A parsed gamut mesh plus the display metadata the compare viewer
/// needs (issue #147).
///
/// `displayName` is user-derived (a file name); views must render it
/// through `Text` only (#114).
public struct NamedGamut: Sendable, Equatable, Identifiable {
/// What the layer is used for in the compare UI.
public enum Role: String, Sendable, Equatable {
/// Bundled reference space (sRGB). Cannot be removed, only hidden.
case reference
/// The workflow's own profile gamut.
case profileA
/// The user-added compare gamut. Replaced, never stacked.
case profileB
}
public var id: String
public var displayName: String
public var role: Role
public var mesh: GamutMesh
public var sourceURL: URL
public init(
id: String,
displayName: String,
role: Role,
mesh: GamutMesh,
sourceURL: URL
) {
self.id = id
self.displayName = displayName
self.role = role
self.mesh = mesh
self.sourceURL = sourceURL
}
}
@@ -0,0 +1,117 @@
import Foundation
/// Persistence for `MediaRecipe` entries (issue #146).
///
/// `media_library.json` is a sibling of `settings.json`, never a field
/// inside it. Writes are atomic via `JSONFileStore` `AtomicFileWriter`
/// (`.tmp` + rename, #213). A corrupt file throws on load/upsert and is
/// never overwritten the view model turns the throw into an empty
/// list plus a persistent warning banner.
public actor MediaLibraryStore {
/// Default cap.
public static let defaultCapacity = 200
/// Path to the JSON store.
public let url: URL
/// In-memory cache, kept in sync with disk.
private var recipes: [MediaRecipe] = []
/// Explicit load flag an empty file is still "loaded".
private var loaded = false
private let capacity: Int
private let fileStore: JSONFileStore<[MediaRecipe]>
public init(
url: URL = AppPaths.appDataDir.appendingPathComponent("media_library.json"),
capacity: Int = defaultCapacity
) {
self.url = url
self.capacity = capacity
self.fileStore = JSONFileStore(
fileURL: url,
corrupt: .throwCorrupt,
defaultValue: { [] },
dateEncoding: .iso8601,
dateDecoding: .iso8601
)
}
/// Loads recipes from disk. Returns the existing cache if already
/// loaded.
///
/// Throws when the file exists but cannot be parsed; the existing
/// file is never overwritten in that case and `loaded` stays false
/// so the next call re-reads.
public func load() throws -> [MediaRecipe] {
guard !loaded else { return recipes }
guard FileManager.default.fileExists(atPath: url.path) else {
loaded = true
return []
}
recipes = try fileStore.load()
loaded = true
return recipes
}
/// Returns all cached recipes.
public func all() -> [MediaRecipe] {
recipes
}
/// Inserts or replaces a recipe matched by `id`, then writes
/// atomically. Replacement preserves `created` and bumps `updated`;
/// inserts beyond `capacity` throw `.capacityReached` no silent
/// eviction.
///
/// Loads the existing library first and propagates any load error
/// so an unparseable file is never overwritten.
@discardableResult
public func upsert(_ recipe: MediaRecipe) throws -> [MediaRecipe] {
let validated = try recipe.validated()
try load()
var updated = recipes
if let index = updated.firstIndex(where: { $0.id == validated.id }) {
var existing = validated
existing.created = updated[index].created
existing.updated = Date()
updated[index] = existing
} else {
guard updated.count < capacity else {
throw MediaLibraryError.capacityReached(capacity)
}
updated.append(validated)
}
try fileStore.save(updated)
recipes = updated
return updated
}
/// Removes a recipe by id and writes atomically. Returns false when
/// no recipe with that id exists.
@discardableResult
public func delete(id: String) throws -> Bool {
try load()
let before = recipes.count
let updated = recipes.filter { $0.id != id }
guard updated.count != before else { return false }
try fileStore.save(updated)
recipes = updated
return true
}
public enum MediaLibraryError: LocalizedError, Equatable {
case capacityReached(Int)
public var errorDescription: String? {
switch self {
case .capacityReached(let cap):
return "Media library is full (\(cap)). Delete a recipe first."
}
}
}
}
@@ -0,0 +1,146 @@
import Foundation
/// A media recipe: a named binding of CUPS queue + paper + ink set +
/// optional `.cal` to a `ProfilingPreset` (issue #146, docs/22 §Media
/// library).
///
/// snake_case keys match the v1 JSON schema so `media_library.json`
/// stays import/export compatible. Identity + binding fields are
/// required; every other field is optional-defaulted. Unknown keys are
/// ignored on decode; missing required fields fail the whole array
/// decode (corrupt-file policy, never silently dropped).
public struct MediaRecipe: Codable, Equatable, Sendable, Identifiable {
/// `"recipe-<uuid>"`, never user-typed.
public var id: String
public var name: String
public var notes: String
/// CUPS queue id (`Printer.name` `Printer` has no `id` member).
public var printerID: String
/// Human label from `Printer.displayName`.
public var printerDisplayName: String
/// Library metadata only never written to targen `-P`/`-I` flags.
public var paperName: String
/// Last captured CUPS `media_type`, read-only.
public var driverMediaType: String?
/// Free text: `"PK"`, `"MK"`, `"Photo Black"`,
public var inkSet: String
/// `"rgb"` | `"cmyk"` must match the bound preset.
public var colourSpace: String
/// `ProfilingPreset.id` (built-in or custom).
public var presetID: String
/// Absolute `.cal` path stored verbatim; `nil` = none.
public var calibrationURL: String?
public var applyCalibration: Bool
public var created: Date
public var updated: Date
public init(
id: String,
name: String,
notes: String = "",
printerID: String,
printerDisplayName: String = "",
paperName: String = "",
driverMediaType: String? = nil,
inkSet: String = "",
colourSpace: String,
presetID: String,
calibrationURL: String? = nil,
applyCalibration: Bool = false,
created: Date = Date(),
updated: Date = Date()
) {
self.id = id
self.name = name
self.notes = notes
self.printerID = printerID
self.printerDisplayName = printerDisplayName
self.paperName = paperName
self.driverMediaType = driverMediaType
self.inkSet = inkSet
self.colourSpace = colourSpace
self.presetID = presetID
self.calibrationURL = calibrationURL
self.applyCalibration = applyCalibration
self.created = created
self.updated = updated
}
enum CodingKeys: String, CodingKey {
case id, name, notes
case printerID = "printer_id"
case printerDisplayName = "printer_display_name"
case paperName = "paper_name"
case driverMediaType = "driver_media_type"
case inkSet = "ink_set"
case colourSpace = "colour_space"
case presetID = "preset_id"
case calibrationURL = "calibration_url"
case applyCalibration = "apply_calibration"
case created, updated
}
/// Strict decode: required identity + binding fields must be
/// present; optionals default. Unknown keys are ignored.
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(String.self, forKey: .id)
name = try c.decode(String.self, forKey: .name)
notes = try c.decodeIfPresent(String.self, forKey: .notes) ?? ""
printerID = try c.decode(String.self, forKey: .printerID)
printerDisplayName = try c.decodeIfPresent(String.self, forKey: .printerDisplayName) ?? ""
paperName = try c.decodeIfPresent(String.self, forKey: .paperName) ?? ""
driverMediaType = try c.decodeIfPresent(String.self, forKey: .driverMediaType)
inkSet = try c.decodeIfPresent(String.self, forKey: .inkSet) ?? ""
colourSpace = try c.decode(String.self, forKey: .colourSpace)
presetID = try c.decode(String.self, forKey: .presetID)
calibrationURL = try c.decodeIfPresent(String.self, forKey: .calibrationURL)
applyCalibration = try c.decodeIfPresent(Bool.self, forKey: .applyCalibration) ?? false
created = try c.decodeIfPresent(Date.self, forKey: .created) ?? Date()
updated = try c.decodeIfPresent(Date.self, forKey: .updated) ?? Date()
}
public enum ValidationError: LocalizedError, Equatable {
case emptyName
case emptyPrinterID
case invalidColourSpace(String)
case emptyPresetID
case invalidCalibrationURL(String)
public var errorDescription: String? {
switch self {
case .emptyName: return "Media recipe is missing a name."
case .emptyPrinterID: return "Media recipe is missing a printer."
case .invalidColourSpace(let v):
return "colour_space must be \"rgb\" or \"cmyk\", got \"\(v)\"."
case .emptyPresetID: return "Media recipe is missing a preset."
case .invalidCalibrationURL(let v):
return "calibration_url must be an absolute path without \"..\" or NUL, got \"\(v)\"."
}
}
}
/// Validates the binding fields. `colourSpace` is normalized to
/// lowercase before comparison. `CAL_` cal names are **not**
/// rejected that is an apply-time policy, not schema.
@discardableResult
public func validated() throws -> MediaRecipe {
var r = self
r.id = id.trimmingCharacters(in: .whitespacesAndNewlines)
r.name = name.trimmingCharacters(in: .whitespacesAndNewlines)
r.colourSpace = colourSpace.lowercased()
guard !r.name.isEmpty else { throw ValidationError.emptyName }
guard !r.printerID.isEmpty else { throw ValidationError.emptyPrinterID }
guard r.colourSpace == "rgb" || r.colourSpace == "cmyk" else {
throw ValidationError.invalidColourSpace(colourSpace)
}
guard !r.presetID.isEmpty else { throw ValidationError.emptyPresetID }
if let cal = r.calibrationURL, !cal.isEmpty {
guard cal.hasPrefix("/"), !cal.contains(".."), !cal.contains("\0") else {
throw ValidationError.invalidCalibrationURL(cal)
}
}
return r
}
}
@@ -51,58 +51,6 @@ public struct PatchColor: Codable, Sendable, Equatable {
}
}
public struct CIEXYZ: Codable, Sendable, Equatable {
public let x: Double
public let y: Double
public let z: Double
public init(from decoder: Decoder) throws {
var container = try decoder.unkeyedContainer()
self.x = try container.decode(Double.self)
self.y = try container.decode(Double.self)
self.z = try container.decode(Double.self)
}
public init(x: Double, y: Double, z: Double) {
self.x = x
self.y = y
self.z = z
}
public func encode(to encoder: Encoder) throws {
var container = encoder.unkeyedContainer()
try container.encode(x)
try container.encode(y)
try container.encode(z)
}
}
public struct CIELab: Codable, Sendable, Equatable {
public let l: Double
public let a: Double
public let b: Double
public init(from decoder: Decoder) throws {
var container = try decoder.unkeyedContainer()
self.l = try container.decode(Double.self)
self.a = try container.decode(Double.self)
self.b = try container.decode(Double.self)
}
public init(l: Double, a: Double, b: Double) {
self.l = l
self.a = a
self.b = b
}
public func encode(to encoder: Encoder) throws {
var container = encoder.unkeyedContainer()
try container.encode(l)
try container.encode(a)
try container.encode(b)
}
}
public struct SpectralData: Codable, Sendable, Equatable {
public let bands: Int
public let startNM: Double
@@ -154,11 +154,9 @@ public enum ColorDifference {
/// Resolve a Lab from a `PatchColor`, computing it from XYZ when Lab is absent.
public static func resolveLab(_ color: PatchColor) -> LabColor? {
if let lab = color.lab {
return LabColor(l: lab.l, a: lab.a, b: lab.b)
}
if let lab = color.lab { return lab }
guard let xyz = color.xyz else { return nil }
return LabColorMath.xyzToLab(XYZColor(x: xyz.x, y: xyz.y, z: xyz.z))
return LabColorMath.xyzToLab(xyz)
}
private static func atan2ToDegrees(_ y: Double, _ x: Double) -> Double {
@@ -1,7 +1,8 @@
import Foundation
/// XYZ tristimulus values, stored in the 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 y: Double
public let z: Double
@@ -11,10 +12,24 @@ public struct XYZColor: Sendable, Equatable {
self.y = y
self.z = z
}
public init(from decoder: Decoder) throws {
var container = try decoder.unkeyedContainer()
self.x = try container.decode(Double.self)
self.y = try container.decode(Double.self)
self.z = try container.decode(Double.self)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.unkeyedContainer()
try container.encode(x)
try container.encode(y)
try container.encode(z)
}
}
/// CIELab value (D50).
public struct LabColor: Sendable, Equatable {
/// CIELab value (D50). Unkeyed Codable matches `ROW_COLORS_JSON` `[L, a, b]`.
public struct LabColor: Codable, Sendable, Equatable {
public let l: Double
public let a: Double
public let b: Double
@@ -24,8 +39,26 @@ public struct LabColor: Sendable, Equatable {
self.a = a
self.b = b
}
public init(from decoder: Decoder) throws {
var container = try decoder.unkeyedContainer()
self.l = try container.decode(Double.self)
self.a = try container.decode(Double.self)
self.b = try container.decode(Double.self)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.unkeyedContainer()
try container.encode(l)
try container.encode(a)
try container.encode(b)
}
}
/// JSON aliases used by `chartread` row payloads.
public typealias CIEXYZ = XYZColor
public typealias CIELab = LabColor
/// sRGB colour in 01 display space.
public struct DisplayRGB: Sendable, Equatable {
public let r: Double
@@ -0,0 +1,48 @@
import Foundation
/// Line classifier for `spotread` stdout (issue #148).
///
/// `spotread` shares `chartread`'s white-tile calibration phrasing, so
/// this wraps `ChartreadClassifier` and only intercepts the lines that
/// would otherwise misclassify:
///
/// - ` and then hit any key to continue,` / `or hit Esc or Q to abort:`
/// continuation lines that trail the calibration and spot prompts
/// sticky to the current prompt state instead of `PROMPT_CONTINUE`.
/// - `Place instrument on a spot to be measured,` /
/// `and hit a key to take a reading,` `AWAITING_STRIP` (the Read
/// prompt; the generic chartread matcher does not know "take a
/// reading").
///
/// Sample lines (`Result is XYZ: , D50 Lab: `) are parsed by
/// `SpotReadParser`, not classified here.
public enum SpotReadClassifier {
public static func classify(
line: String,
previousState: ChartreadState
) -> ChartreadClassifyResult {
let text = line.lowercased()
// Spot-read prompt continuations keep the current prompt state.
if previousState == .calibrating || previousState == .awaitingStrip {
if text.contains("hit any key")
|| text.contains("hit space")
|| text.contains("esc or")
|| text.contains("abort")
|| text.contains("to abort") {
return ChartreadClassifyResult(state: previousState)
}
}
// "Place instrument on a spot to be measured," /
// " and hit a key to take a reading," the Read trigger prompt.
if text.contains("spot to be measured")
|| text.contains("take a reading")
|| text.contains("measure the spot") {
return ChartreadClassifyResult(state: .awaitingStrip)
}
return ChartreadClassifier.classify(line: line, previousState: previousState)
}
}
@@ -0,0 +1,80 @@
import Foundation
/// One patch measurement emitted by a running `spotread` child (#148).
public struct SpotReadSample: Codable, Sendable, Equatable, Identifiable {
public var id: UUID
public var timestamp: Date
/// D50 Lab (always present derived from XYZ when needed).
public var lab: LabColor
/// XYZ on the 0100 scale used by the fork, when the line carried it.
public var xyz: XYZColor?
public var instrumentName: String
public var port: Int?
/// Diagnostics only never rendered as HTML or shown in the table.
public var rawLine: String
public init(
id: UUID = UUID(),
timestamp: Date = Date(),
lab: LabColor,
xyz: XYZColor? = nil,
instrumentName: String = "",
port: Int? = nil,
rawLine: String = ""
) {
self.id = id
self.timestamp = timestamp
self.lab = lab
self.xyz = xyz
self.instrumentName = instrumentName
self.port = port
self.rawLine = rawLine
}
}
/// Parses `spotread` result lines into Lab / XYZ triples.
///
/// The fork's line shape is the upstream
/// `Result is XYZ: <x> <y> <z>, D50 Lab: <L> <a> <b>`; a Lab-only line
/// also parses, and an XYZ-only line derives Lab via
/// `LabColorMath.xyzToLab` (D50).
public enum SpotReadParser {
public static func parse(line: String) -> (xyz: XYZColor?, lab: LabColor)? {
guard line.range(of: "result is", options: .caseInsensitive) != nil
|| line.range(of: #"\bLab\b"#, options: .regularExpression) != nil
|| line.range(of: #"\bXYZ\b"#, options: .regularExpression) != nil
else { return nil }
var xyz: XYZColor?
var lab: LabColor?
if let m = triple(#"\bXYZ\b[:\s]"#, in: line) {
xyz = XYZColor(x: m.0, y: m.1, z: m.2)
}
if let m = triple(#"\bLab\b[:\s]"#, in: line) {
lab = LabColor(l: m.0, a: m.1, b: m.2)
}
if lab == nil, let xyz {
lab = LabColorMath.xyzToLab(xyz)
}
guard let lab else { return nil }
return (xyz, lab)
}
private static func triple(_ marker: String, in line: String) -> (Double, Double, Double)? {
let pattern = marker + #"\s*(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)"#
guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive),
let match = regex.firstMatch(
in: line, options: [], range: NSRange(line.startIndex..., in: line)),
match.numberOfRanges == 4,
let r1 = Range(match.range(at: 1), in: line),
let r2 = Range(match.range(at: 2), in: line),
let r3 = Range(match.range(at: 3), in: line),
let a = Double(line[r1]),
let b = Double(line[r2]),
let c = Double(line[r3])
else { return nil }
return (a, b, c)
}
}
@@ -4,6 +4,8 @@ import Foundation
/// filter events on `id` historical bug #56 was an id mismatch.
public enum ProcessID {
public static let instlist = "instlist"
/// Spot-read console (issue #148) single lease, like `instlist`.
public static let spotread = "spotread"
public static func targen(_ basename: String) -> String { "targen_\(basename)" }
public static func printtarg(_ basename: String) -> String { "printtarg_\(basename)" }
@@ -136,31 +136,26 @@ public actor ProcessManager {
) throws {
guard !isRunning(id) else { throw ProcessError.duplicateID(id) }
let process = Process()
let stdinPipe = Pipe()
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
process.executableURL = binary
process.arguments = arguments
process.currentDirectoryURL = workingDirectory
process.standardInput = stdinPipe
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
process.environment = childEnvironment(extra: environment)
AppLogger(category: "process").debug(
"spawn \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))"
let prepared = makeProcess(
binary: binary,
arguments: arguments,
workingDirectory: workingDirectory,
environment: environment,
includeStdin: true
)
let process = prepared.process
logSpawn(id: id, binary: binary, arguments: arguments, captured: false)
children[id] = RunningChild(
process: process,
stdin: stdinPipe.fileHandleForWriting,
stdin: prepared.stdinPipe?.fileHandleForWriting,
stdoutDecoder: ProcessLineDecoder(),
stderrDecoder: ProcessLineDecoder()
)
let stdoutHandle = stdoutPipe.fileHandleForReading
let stderrHandle = stderrPipe.fileHandleForReading
let stdoutHandle = prepared.stdoutPipe.fileHandleForReading
let stderrHandle = prepared.stderrPipe.fileHandleForReading
stdoutHandle.readabilityHandler = { [weak self] handle in
let data = handle.availableData
guard let self else { return }
@@ -172,27 +167,23 @@ public actor ProcessManager {
Task { await self.ingestOutput(data, id: id, isStderr: true, handle: handle) }
}
process.terminationHandler = { [weak self] proc in
attachTerminationHandler(process) { [weak self] code in
guard let self else { return }
Task { await self.didTerminate(id: id, code: proc.terminationStatus) }
Task { await self.didTerminate(id: id, code: code) }
}
do {
try process.run()
// Fallback watchdog: very fast child exits can race past the
// terminationHandler delivery on a loaded host. waitUntilExit()
// blocks the detached thread and guarantees didTerminate runs.
Task.detached { [weak self, process] in
process.waitUntilExit()
guard let self else { return }
await self.didTerminate(id: id, code: process.terminationStatus)
}
} catch {
preKillHooks.removeValue(forKey: id)
children.removeValue(forKey: id)
emit(.error(id: id, message: error.localizedDescription))
throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)")
}
startWaitUntilExitWatchdog(process) { [weak self] code in
guard let self else { return }
Task { await self.didTerminate(id: id, code: code) }
}
}
// MARK: - Spawn (captured)
@@ -210,19 +201,18 @@ public actor ProcessManager {
) async throws -> CapturedResult {
guard !isRunning(id) else { throw ProcessError.duplicateID(id) }
let process = Process()
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
process.executableURL = binary
process.arguments = arguments
process.currentDirectoryURL = workingDirectory
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
process.environment = childEnvironment(extra: environment)
AppLogger(category: "process").debug(
"spawn(captured) \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))"
let prepared = makeProcess(
binary: binary,
arguments: arguments,
workingDirectory: workingDirectory,
environment: environment,
includeStdin: false
)
let process = prepared.process
let stdoutPipe = prepared.stdoutPipe
let stderrPipe = prepared.stderrPipe
logSpawn(id: id, binary: binary, arguments: arguments, captured: true)
// Register and set up the termination hand-off before run() so
// a very fast exit is never missed (#50, #52).
@@ -275,20 +265,12 @@ public actor ProcessManager {
}
}
let box = Box()
capturedProcess.terminationHandler = { proc in
_ = box.resume(with: proc.terminationStatus)
attachTerminationHandler(capturedProcess) { status in
_ = box.resume(with: status)
}
do {
try process.run()
// Fallback watchdog: very fast child exits can race past the
// terminationHandler delivery on a loaded host. waitUntilExit()
// blocks the detached thread and resumes the box if the handler
// did not already do so (#50, #52).
Task.detached { [capturedProcess] in
capturedProcess.waitUntilExit()
_ = box.resume(with: capturedProcess.terminationStatus)
}
} catch {
_ = box.resume(with: -1)
captured.removeValue(forKey: id)
@@ -296,6 +278,9 @@ public actor ProcessManager {
emit(.error(id: id, message: error.localizedDescription))
throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)")
}
startWaitUntilExitWatchdog(capturedProcess) { status in
_ = box.resume(with: status)
}
// Close the parent write ends so readDataToEndOfFile() gets EOF
// as soon as the child exits; the child still has its own copies.
@@ -371,12 +356,7 @@ public actor ProcessManager {
guard var child = children[id], !child.finalized else { return }
if let tail = child.stdoutDecoder.flushPartial() {
if tail.hasPrefix(Self.rowColorsPrefix) {
let payload = Data(tail.dropFirst(Self.rowColorsPrefix.count).utf8)
emit(.jsonRow(id: id, payload: payload))
} else {
emit(.stdout(id: id, line: tail))
}
emitStdoutLine(id: id, line: tail)
}
if let tail = child.stderrDecoder.flushPartial() {
emit(.stderr(id: id, line: tail))
@@ -447,6 +427,86 @@ public actor ProcessManager {
// MARK: - Internals
private struct PreparedProcess {
let process: Process
let stdinPipe: Pipe?
let stdoutPipe: Pipe
let stderrPipe: Pipe
}
private func makeProcess(
binary: URL,
arguments: [String],
workingDirectory: URL?,
environment: [String: String],
includeStdin: Bool
) -> PreparedProcess {
let process = Process()
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
let stdinPipe: Pipe? = includeStdin ? Pipe() : nil
process.executableURL = binary
process.arguments = arguments
process.currentDirectoryURL = workingDirectory
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
if let stdinPipe {
process.standardInput = stdinPipe
}
process.environment = childEnvironment(extra: environment)
return PreparedProcess(
process: process,
stdinPipe: stdinPipe,
stdoutPipe: stdoutPipe,
stderrPipe: stderrPipe
)
}
private nonisolated func logSpawn(
id: String,
binary: URL,
arguments: [String],
captured: Bool
) {
let prefix = captured ? "spawn(captured)" : "spawn"
AppLogger(category: "process").debug(
"\(prefix) \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))"
)
}
/// `terminationHandler` can lose a fast-exit race on a loaded host;
/// `waitUntilExit` on a detached thread is the fallback (#50, #52).
/// The handler is attached before `run()`; the wait thread starts
/// only after a successful launch `terminationStatus` on an
/// unlaunched NSTask raises NSInvalidArgumentException.
private func attachTerminationHandler(
_ process: Process,
onExit: @escaping @Sendable (Int32) -> Void
) {
process.terminationHandler = { proc in
onExit(proc.terminationStatus)
}
}
private func startWaitUntilExitWatchdog(
_ process: Process,
onExit: @escaping @Sendable (Int32) -> Void
) {
Task.detached { [process] in
process.waitUntilExit()
onExit(process.terminationStatus)
}
}
private func emitStdoutLine(id: String, line: String) {
if line.hasPrefix(Self.rowColorsPrefix) {
let payload = Data(line.dropFirst(Self.rowColorsPrefix.count).utf8)
emit(.jsonRow(id: id, payload: payload))
} else {
emit(.stdout(id: id, line: line))
}
}
private func childEnvironment(extra: [String: String]) -> [String: String] {
var env = ProcessInfo.processInfo.environment
env["ARGYLL_NOT_INTERACTIVE"] = "1"
@@ -478,15 +538,14 @@ public actor ProcessManager {
let log = AppLogger(category: "subprocess")
for line in lines {
if !isStderr, line.hasPrefix(Self.rowColorsPrefix) {
let payload = Data(line.dropFirst(Self.rowColorsPrefix.count).utf8)
emit(.jsonRow(id: id, payload: payload))
} else if isStderr {
if !isStderr {
emitStdoutLine(id: id, line: line)
if !line.hasPrefix(Self.rowColorsPrefix) {
log.info("[\(id)] \(line)")
}
} else {
log.warn("[\(id)] \(line)")
emit(.stderr(id: id, line: line))
} else {
log.info("[\(id)] \(line)")
emit(.stdout(id: id, line: line))
}
}
}
@@ -500,7 +559,7 @@ public actor ProcessManager {
// Start a watchdog in case the `readabilityHandler` EOFs never
// arrive after the process exits (e.g. a hung pipe).
child.finalizeTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(2))
try? await Task.sleep(nanoseconds: 2_000_000_000)
guard let self else { return }
await self.forceKill(id: id)
await self.forceFinalize(id: id)
@@ -528,11 +587,7 @@ public actor ProcessManager {
// Flush unterminated tail lines.
if var decoder = Optional(child.stdoutDecoder),
let tail = decoder.finish() {
if tail.hasPrefix(Self.rowColorsPrefix) {
emit(.jsonRow(id: id, payload: Data(tail.dropFirst(Self.rowColorsPrefix.count).utf8)))
} else {
emit(.stdout(id: id, line: tail))
}
emitStdoutLine(id: id, line: tail)
}
if var decoder = Optional(child.stderrDecoder),
let tail = decoder.finish() {
@@ -0,0 +1,54 @@
import Foundation
/// Canonical `CAL_` / original-stem pairing for Stage 0 (issue #29 / #83).
///
/// The live wizard basename, the persisted `calibrationOriginalBasename`,
/// and the runner all derive identity from this type. Do not add a second
/// `hasPrefix("CAL_")` ternary elsewhere.
public struct CalibrationIdentity: Equatable, Sendable {
/// Never has a `CAL_` prefix. Empty only when the live basename is empty.
public var originalBasename: String
/// Always `CAL_{original}` when original is non-empty.
public var calibrationBasename: String
public init(originalBasename: String, calibrationBasename: String) {
self.originalBasename = originalBasename
self.calibrationBasename = calibrationBasename
}
public static func isCalibration(_ basename: String) -> Bool {
basename.hasPrefix("CAL_")
}
/// The only place that adds a `CAL_` prefix.
public static func prefix(_ original: String) -> String {
if original.isEmpty { return original }
return original.hasPrefix("CAL_") ? original : "CAL_\(original)"
}
/// Strip a single leading `CAL_` if present.
public static func strip(_ basename: String) -> String {
basename.hasPrefix("CAL_") ? String(basename.dropFirst(4)) : basename
}
/// Derive identity from the live wizard basename and the persisted
/// original. A non-empty persisted original wins over a `CAL_` live
/// name (Force Quit mid-calibration). An empty live basename always
/// produces an empty identity a persisted original must never
/// resurrect a target that no longer exists (#83).
public static func parse(liveBasename: String, persistedOriginal: String) -> CalibrationIdentity {
guard !liveBasename.isEmpty else {
return CalibrationIdentity(originalBasename: "", calibrationBasename: "")
}
let original: String
if liveBasename.hasPrefix("CAL_") {
original = persistedOriginal.isEmpty ? strip(liveBasename) : persistedOriginal
} else {
original = liveBasename
}
return CalibrationIdentity(
originalBasename: original,
calibrationBasename: prefix(original)
)
}
}
@@ -86,7 +86,7 @@ public enum CalibrationTargenArgs {
args.append(contentsOf: ["-l", "\(inkLimit)"])
}
let calBasename = cleanBasename.hasPrefix("CAL_") ? cleanBasename : "CAL_\(cleanBasename)"
let calBasename = CalibrationIdentity.prefix(cleanBasename)
args.append(calBasename)
return args
}
@@ -26,18 +26,13 @@ public enum ColprofArgs {
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
var args: [String] = ["-v"]
args.append(contentsOf: ["-a", config.algorithm])
args.append(contentsOf: ["-q", config.quality])
if let intent = config.intent?.trimmingCharacters(in: .whitespaces), !intent.isEmpty {
args.append(contentsOf: ["-t", intent])
}
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-t", config.intent))
if let fwa = config.fwa?.trimmingCharacters(in: .whitespaces) {
switch fwa.lowercased() {
case "none", "":
// "none" omits the flag; an explicit empty string means bare -f.
if fwa.isEmpty {
args.append("-f")
}
@@ -46,32 +41,20 @@ public enum ColprofArgs {
}
}
if let illuminant = config.illuminant?.trimmingCharacters(in: .whitespaces), !illuminant.isEmpty {
args.append(contentsOf: ["-i", illuminant])
}
if let observer = config.observer?.trimmingCharacters(in: .whitespaces), !observer.isEmpty {
args.append(contentsOf: ["-o", observer])
}
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-i", config.illuminant))
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-o", config.observer))
if let inputCond = config.inputViewingCond?.trimmingCharacters(in: .whitespaces),
!inputCond.isEmpty, inputCond.lowercased() != "none" {
args.append(contentsOf: ["-c", inputCond])
}
if let outputCond = config.outputViewingCond?.trimmingCharacters(in: .whitespaces),
!outputCond.isEmpty, outputCond.lowercased() != "none" {
args.append(contentsOf: ["-d", outputCond])
}
let profileDescription = config.description?.trimmingCharacters(in: .whitespaces)
if let description = profileDescription, !description.isEmpty {
args.append(contentsOf: ["-D", description])
}
if let copyright = config.copyright?.trimmingCharacters(in: .whitespaces), !copyright.isEmpty {
args.append(contentsOf: ["-C", copyright])
}
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-D", config.description))
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-C", config.copyright))
args.append(cleanBasename)
return args
@@ -79,16 +79,9 @@ public enum PrintcalArgs {
var args: [String] = ["-v", "-e"]
if config.noInkLimit {
args.append("-I")
}
if config.verify {
args.append("-z")
}
if let previous = config.previousCalPath?.trimmingCharacters(in: .whitespacesAndNewlines),
!previous.isEmpty {
args.append(contentsOf: ["-a", previous])
}
args.append(contentsOf: ArgsBuilder.flag("-I", when: config.noInkLimit))
args.append(contentsOf: ArgsBuilder.flag("-z", when: config.verify))
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-a", config.previousCalPath))
if let tac = config.totalInkLimit, tac > 0 {
args.append(contentsOf: ["-m", String(format: "%.1f", tac)])
} else if let tac = config.totalInkLimit {
@@ -107,7 +100,7 @@ public enum PrintcalArgs {
}
args.append(contentsOf: ["-o", config.outputURL.path])
let calBasename = cleanBasename.hasPrefix("CAL_") ? cleanBasename : "CAL_\(cleanBasename)"
let calBasename = CalibrationIdentity.prefix(cleanBasename)
args.append(calBasename)
return args
}
@@ -13,8 +13,7 @@ public actor VerificationHistoryStore {
private var records: [VerificationRecord] = []
private let capacity: Int
private let encoder: JSONEncoder
private let decoder: JSONDecoder
private let fileStore: JSONFileStore<[VerificationRecord]>
public init(
url: URL = AppPaths.appDataDir.appendingPathComponent("verification_history.json"),
@@ -22,13 +21,13 @@ public actor VerificationHistoryStore {
) {
self.url = url
self.capacity = capacity
self.encoder = JSONEncoder()
self.encoder.dateEncodingStrategy = .iso8601
self.encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
self.decoder = JSONDecoder()
self.decoder.dateDecodingStrategy = .iso8601
self.fileStore = JSONFileStore(
fileURL: url,
corrupt: .throwCorrupt,
defaultValue: { [] },
dateEncoding: .iso8601,
dateDecoding: .iso8601
)
}
/// Loads records from disk. Returns the existing cache if already loaded.
@@ -37,10 +36,8 @@ public actor VerificationHistoryStore {
/// is never overwritten in that case.
public func load() throws -> [VerificationRecord] {
guard records.isEmpty else { return records }
let fm = FileManager.default
guard fm.fileExists(atPath: url.path),
let data = try? Data(contentsOf: url) else { return [] }
records = try decoder.decode([VerificationRecord].self, from: data)
guard FileManager.default.fileExists(atPath: url.path) else { return [] }
records = try fileStore.load()
return records
}
@@ -76,7 +73,11 @@ public actor VerificationHistoryStore {
}
/// Removes all history and updates disk.
///
/// Loads the existing history first and propagates any load error so an
/// unparseable file is never overwritten.
public func clear() throws {
try load()
try write([])
records = []
}
@@ -106,8 +107,7 @@ public actor VerificationHistoryStore {
/// Writes `records` through a temp file and rename.
private func write(_ records: [VerificationRecord]) throws {
let data = try encoder.encode(records)
try AtomicFileWriter.write(data, to: url)
try fileStore.save(records)
}
private func csvRow(_ fields: [String]) -> String {
@@ -0,0 +1,225 @@
import Foundation
extension TargenConfig {
/// Stage 1 fields of a profiling preset. Optional advanced flags stay
/// `nil` when the preset omitted them so argv builders skip the flag.
public init(preset: ProfilingPreset, basename: String, workingDirectory: URL?) {
self.init(
colourSpace: preset.colourSpace.lowercased() == "cmyk" ? .cmyk : .rgb,
patchCount: preset.patchCount,
whitePatches: preset.whitePatches,
blackPatches: preset.blackPatches,
greySteps: preset.greySteps,
singleChannelSteps: preset.singleChannelSteps,
neutralSteps: preset.neutralSteps,
neutralConcentration: preset.neutralConcentration,
preconditioningProfile: preset.preconditioningProfile,
// An explicit `false` is preserved distinguishable from a
// missing key; `-G` is only emitted for `true` (#82).
ofpsHighQuality: preset.ofpsHighQuality,
ofpsAdaptation: preset.ofpsAdaptation,
fullSpreadAlgorithm: preset.fullSpreadAlgorithm.flatMap { FullSpreadAlgorithm(presetValue: $0) }.flatMap { $0 == .ofps ? nil : $0 },
totalInkLimit: preset.totalInkLimit,
darkEmphasis: preset.darkEmphasis,
devicePower: preset.devicePower,
basename: basename,
workingDirectory: workingDirectory
)
}
}
extension PrinttargConfig {
public init(
preset: ProfilingPreset,
basename: String,
workingDirectory: URL?,
calibrationFile: String?,
label: String? = nil
) {
let page: PageSize
let customW: Double
let customH: Double
if let size = PageSize(rawValue: preset.pageSize) {
page = size
customW = 210
customH = 297
} else if let (w, h) = PageSize.parseCustom(preset.pageSize) {
page = .custom
customW = w
customH = h
} else {
page = .a4
customW = 210
customH = 297
}
let layout: LayoutOrder
let seed = preset.randomSeed ?? 1
if preset.noRandomize == true {
layout = .raster
} else if seed == 1 {
layout = .deterministic
} else {
layout = .customSeed
}
self.init(
instrument: PrintInstrument(rawValue: preset.instrument) ?? .i1,
pageSize: page,
customPageWidth: customW,
customPageHeight: customH,
bitDepth: preset.bitDepth == 16 ? .sixteen : .eight,
dpi: preset.dpi,
layoutOrder: layout,
customSeed: seed,
label: label,
calibrationFile: calibrationFile,
calibrationEmbedOnly: false,
basename: basename,
workingDirectory: workingDirectory
)
}
}
extension ColprofConfig {
public init(
preset: ProfilingPreset,
basename: String,
workingDirectory: URL?,
description: String? = nil,
copyright: String? = nil
) {
self.init(
algorithm: preset.colprofAlgorithm ?? "l",
quality: preset.colprofQuality ?? "m",
intent: Self.nilIfEmpty(preset.colprofIntent),
fwa: preset.colprofFwa,
illuminant: Self.nilIfEmpty(preset.colprofIlluminant),
observer: Self.nilIfEmpty(preset.colprofObserver),
inputViewingCond: Self.nilIfEmpty(preset.colprofInputViewingCond),
outputViewingCond: Self.nilIfEmpty(preset.colprofOutputViewingCond),
description: description,
copyright: copyright,
basename: basename,
workingDirectory: workingDirectory
)
}
private static func nilIfEmpty(_ value: String?) -> String? {
guard let value, !value.isEmpty else { return nil }
return value
}
}
/// User-facing FWA selection for the Stage 4 form, plus the two
/// directions of `colprof_fwa` conversion centralised here so the view
/// models carry no mapping switches of their own (#82).
public enum ColprofFwaSelection: String, CaseIterable, Sendable, Equatable {
case none = "none"
case empty = ""
case D50 = "D50"
case D65 = "D65"
case custom = "custom"
public var displayName: String {
switch self {
case .none: return "None"
case .empty: return "Bare (-f)"
case .D50: return "D50"
case .D65: return "D65"
case .custom: return "Custom .sp"
}
}
/// Preset `colprof_fwa` selection. `nil`/`"none"` map to `.none`,
/// `""` to `.empty`, `D50`/`D65` case-insensitively, and any other
/// string is a custom `.sp` path.
public init(presetValue: String?) {
switch presetValue?.lowercased() {
case nil, "none": self = .none
case "": self = .empty
case "d50": self = .D50
case "d65": self = .D65
default: self = .custom
}
}
/// Selection `colprof_fwa` value. `.custom` returns `customPath`.
public func presetValue(customPath: String) -> String? {
switch self {
case .none: return nil
case .empty: return ""
case .D50: return "D50"
case .D65: return "D65"
case .custom: return customPath
}
}
}
extension PageSize {
/// `"210x297"` custom page parse used by presets (issue #82).
public static func parseCustom(_ raw: String) -> (Double, Double)? {
let parts = raw.lowercased().split(separator: "x")
guard parts.count == 2,
let w = Double(parts[0]), let h = Double(parts[1]),
w >= 50, h >= 50 else { return nil }
return (w, h)
}
}
extension ProfilingPreset {
/// Snapshot of the three live configs plus calibration toggles.
public init(
id: String,
name: String,
description: String,
targen: TargenConfig,
printtarg: PrinttargConfig,
colprof: ColprofConfig,
calibrationFile: String?,
applyCalibration: Bool?
) {
let pageSize: String
if printtarg.pageSize == .custom {
pageSize = "\(Int(printtarg.customPageWidth))x\(Int(printtarg.customPageHeight))"
} else {
pageSize = printtarg.pageSize.rawValue
}
self.init(
id: id,
name: name,
description: description,
colourSpace: targen.colourSpace == .cmyk ? "cmyk" : "rgb",
patchCount: targen.patchCount,
whitePatches: targen.whitePatches,
blackPatches: targen.blackPatches,
greySteps: targen.greySteps,
singleChannelSteps: targen.singleChannelSteps,
neutralSteps: targen.neutralSteps,
neutralConcentration: targen.neutralConcentration,
preconditioningProfile: targen.preconditioningProfile,
ofpsHighQuality: targen.ofpsHighQuality,
ofpsAdaptation: targen.ofpsAdaptation,
fullSpreadAlgorithm: (targen.fullSpreadAlgorithm ?? .ofps).presetValue,
totalInkLimit: targen.totalInkLimit,
darkEmphasis: targen.darkEmphasis,
devicePower: targen.devicePower,
instrument: printtarg.instrument.rawValue,
pageSize: pageSize,
bitDepth: printtarg.bitDepth.rawValue,
dpi: printtarg.dpi,
randomSeed: printtarg.layoutOrder == .deterministic ? 1 : printtarg.customSeed,
noRandomize: printtarg.layoutOrder == .raster,
calibrationFile: calibrationFile,
applyCalibration: applyCalibration,
colprofAlgorithm: colprof.algorithm,
colprofQuality: colprof.quality,
colprofIntent: colprof.intent,
colprofFwa: colprof.fwa,
colprofIlluminant: colprof.illuminant,
colprofObserver: colprof.observer,
colprofInputViewingCond: colprof.inputViewingCond,
colprofOutputViewingCond: colprof.outputViewingCond
)
}
}
@@ -60,9 +60,7 @@ public final class PresetStore: Sendable {
/// Single-preset pretty JSON export.
public func export(_ preset: ProfilingPreset) throws -> Data {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
return try encoder.encode(preset)
return try JSONEncoder.icceryPretty().encode(preset)
}
/// Parses + validates a preset from JSON. The preset is assigned a
@@ -4,8 +4,8 @@ import Foundation
/// `~/Library/Application Support/com.gronod.iccery2/settings.json`
/// (issue #5 the v1 path is never read).
///
/// Writes are atomic (`AtomicFileWriter`). Invalid/corrupt JSON falls
/// back to defaults. Saving posts `settingsDidChange` so #20 can
/// Writes are atomic (`JSONFileStore` `AtomicFileWriter`). Invalid/corrupt
/// JSON falls back to defaults. Saving posts `settingsDidChange` so #20 can
/// reclassify swatches.
public final class SettingsStore: Sendable {
@@ -14,18 +14,19 @@ public final class SettingsStore: Sendable {
Notification.Name("com.gronod.iccery2.settingsDidChange")
public let fileURL: URL
private let store: JSONFileStore<AppSettings>
public init(fileURL: URL = AppPaths.appDataDir.appendingPathComponent("settings.json")) {
self.fileURL = fileURL
self.store = JSONFileStore(
fileURL: fileURL,
corrupt: .replaceWithDefault,
defaultValue: { .default }
)
}
public func load() -> AppSettings {
guard let data = try? Data(contentsOf: fileURL),
let settings = try? JSONDecoder().decode(AppSettings.self, from: data)
else {
return .default
}
return settings
(try? store.load()) ?? .default
}
/// Validates before persisting throws `SettingsError` listing
@@ -35,9 +36,7 @@ public final class SettingsStore: Sendable {
guard errors.isEmpty else {
throw SettingsError.validationFailed(errors)
}
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
try AtomicFileWriter.write(encoder.encode(settings), to: fileURL)
try store.save(settings)
NotificationCenter.default.post(name: Self.settingsDidChange, object: nil)
}
@@ -74,23 +74,24 @@ public struct WizardState: Codable, Equatable, Sendable {
/// Atomic JSON persistence for `WizardState` (issue #4).
public final class WizardStateStore: Sendable {
public let fileURL: URL
private let store: JSONFileStore<WizardState>
public init(
fileURL: URL = AppPaths.appDataDir.appendingPathComponent("wizard_state.json")
) {
self.fileURL = fileURL
self.store = JSONFileStore(
fileURL: fileURL,
corrupt: .replaceWithDefault,
defaultValue: { .default }
)
}
public func load() -> WizardState {
guard let data = try? Data(contentsOf: fileURL),
let state = try? JSONDecoder().decode(WizardState.self, from: data)
else { return .default }
return state
(try? store.load()) ?? .default
}
public func save(_ state: WizardState) throws {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
try AtomicFileWriter.write(encoder.encode(state), to: fileURL)
try store.save(state)
}
}
+14 -6
View File
@@ -11,7 +11,10 @@ All measurement, chart generation, and profile mathematics live in the [Gronod A
| Floor | macOS 14 Sonoma, universal `arm64` + `x86_64` |
| Default branch | `develop` |
| M6 | Stage 0 calibration, CGATS import, SceneKit gamut viewer, packaging — shipped on `develop` |
| M7 | UAT-ready hardening of the v2.0 wizard paths |
| M7 | Pre-UAT hardening & baseline consolidation — shipped on `develop` |
| M8 | Deduplication/consolidation contracts & UAT-ready hardening (#79#86) — shipped on `develop` |
| M9 | macOS 12 / Xcode 14.2 retarget — shipped on `develop` (PR #145) |
| M10 | Studio workflow (#146#149) — in flight on `milestone/m10-studio` |
| Licence | Proprietary source in [`LICENCE.md`](LICENCE.md); bundled Argyll sidecars remain AGPLv3 |
## What it does
@@ -56,9 +59,11 @@ Equivalent without Make:
xcodegen generate
xcodebuild test -scheme ICCery \
-destination 'platform=macOS' \
ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
ARCHS="$(uname -m)"
```
`project.yml` sets `ARCHS: "$(ARCHS_STANDARD)"`, so a plain `xcodebuild test` (and `make test`) builds universal; the `ARCHS="$(uname -m)"` override narrows it to the host slice.
Sidecars are **not** in git. `scripts/fetch-argyll.sh` pulls the latest (or `ARGYLL_RELEASE_TAG`) macOS-universal release from `gronod/argyllcms`, extracts to `Vendor/Argyll/macos-universal/`, ad-hoc signs every Mach-O, and fails if `codesign -dvv` or the `instlist` marker is missing.
```bash
@@ -123,10 +128,13 @@ docs/ functional spec + v2 ticket plan
## Tests
```bash
# full suite (universal)
# full suite (host arch)
xcodebuild test -scheme ICCery \
-destination 'platform=macOS' \
ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
ARCHS="$(uname -m)"
# to compile-check both slices instead:
# ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
# examples
xcodebuild test -scheme ICCery -destination 'platform=macOS' \
@@ -170,11 +178,11 @@ Agent / branch rules: [`AGENTS.md`](AGENTS.md), [`BUILD-PLAN.md`](BUILD-PLAN.md)
```
develop
└── milestone/mN-<slug> # integration only
└── milestone/m10-studio # M10 integration branch
└── feat/<issue>-<slug> # one issue per branch
```
Feature PRs target the current milestone branch, not `develop`. The milestone branch merges to `develop` when its issues are green. 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 #146#149 target `milestone/m10-studio`; `milestone/m10-studio` merges into `develop` once all milestone gates pass. Do not open umbrella "bugfix" branches that mix tickets.
## Licence
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- App Sandbox intentionally absent: ICCery must spawn Argyll tools,
read/write user-chosen working directories, and talk to lp/CUPS. -->
<key>com.apple.security.device.usb</key>
<true/>
<!-- Debug only: the shared ICCeryCore package framework embedded in the
test products is ad-hoc signed with no Team ID, so hardened-runtime
library validation kills the test host at launch (run 31992, #119).
Release uses ICCery.entitlements and links the package statically. -->
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
+17 -2
View File
@@ -13,6 +13,7 @@ struct AppEnvironment: Sendable {
let runner: ArgyllRunner
let cupsService: CupsService
let historyStore: VerificationHistoryStore
let mediaStore: MediaLibraryStore
static func live(
environment: [String: String] = ProcessInfo.processInfo.environment
@@ -20,11 +21,15 @@ struct AppEnvironment: Sendable {
let settingsStore = SettingsStore()
var overrideDir = settingsStore.load().argyllBinaryDir
.map { URL(fileURLWithPath: $0) }
var bundledRoot = AppPaths.bundledArgyllDir
var cupsDir = URL(fileURLWithPath: "/usr/bin")
#if DEBUG
if let dir = environment["ICCERY_ARGYLL_BINARY_DIR"], !dir.isEmpty {
overrideDir = URL(fileURLWithPath: dir)
}
if let dir = environment["ICCERY_ARGYLL_BUNDLED_ROOT"], !dir.isEmpty {
bundledRoot = URL(fileURLWithPath: dir)
}
if let dir = environment["ICCERY_CUPS_BIN_DIR"], !dir.isEmpty {
cupsDir = URL(fileURLWithPath: dir)
}
@@ -35,12 +40,14 @@ struct AppEnvironment: Sendable {
presetStore: PresetStore(settingsStore: settingsStore),
runner: ArgyllRunner(
processManager: .shared,
binaryResolver: BinaryResolver(overrideDir: overrideDir)
binaryResolver: BinaryResolver(
bundledRoot: bundledRoot, overrideDir: overrideDir)
),
cupsService: CupsService(
processManager: .shared,
binaryDir: cupsDir),
historyStore: VerificationHistoryStore()
historyStore: VerificationHistoryStore(),
mediaStore: MediaLibraryStore()
)
}
}
@@ -74,6 +81,14 @@ enum UITestHooks {
static var presetImportURL: URL? { url("ICCERY_TEST_PRESET_IMPORT") }
/// Preset export destination.
static var presetExportURL: URL? { url("ICCERY_TEST_PRESET_EXPORT") }
/// Spot-read CSV export destination (`selectCsvSavePath`, #148).
static var csvExportURL: URL? { url("ICCERY_TEST_CSV_EXPORT") }
/// `.gam` compare picker result (gamut sheet, #147). Unset cancel.
static var gamutFileURL: URL? { url("ICCERY_TEST_GAMUT_FILE") }
/// `.icc/.icm` compare picker result (gamut sheet, #147). Unset cancel.
static var gamutProfileURL: URL? { url("ICCERY_TEST_GAMUT_PROFILE") }
/// TIFF sample picker result (gamut sheet, #147). Unset cancel.
static var gamutTiffURL: URL? { url("ICCERY_TEST_GAMUT_TIFF") }
// MARK: - Print panel / CUPS stubs (issue 13/17)
+9 -12
View File
@@ -3,7 +3,8 @@ import ICCeryCore
/// Stage 0 calibration dashboard (issue #29, docs/07).
struct CalibrationView: View {
@Bindable var model: CalibrationViewModel
@ObservedObject var model: CalibrationViewModel
@ObservedObject var wizard: WizardViewModel
var body: some View {
VStack(alignment: .leading, spacing: 0) {
@@ -51,11 +52,15 @@ struct CalibrationView: View {
HStack(spacing: 12) {
Button("Generate Target") { model.generateTarget() }
.accessibilityIdentifier("btnCalGenerate")
.disabled(!model.canGenerate)
.disabled(wizard.basename.isEmpty
|| wizard.effectiveWorkingDirectory == nil
|| model.isGenerating)
Button("Create Layout & Print") { model.createLayout() }
.accessibilityIdentifier("btnCalLayout")
.disabled(!model.canGenerate)
.disabled(wizard.basename.isEmpty
|| wizard.effectiveWorkingDirectory == nil
|| model.isGenerating)
Button("Measure") { model.measureChart() }
.accessibilityIdentifier("btnCalMeasure")
@@ -68,7 +73,7 @@ struct CalibrationView: View {
if let url = model.computedCalURL {
Toggle("Apply calibration to next profile", isOn: $model.applyToProfile)
.onChange(of: model.applyToProfile) { model.updateApplyToProfile() }
.onChange(of: model.applyToProfile) { _ in model.updateApplyToProfile() }
.accessibilityIdentifier("calApplyToggle")
Text("Loaded: \(url.lastPathComponent)")
@@ -90,15 +95,7 @@ struct CalibrationView: View {
.frame(minHeight: 80, maxHeight: 120)
}
}
if let error = model.lastError {
Section {
Text(error)
.foregroundStyle(.red)
}
}
}
.formStyle(.grouped)
HStack {
Spacer()
+44 -52
View File
@@ -1,13 +1,12 @@
import Combine
import Foundation
import Observation
import SwiftUI
import ICCeryCore
/// Stage 0 calibration workflow: generate wedge, print, measure, and
/// compute `.cal` curves.
@MainActor
@Observable
final class CalibrationViewModel {
final class CalibrationViewModel: ObservableObject {
let workflow: TargetWorkflowViewModel
let profile: ProfileWorkflowViewModel
@@ -15,17 +14,16 @@ final class CalibrationViewModel {
// MARK: - Form state
var colourSpace: ColourSpace = .cmyk
var steps: Int = 21
var whitePatches: Int = 4
var includeNeutralEmphasis: Bool = false
var inkLimit: String = "320"
var applyToProfile: Bool = false
var computedCalURL: URL?
var calibrationLog: [String] = []
var isGenerating = false
var isComputing = false
var lastError: String?
@Published var colourSpace: ColourSpace = .cmyk
@Published var steps: Int = 21
@Published var whitePatches: Int = 4
@Published var includeNeutralEmphasis: Bool = false
@Published var inkLimit: String = "320"
@Published var applyToProfile: Bool = false
@Published var computedCalURL: URL?
@Published var calibrationLog: [String] = []
@Published var isGenerating = false
@Published var isComputing = false
init(workflow: TargetWorkflowViewModel, profile: ProfileWorkflowViewModel, environment: AppEnvironment) {
self.workflow = workflow
@@ -50,14 +48,15 @@ final class CalibrationViewModel {
return cwd.appendingPathComponent("\(calBasename).ti3")
}
private var calBasename: String {
if wizard.basename.hasPrefix("CAL_") { return wizard.basename }
let original = !wizard.calibrationOriginalBasename.isEmpty
? wizard.calibrationOriginalBasename
: wizard.basename
return "CAL_\(original)"
private var identity: CalibrationIdentity {
CalibrationIdentity.parse(
liveBasename: wizard.basename,
persistedOriginal: wizard.calibrationOriginalBasename
)
}
private var calBasename: String { identity.calibrationBasename }
private var calOutputURL: URL? {
guard let cwd = wizard.effectiveWorkingDirectory else { return nil }
return cwd.appendingPathComponent("\(calBasename).cal")
@@ -68,43 +67,38 @@ final class CalibrationViewModel {
func generateTarget() {
guard canGenerate, let cwd = wizard.effectiveWorkingDirectory else { return }
// Snapshot the original (pre-CAL_) basename before changing the live one.
if !wizard.basename.hasPrefix("CAL_") {
wizard.calibrationOriginalBasename = wizard.basename
} else if wizard.calibrationOriginalBasename.isEmpty {
wizard.calibrationOriginalBasename = String(wizard.basename.dropFirst(4))
}
let original = wizard.calibrationOriginalBasename
wizard.basename = "CAL_\(original)"
let identity = CalibrationIdentity.parse(
liveBasename: wizard.basename,
persistedOriginal: wizard.calibrationOriginalBasename
)
wizard.calibrationOriginalBasename = identity.originalBasename
wizard.basename = identity.calibrationBasename
wizard.sessionMode = .calibration
isGenerating = true
calibrationLog = []
lastError = nil
let config = CalibrationTargenConfig(
colourSpace: colourSpace,
steps: steps,
whitePatches: whitePatches,
includeNeutralEmphasis: includeNeutralEmphasis,
inkLimit: inkLimitValue,
basename: original,
basename: identity.originalBasename,
workingDirectory: cwd
)
Task { @MainActor in
defer { self.isGenerating = false }
do {
_ = try await self.environment.runner.runCalibrationTargen(config: config) { batch in
Task { @MainActor [weak self] in
self?.calibrationLog.append(contentsOf: batch)
}
_ = try await ProcessRunSupport.runLogged(
setRunning: { self.isGenerating = $0 },
resetLog: { self.calibrationLog = [] },
onLog: { self.calibrationLog.append(contentsOf: $0) }
) { onLog in
try await self.environment.runner.runCalibrationTargen(
config: config, onLogBatch: onLog)
}
self.wizard.refreshGating()
self.wizard.showNotice("Calibration target generated.")
self.wizard.go(to: .layOutPrint)
} catch {
self.lastError = error.localizedDescription
self.wizard.showNotice(
"Calibration target failed: \(error.localizedDescription)",
kind: .error
@@ -139,15 +133,13 @@ final class CalibrationViewModel {
// "already exists" when the user declines overwrite. We do not
// silently clobber.
if FileManager.default.fileExists(atPath: outputURL.path) {
lastError = "\(outputURL.lastPathComponent) already exists. Rename or overwrite it first."
wizard.showNotice(lastError!, kind: .error)
wizard.showNotice(
"\(outputURL.lastPathComponent) already exists. Rename or overwrite it first.",
kind: .error
)
return
}
isComputing = true
calibrationLog = []
lastError = nil
let config = PrintcalConfig(
ti3Basename: calBasename,
workingDirectory: cwd,
@@ -160,13 +152,14 @@ final class CalibrationViewModel {
)
Task { @MainActor in
defer { self.isComputing = false }
do {
let url = try await self.environment.runner.runPrintcal(config: config) { batch in
Task { @MainActor [weak self] in
self?.calibrationLog.append(contentsOf: batch)
}
let url = try await ProcessRunSupport.runLogged(
setRunning: { self.isComputing = $0 },
resetLog: { self.calibrationLog = [] },
onLog: { self.calibrationLog.append(contentsOf: $0) }
) { onLog in
try await self.environment.runner.runPrintcal(
config: config, onLogBatch: onLog)
}
self.computedCalURL = url
self.profile.calibrationFile = url.path
@@ -174,7 +167,6 @@ final class CalibrationViewModel {
self.wizard.showNotice("Calibration curves computed.")
self.wizard.restoreCalibration()
} catch {
self.lastError = error.localizedDescription
self.wizard.showNotice(
"Calibration curve computation failed: \(error.localizedDescription)",
kind: .error
+36 -22
View File
@@ -29,13 +29,8 @@ final class FileDialogService {
/// `selectTargetFile` **save** panel for the new `.ti1` target.
func selectTargetFile(startingAt start: URL? = nil) -> URL? {
let panel = NSSavePanel()
panel.nameFieldStringValue = "target.ti1"
panel.allowedContentTypes = utTypes(["ti1"])
panel.allowsOtherFileTypes = false
panel.directoryURL = start
panel.message = "Choose the .ti1 target file to create"
return run(panel)
save(named: "target.ti1", extensions: ["ti1"], startingAt: start,
message: "Choose the .ti1 target file to create")
}
/// `selectExistingTarget` open `.ti1`/`.ti2` (docs/06 §Resume, #140).
@@ -66,12 +61,7 @@ final class FileDialogService {
/// `selectCsvSavePath` verification-history CSV export.
func selectCsvSavePath(startingAt start: URL? = nil) -> URL? {
let panel = NSSavePanel()
panel.nameFieldStringValue = "verification-history.csv"
panel.allowedContentTypes = utTypes(["csv"])
panel.allowsOtherFileTypes = false
panel.directoryURL = start
return run(panel)
save(named: "verification-history.csv", extensions: ["csv"], startingAt: start)
}
/// `selectCalFile` `.cal` calibration curves.
@@ -80,6 +70,19 @@ final class FileDialogService {
message: "Choose a calibration file (.cal)")
}
/// `selectGamutFile` `.gam` surface mesh for the compare slot (#147).
func selectGamutFile(startingAt start: URL? = nil) -> URL? {
open(extensions: ["gam"], startingAt: start,
message: "Choose a .gam surface mesh",
allowsOtherFileTypes: false)
}
/// `selectTiffFile` `.tif`/`.tiff` target page for gamut sampling (#147).
func selectTiffFile(startingAt start: URL? = nil) -> URL? {
open(extensions: ["tif", "tiff"], startingAt: start,
message: "Choose a target TIFF page")
}
/// `btnImportPreset` open a `.json` preset file.
func selectPresetFile(startingAt start: URL? = nil) -> URL? {
open(extensions: ["json"], startingAt: start,
@@ -88,28 +91,39 @@ final class FileDialogService {
/// `btnExportActivePreset` save a `.json` preset file.
func selectPresetSavePath(name: String, startingAt start: URL? = nil) -> URL? {
let panel = NSSavePanel()
panel.nameFieldStringValue = "\(name).json"
panel.allowedContentTypes = utTypes(["json"])
panel.allowsOtherFileTypes = false
panel.directoryURL = start
panel.message = "Export this preset as JSON"
return run(panel)
save(named: "\(name).json", extensions: ["json"], startingAt: start,
message: "Export this preset as JSON")
}
// MARK: - Internals (private not a shared public picker API)
private func save(
named: String,
extensions: [String],
startingAt start: URL?,
message: String? = nil
) -> URL? {
let panel = NSSavePanel()
panel.nameFieldStringValue = named
panel.allowedContentTypes = utTypes(extensions)
panel.allowsOtherFileTypes = false
panel.directoryURL = start
if let message { panel.message = message }
return run(panel)
}
private func open(
extensions: [String],
startingAt start: URL?,
message: String?
message: String?,
allowsOtherFileTypes: Bool = true
) -> URL? {
let panel = NSOpenPanel()
panel.canChooseDirectories = false
panel.canChooseFiles = true
panel.allowsMultipleSelection = false
panel.allowedContentTypes = utTypes(extensions)
panel.allowsOtherFileTypes = true
panel.allowsOtherFileTypes = allowsOtherFileTypes
panel.directoryURL = start
if let message { panel.message = message }
return run(panel)
+503 -78
View File
@@ -1,5 +1,6 @@
import SwiftUI
import SceneKit
import Metal
import ICCeryCore
import simd
@@ -62,77 +63,300 @@ internal struct GamutSceneGeometryBuilder {
}
}
/// Native SceneKit 3D gamut viewer.
/// Native SceneKit 3D gamut viewer (issues #28, #147).
///
/// Displays a profile gamut mesh and the bundled `sRGB.gam` reference. Uses
/// the CIELAB coordinate convention `x = a*`, `y = L*`, `z = b*` so that the
/// a* (green-red) axis is horizontal, L* (lightness) is vertical, and b*
/// (blue-yellow) is depth.
/// Displays the bundled `sRGB.gam` reference plus up to two profile
/// meshes with independent visibility toggles, a status line, and an
/// inspect panel (click a mesh, type a Lab value, or sample a TIFF
/// pixel). Uses the CIELAB coordinate convention `x = a*`, `y = L*`,
/// `z = b*`.
struct GamutView: View {
@State private var viewModel: GamutViewModel
@StateObject private var viewModel: GamutViewModel
@State private var pause: () -> Void = {}
@FocusState private var isFocused: Bool
@Binding var showingAllHelp: Bool
init(profileGamURL: URL? = nil) {
_viewModel = State(wrappedValue: GamutViewModel(profileGamURL: profileGamURL))
init(
environment: AppEnvironment,
profileGamURL: URL? = nil,
showingAllHelp: Binding<Bool>
) {
_viewModel = StateObject(wrappedValue: GamutViewModel(
environment: environment, profileGamURL: profileGamURL))
_showingAllHelp = showingAllHelp
}
var body: some View {
ZStack {
GamutSceneView(
profileMesh: viewModel.profileMesh,
referenceMesh: viewModel.sRGBMesh,
onReset: $viewModel.resetCamera,
onPause: $pause
)
.focusable()
.focused($isFocused)
.focusEffectDisabled()
.onKeyPress(.init("R"), action: {
viewModel.resetCamera()
return .handled
})
.onAppear { isFocused = true }
VStack {
HStack {
Spacer()
Button(action: { viewModel.resetCamera() }) {
Text("Reset view")
}
.accessibilityIdentifier("btnResetGamutCamera")
.padding(8)
}
Spacer()
HStack {
Text(viewModel.status)
.font(.caption)
.padding(8)
.background(.thinMaterial)
.cornerRadius(6)
.accessibilityIdentifier("gamutStatusText")
Spacer()
}
.padding(8)
}
VStack(spacing: 0) {
toolbar
Divider().overlay(Theme.border)
sceneArea
Divider().overlay(Theme.border)
statusLine
inspectPanel
}
.frame(minWidth: 500, minHeight: 400)
.frame(minWidth: 720, minHeight: 520)
.background(Theme.background)
.onDisappear { pause() }
.accessibilityElement(children: .contain)
.accessibilityIdentifier("gamutView")
.sheet(isPresented: $viewModel.showingTiffPreview) {
tiffPreviewSheet
}
}
// MARK: - Toolbar
private var toolbar: some View {
HStack(spacing: 12) {
layerToggle(id: GamutViewModel.srgbLayerID, fallback: "sRGB")
layerToggle(id: GamutViewModel.profileLayerID, fallback: "Profile")
layerToggle(id: GamutViewModel.compareLayerID, fallback: "Compare")
Spacer()
addCompareMenu
Button("Remove compare") { viewModel.removeCompare() }
.disabled(viewModel.layer(id: GamutViewModel.compareLayerID) == nil)
.accessibilityIdentifier("btnGamutRemoveCompare")
Button("Sample TIFF…") { viewModel.openTiffSample() }
.accessibilityIdentifier("btnGamutSampleTiff")
.helpOverlay(
"Sample a colour from a target TIFF page.",
showing: $showingAllHelp)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
private func layerToggle(id: String, fallback: String) -> some View {
let layer = viewModel.layer(id: id)
return Toggle(isOn: Binding(
get: { layer != nil && viewModel.visibleIDs.contains(id) },
set: { on in
if on {
viewModel.visibleIDs.insert(id)
} else {
viewModel.visibleIDs.remove(id)
}
}
)) {
Text(layer?.displayName ?? fallback)
}
.toggleStyle(.checkbox)
.disabled(layer == nil || viewModel.viewerUnavailable)
.help(layer.map { $0.sourceURL.lastPathComponent } ?? "No profile .gam loaded")
.accessibilityIdentifier("gamutLayer-\(id)")
}
private var addCompareMenu: some View {
Menu("Add compare…") {
Button("Open .gam…") { viewModel.openCompareGam() }
.accessibilityIdentifier("btnGamutOpenGam")
Button("Open profile…") { viewModel.openCompareProfile() }
.accessibilityIdentifier("btnGamutOpenProfile")
}
.accessibilityIdentifier("btnGamutAddCompare")
.helpOverlay(
"Add a second profile or .gam mesh to compare against.",
showing: $showingAllHelp)
}
// MARK: - Scene
private var sceneArea: some View {
ZStack(alignment: .topTrailing) {
if viewModel.viewerUnavailable {
Text("3D gamut viewer is unavailable on this Mac; the rest of ICCery still works.")
.font(.callout)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.accessibilityIdentifier("gamutViewerUnavailable")
} else {
GamutSceneView(
layers: viewModel.layers,
visibleIDs: viewModel.visibleIDs,
onReset: $viewModel.resetCamera,
onPause: $pause,
onUnavailable: { viewModel.viewerUnavailable = true },
onInspect: { point, layerID in
if let layerID {
viewModel.inspectSceneHit(world: point, layerID: layerID)
} else {
viewModel.clearInspect()
}
}
)
.focusable()
.focused($isFocused)
.onAppear { isFocused = true }
}
Button(action: { viewModel.resetCamera() }) {
Text("Reset view")
}
.accessibilityIdentifier("btnResetGamutCamera")
.padding(8)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
// MARK: - Status
private var statusLine: some View {
HStack(spacing: 10) {
Text(viewModel.status)
.font(.caption)
.foregroundStyle(Theme.text)
.accessibilityIdentifier("gamutStatusText")
if let notice = viewModel.noticeText {
Text(notice)
.font(.caption)
.foregroundStyle(.secondary)
.accessibilityIdentifier("gamutNoticeText")
}
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
}
// MARK: - Inspect panel
private var inspectPanel: some View {
HStack(spacing: 12) {
if let lab = viewModel.inspectLab {
inspectSwatch
labReadout(lab)
containmentColumn
if viewModel.inspectIsApproximate {
Text("approx. Lab, not ColorSync")
.font(.caption2)
.foregroundStyle(.secondary)
.accessibilityIdentifier("gamutInspectApprox")
}
} else {
Text("Click the mesh, or enter Lab, to inspect.")
.font(.caption)
.foregroundStyle(.secondary)
.accessibilityIdentifier("gamutInspectIdle")
}
Spacer()
labEntryFields
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.frame(minHeight: 56)
.background(Theme.panel)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("gamutInspectPanel")
.helpOverlay(
"Inspect a Lab point against each loaded gamut.",
showing: $showingAllHelp)
}
@ViewBuilder
private var inspectSwatch: some View {
if let swatch = viewModel.inspectSwatch {
Color(red: swatch.r, green: swatch.g, blue: swatch.b)
.frame(width: 16, height: 16)
.clipShape(RoundedRectangle(cornerRadius: 2))
.overlay(RoundedRectangle(cornerRadius: 2).stroke(Theme.border))
.accessibilityIdentifier("gamutInspectSwatch")
}
}
private func labReadout(_ lab: LabColor) -> some View {
HStack(spacing: 10) {
Text(String(format: "L %.1f", lab.l))
.accessibilityIdentifier("gamutInspectL")
Text(String(format: "a %.1f", lab.a))
.accessibilityIdentifier("gamutInspectA")
Text(String(format: "b %.1f", lab.b))
.accessibilityIdentifier("gamutInspectB")
}
.font(.caption.monospacedDigit())
.foregroundStyle(Theme.text)
}
private var containmentColumn: some View {
HStack(spacing: 10) {
ForEach(viewModel.inspectResults, id: \.id) { result in
Text("\(result.name) \(containmentWord(result.containment))")
.font(.caption)
.foregroundStyle(Theme.text)
.accessibilityIdentifier("gamutInspect-\(result.id)")
}
}
}
private func containmentWord(_ containment: GamutContainment) -> String {
switch containment {
case .inside: return "in"
case .outside: return "out"
case .unknown: return "?"
}
}
private var labEntryFields: some View {
HStack(spacing: 6) {
Text("Lab 0100 · ±128")
.font(.caption2)
.foregroundStyle(.secondary)
TextField("L", text: $viewModel.labEntryL)
.textFieldStyle(.roundedBorder)
.frame(width: 56)
.accessibilityIdentifier("gamutLabEntryL")
TextField("a", text: $viewModel.labEntryA)
.textFieldStyle(.roundedBorder)
.frame(width: 56)
.accessibilityIdentifier("gamutLabEntryA")
TextField("b", text: $viewModel.labEntryB)
.textFieldStyle(.roundedBorder)
.frame(width: 56)
.accessibilityIdentifier("gamutLabEntryB")
Button("Inspect") { viewModel.inspectEnteredLab() }
.disabled(!viewModel.canInspectLab)
.accessibilityIdentifier("btnGamutInspectLab")
}
}
// MARK: - TIFF sample sheet
private var tiffPreviewSheet: some View {
VStack(spacing: 12) {
Text("Click a pixel to sample its colour.")
.font(.headline)
.foregroundStyle(Theme.text)
if let png = viewModel.tiffPreviewPNG {
TiffSampleImageView(pngData: png) { r, g, b in
viewModel.sampleTiffPixel(r: r, g: g, b: b)
}
.frame(minWidth: 320, minHeight: 240)
}
HStack {
Spacer()
Button("Cancel") { viewModel.showingTiffPreview = false }
.accessibilityIdentifier("btnCloseGamutTiffPreview")
}
}
.padding(16)
.background(Theme.background)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("gamutTiffPreview")
}
}
/// `NSViewRepresentable` wrapper around an `SCNView` that builds the scene from
/// one or two ``GamutMesh`` values.
/// `NSViewRepresentable` wrapper around an `SCNView` rendering one node
/// per ``NamedGamut`` layer.
///
/// Scene construction and camera reset are coordinated through a typed callback
/// binding owned by the view model.
/// Layer toggles hide/show `SCNNode`s the scene is built once and the
/// camera is only reset through the explicit reset path, never on a
/// mesh or visibility update.
private struct GamutSceneView: NSViewRepresentable {
var profileMesh: GamutMesh?
var referenceMesh: GamutMesh?
var layers: [NamedGamut]
var visibleIDs: Set<String>
var onReset: Binding<() -> Void>
var onPause: Binding<() -> Void>
var onUnavailable: () -> Void
var onInspect: (SIMD3<Float>, String?) -> Void
func makeNSView(context: Context) -> SCNView {
let scnView = SCNView()
@@ -147,13 +371,23 @@ private struct GamutSceneView: NSViewRepresentable {
context.coordinator.scnView = scnView
context.coordinator.scene = scene
context.coordinator.buildScene(profile: profileMesh, reference: referenceMesh)
context.coordinator.onInspect = onInspect
context.coordinator.buildSceneOnce()
context.coordinator.syncLayers(layers, visibleIDs: visibleIDs)
context.coordinator.installKeyMonitor()
context.coordinator.installClickGesture()
// No GPU the docs/18 fallback; never respawn the view in a loop.
if MTLCreateSystemDefaultDevice() == nil {
DispatchQueue.main.async { onUnavailable() }
}
return scnView
}
func updateNSView(_ nsView: SCNView, context: Context) {
context.coordinator.buildScene(profile: profileMesh, reference: referenceMesh)
context.coordinator.onInspect = onInspect
context.coordinator.syncLayers(layers, visibleIDs: visibleIDs)
}
func makeCoordinator() -> Coordinator {
@@ -168,6 +402,10 @@ private struct GamutSceneView: NSViewRepresentable {
}
static func dismantleNSView(_ nsView: SCNView, coordinator: Coordinator) {
coordinator.removeKeyMonitor()
if let click = coordinator.clickGesture {
nsView.removeGestureRecognizer(click)
}
nsView.isPlaying = false
}
@@ -175,10 +413,14 @@ private struct GamutSceneView: NSViewRepresentable {
final class Coordinator: NSObject {
weak var scnView: SCNView?
weak var scene: SCNScene?
var onInspect: (SIMD3<Float>, String?) -> Void = { _, _ in }
private(set) var clickGesture: NSClickGestureRecognizer?
private var keyMonitor: Any?
private let profileNode = SCNNode()
private let referenceGroup = SCNNode()
/// One `SCNNode` per loaded layer, keyed by `NamedGamut.id`.
private var layerNodes: [String: SCNNode] = [:]
private let axisNode = SCNNode()
private let layerGroup = SCNNode()
private let cameraNode: SCNNode = {
let node = SCNNode()
node.camera = SCNCamera()
@@ -186,33 +428,97 @@ private struct GamutSceneView: NSViewRepresentable {
return node
}()
func buildScene(profile: GamutMesh?, reference: GamutMesh?) {
guard let scene else { return }
/// Builds the static scene furniture exactly once axis
/// scaffold, lights, camera home. Layer content lives under
/// `layerGroup` and is managed by `syncLayers`.
func buildSceneOnce() {
guard let scene, scene.rootNode.childNodes.isEmpty else { return }
// Rebuild from scratch on every mesh change to avoid stale geometry.
scene.rootNode.childNodes.forEach { $0.removeFromParentNode() }
scene.rootNode.addChildNode(axisNode)
scene.rootNode.addChildNode(profileNode)
scene.rootNode.addChildNode(referenceGroup)
scene.rootNode.addChildNode(layerGroup)
scene.rootNode.addChildNode(cameraNode)
buildAxisScaffold()
if let profile {
profileNode.addChildNode(profileMeshNode(profile, name: "profile"))
} else {
profileNode.childNodes.forEach { $0.removeFromParentNode() }
}
if let reference {
referenceGroup.childNodes.forEach { $0.removeFromParentNode() }
referenceGroup.addChildNode(referenceMeshNode(reference))
}
addLights(to: scene)
resetCamera()
}
/// Reconciles the node set with `layers` and `visibleIDs`.
///
/// New layers get a node; removed layers lose theirs; hidden
/// layers keep their mesh (`isHidden` only). Never rebuilds the
/// scene, so the camera is untouched by a checkbox toggle.
func syncLayers(_ layers: [NamedGamut], visibleIDs: Set<String>) {
guard scene != nil else { return }
let wanted = Set(layers.map { $0.id })
for (id, node) in layerNodes where !wanted.contains(id) {
node.removeFromParentNode()
layerNodes.removeValue(forKey: id)
}
for layer in layers {
if layerNodes[layer.id] == nil {
let node = makeLayerNode(for: layer)
layerNodes[layer.id] = node
layerGroup.addChildNode(node)
}
layerNodes[layer.id]?.isHidden = !visibleIDs.contains(layer.id)
}
}
private func makeLayerNode(for layer: NamedGamut) -> SCNNode {
let node: SCNNode
switch layer.role {
case .reference:
node = referenceMeshNode(layer.mesh)
case .profileA:
node = profileMeshNode(layer.mesh)
case .profileB:
node = compareMeshNode(layer.mesh)
}
node.name = layer.id
return node
}
// MARK: - Click inspect (#147)
/// Click (not drag) hit-tests the scene. `NSClickGestureRecognizer`
/// only fires on a press+release in place, so orbit drags are
/// untouched.
func installClickGesture() {
guard let scnView, clickGesture == nil else { return }
let gesture = NSClickGestureRecognizer(target: self, action: #selector(handleClick(_:)))
scnView.addGestureRecognizer(gesture)
clickGesture = gesture
}
@objc private func handleClick(_ gesture: NSClickGestureRecognizer) {
guard let scnView else { return }
let point = gesture.location(in: scnView)
for hit in scnView.hitTest(point, options: nil) {
if let layerID = layerID(for: hit.node) {
let world = hit.worldCoordinates
onInspect(
SIMD3<Float>(Float(world.x), Float(world.y), Float(world.z)),
layerID)
return
}
}
// Axis scaffold / empty background back to idle.
onInspect(.zero, nil)
}
/// Walks the hit node's ancestor chain looking for a layer node.
private func layerID(for node: SCNNode) -> String? {
var current: SCNNode? = node
while let node = current {
if let name = node.name, layerNodes[name] != nil { return name }
current = node.parent
}
return nil
}
// MARK: - Scene furniture (unchanged from #28)
private func addLights(to scene: SCNScene) {
let ambient = SCNNode()
ambient.light = SCNLight()
@@ -353,7 +659,8 @@ private struct GamutSceneView: NSViewRepresentable {
return SCNNode(geometry: geometry)
}
private func profileMeshNode(_ mesh: GamutMesh, name: String) -> SCNNode {
/// Profile A: solid vertex-coloured surface.
private func profileMeshNode(_ mesh: GamutMesh) -> SCNNode {
let (geometry, _) = scnGeometry(for: mesh)
let material = SCNMaterial()
@@ -363,11 +670,26 @@ private struct GamutSceneView: NSViewRepresentable {
material.isDoubleSided = true
geometry.materials = [material]
let node = SCNNode(geometry: geometry)
node.name = name
return node
return SCNNode(geometry: geometry)
}
/// Compare profile B: same vertex colours at ~30 % opacity so
/// overlaps with A and the sRGB reference stay readable.
private func compareMeshNode(_ mesh: GamutMesh) -> SCNNode {
let (geometry, _) = scnGeometry(for: mesh)
let material = SCNMaterial()
material.lightingModel = .lambert
material.diffuse.contents = NSColor.white
material.transparency = 0.30
material.isDoubleSided = true
material.writesToDepthBuffer = false
geometry.materials = [material]
return SCNNode(geometry: geometry)
}
/// Bundled sRGB reference: faint fill + structural edge lines.
private func referenceMeshNode(_ mesh: GamutMesh) -> SCNNode {
let (geometry, _) = scnGeometry(for: mesh)
@@ -431,6 +753,31 @@ private struct GamutSceneView: NSViewRepresentable {
scnView?.isPlaying = false
}
/// Local key-down monitor for the R camera-reset shortcut (the
/// SwiftUI key-press modifier is unavailable on macOS 12). Only
/// events aimed at this view's window are handled; everything
/// else passes through untouched.
func installKeyMonitor() {
guard keyMonitor == nil else { return }
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) {
[weak self] event in
guard let self,
let scnView = self.scnView,
event.window === scnView.window,
event.charactersIgnoringModifiers?.uppercased() == "R"
else { return event }
self.resetCamera()
return nil
}
}
func removeKeyMonitor() {
if let keyMonitor {
NSEvent.removeMonitor(keyMonitor)
self.keyMonitor = nil
}
}
func resetCamera() {
guard let scnView else { return }
@@ -465,3 +812,81 @@ private struct GamutSceneView: NSViewRepresentable {
}
}
}
/// Click-to-sample image view for the TIFF preview sheet (#147).
///
/// The TIFF is already decoded to PNG on the host side (#58); the view
/// reports 8-bit sRGB pixel values at the clicked point the Lab
/// conversion is the documented approximate matrix helper, not a CMM.
private struct TiffSampleImageView: NSViewRepresentable {
let pngData: Data
var onSample: (Int, Int, Int) -> Void
func makeNSView(context: Context) -> TiffSampleNSView {
let view = TiffSampleNSView()
view.image = NSImage(data: pngData)
view.onSample = onSample
return view
}
func updateNSView(_ nsView: TiffSampleNSView, context: Context) {
nsView.onSample = onSample
}
}
private final class TiffSampleNSView: NSView {
var image: NSImage? {
didSet {
bitmapRep = image?.cgImage(forProposedRect: nil, context: nil, hints: nil)
.flatMap { NSBitmapImageRep(cgImage: $0) }
invalidateIntrinsicContentSize()
needsDisplay = true
}
}
var onSample: ((Int, Int, Int) -> Void)?
private var bitmapRep: NSBitmapImageRep?
override var intrinsicContentSize: NSSize {
image?.size ?? NSSize(width: 320, height: 240)
}
override var acceptsFirstResponder: Bool { true }
override func draw(_ dirtyRect: NSRect) {
NSColor(red: 0.055, green: 0.055, blue: 0.078, alpha: 1).setFill()
dirtyRect.fill()
guard let image else { return }
image.draw(in: imageRect())
}
override func mouseUp(with event: NSEvent) {
guard let rep = bitmapRep else { return }
let rect = imageRect()
let location = convert(event.locationInWindow, from: nil)
guard rect.contains(location), rect.width > 0, rect.height > 0 else { return }
let x = Int((location.x - rect.minX) / rect.width * CGFloat(rep.pixelsWide))
// This view is not flipped: y grows up, bitmap rows grow down.
let y = rep.pixelsHigh - 1
- Int((location.y - rect.minY) / rect.height * CGFloat(rep.pixelsHigh))
guard x >= 0, x < rep.pixelsWide, y >= 0, y < rep.pixelsHigh else { return }
guard let color = rep.colorAt(x: x, y: y)?.usingColorSpace(.sRGB) else { return }
onSample?(
Int((color.redComponent * 255).rounded()),
Int((color.greenComponent * 255).rounded()),
Int((color.blueComponent * 255).rounded()))
}
/// Aspect-fit rect of the image inside `bounds`.
private func imageRect() -> NSRect {
guard let image, image.size.width > 0, image.size.height > 0 else { return .zero }
let scale = min(bounds.width / image.size.width, bounds.height / image.size.height)
let size = NSSize(width: image.size.width * scale, height: image.size.height * scale)
return NSRect(
x: (bounds.width - size.width) / 2,
y: (bounds.height - size.height) / 2,
width: size.width,
height: size.height)
}
}
+276 -27
View File
@@ -1,54 +1,303 @@
import Combine
import Foundation
import ICCeryCore
import Observation
import simd
/// View model for the native SceneKit gamut viewer.
/// View model for the native SceneKit gamut viewer (issues #28, #147).
///
/// Loads the bundled `sRGB.gam` reference immediately and, optionally, a
/// printer/profile `.gam` from the current working directory.
/// Loads the bundled `sRGB.gam` reference immediately, the workflow's own
/// profile `.gam` when one exists, and an optional compare mesh the user
/// adds from the sheet toolbar. `iccgamut` failure is an in-sheet info
/// notice, never fatal (#24).
@MainActor
@Observable
final class GamutViewModel {
final class GamutViewModel: ObservableObject {
/// Parsed reference sRGB gamut mesh.
var sRGBMesh: GamutMesh?
/// Stable layer ids also the `gamutLayer-<id>` a11y suffixes.
static let srgbLayerID = "sRGB"
static let profileLayerID = "profile"
static let compareLayerID = "compare"
/// Parsed printer/profile gamut mesh.
var profileMesh: GamutMesh?
/// Loaded meshes: bundled sRGB plus up to two profiles.
@Published var layers: [NamedGamut] = []
/// User-facing status line.
var status = "Loading gamut…"
/// Layer ids currently shown in the scene. Toggling never unloads
/// the mesh the `SCNNode` is hidden only.
@Published var visibleIDs: Set<String> = [srgbLayerID]
/// User-facing status line (`gamutStatusText`). Always non-empty
/// once set `Milestone6GamutUITests` asserts it.
@Published var status = "Loading gamut…"
/// In-sheet info line (`gamutNoticeText`). The main `NoticeBanner`
/// sits behind the sheet, so notices surface here instead.
@Published var noticeText: String?
/// Set when `SCNView` cannot create a render context; the scene is
/// replaced by the docs/18 fallback text (`gamutViewerUnavailable`).
@Published var viewerUnavailable = false
/// Closure injected into the SceneKit view to request a camera reset.
var resetCamera: () -> Void = {}
@Published var resetCamera: () -> Void = {}
// MARK: - Inspect panel
/// Lab point currently inspected, or `nil` for the idle state.
@Published var inspectLab: LabColor?
/// Swatch colour: the hit vertex's `rgb`, or the approximate sRGB of
/// the inspected Lab.
@Published var inspectSwatch: DisplayRGB?
/// `true` when the swatch/Lab came from the approximate helper or a
/// typed value drives the "approx. Lab, not ColorSync" caption.
@Published var inspectIsApproximate = false
/// Per-layer containment for `inspectLab`, in layer order.
@Published var inspectResults: [(id: String, name: String, containment: GamutContainment)] = []
/// Manual Lab entry fields (`gamutLabEntry*`).
@Published var labEntryL = ""
@Published var labEntryA = ""
@Published var labEntryB = ""
// MARK: - TIFF sampling
/// PNG bytes for the preview sheet (`gamutTiffPreview`).
@Published var tiffPreviewPNG: Data?
@Published var showingTiffPreview = false
private let environment: AppEnvironment
private let profileGamURL: URL?
private let fileDialogs = FileDialogService.shared
init(profileGamURL: URL? = nil) {
init(environment: AppEnvironment, profileGamURL: URL? = nil) {
self.environment = environment
self.profileGamURL = profileGamURL
Task { await load() }
loadTask = Task { await load() }
}
private var loadTask: Task<Void, Never>?
/// Awaits the initial sRGB/profile load used by tests.
func awaitInitialLoad() async {
await loadTask?.value
}
func layer(id: String) -> NamedGamut? {
layers.first { $0.id == id }
}
// MARK: - Initial load
private func load() async {
do {
let referenceURL = BinaryResolver().referenceGamut("sRGB")
let referenceURL = environment.runner.binaryResolver.referenceGamut("sRGB")
let reference = try await parse(url: referenceURL)
sRGBMesh = reference
if let profileGamURL {
let profile = try await parse(url: profileGamURL)
profileMesh = profile
status = "Profile gamut (\(profile.faces.count) faces) vs sRGB reference"
} else {
status = "sRGB reference gamut (\(reference.faces.count) faces)"
}
layers.append(NamedGamut(
id: Self.srgbLayerID,
displayName: "sRGB",
role: .reference,
mesh: reference,
sourceURL: referenceURL))
visibleIDs.insert(Self.srgbLayerID)
} catch {
status = "Could not load gamut: \(error.localizedDescription)"
return
}
if let profileGamURL {
do {
let profile = try await parse(url: profileGamURL)
layers.append(NamedGamut(
id: Self.profileLayerID,
displayName: profileGamURL.deletingPathExtension().lastPathComponent,
role: .profileA,
mesh: profile,
sourceURL: profileGamURL))
visibleIDs.insert(Self.profileLayerID)
} catch {
// #24 a missing/unparseable profile mesh is info, not fatal.
noticeText = "Profile gamut could not be loaded: \(error.localizedDescription)"
}
}
refreshStatus()
}
// MARK: - Compare slot (profile B)
/// `btnGamutOpenGam` pick an existing `.gam` for the compare slot.
func openCompareGam() {
let url = UITestHooks.isEnabled
? UITestHooks.gamutFileURL
: fileDialogs.selectGamutFile()
guard let url else { return }
Task { await loadCompareGam(url: url) }
}
/// `btnGamutOpenProfile` pick `.icc/.icm`; uses a sibling `.gam`
/// when present, otherwise runs bundled `iccgamut -v -d 10` (#24).
func openCompareProfile() {
let url = UITestHooks.isEnabled
? UITestHooks.gamutProfileURL
: fileDialogs.selectProfileFile()
guard let url else { return }
Task { await loadCompareProfile(url: url) }
}
/// `btnGamutRemoveCompare` drops layer B, leaves sRGB + A.
func removeCompare() {
layers.removeAll { $0.id == Self.compareLayerID }
visibleIDs.remove(Self.compareLayerID)
refreshStatus()
}
/// Parses `url` into the compare slot. Internal for tests the UI
/// reaches it through `openCompareGam` / `openCompareProfile`.
func loadCompareGam(url: URL) async {
do {
let mesh = try await parse(url: url)
installCompare(NamedGamut(
id: Self.compareLayerID,
displayName: url.deletingPathExtension().lastPathComponent,
role: .profileB,
mesh: mesh,
sourceURL: url))
} catch {
noticeText = "Could not load compare gamut: \(error.localizedDescription)"
}
}
/// Parses a `.gam` file off the main actor so large meshes do not stall
/// the UI.
/// `.icc/.icm` sibling `.gam` or `iccgamut` compare slot.
/// Internal for tests.
func loadCompareProfile(url: URL) async {
let gamURL = url.deletingPathExtension().appendingPathExtension("gam")
do {
if !FileManager.default.fileExists(atPath: gamURL.path) {
_ = try await environment.runner.runIccgamut(
config: IccgamutConfig(profileURL: url))
}
await loadCompareGam(url: gamURL)
} catch {
noticeText = "Gamut extraction failed: \(error.localizedDescription)"
}
}
/// A third profile replaces B the compare slot never stacks and
/// sRGB is never touched.
private func installCompare(_ gamut: NamedGamut) {
if layers.contains(where: { $0.id == gamut.id }) {
noticeText = "Compare slot holds one profile. The previous compare mesh was replaced."
}
layers.removeAll { $0.id == gamut.id }
layers.append(gamut)
visibleIDs.insert(gamut.id)
refreshStatus()
}
// MARK: - Status line
/// `sRGB 448v / 892 faces · Profile 1024v / 2048 faces · vol 62% of sRGB`.
/// Unloaded layers are omitted; the volume clause appears only when
/// both volumes are finite and positive.
private func refreshStatus() {
var clauses = layers.map {
"\($0.displayName) \($0.mesh.vertices.count)v / \($0.mesh.faces.count) faces"
}
if let srgb = layer(id: Self.srgbLayerID),
let profile = layer(id: Self.profileLayerID) {
let srgbVolume = GamutGeometry.volume(of: srgb.mesh)
let profileVolume = GamutGeometry.volume(of: profile.mesh)
if srgbVolume.isFinite, srgbVolume > 0,
profileVolume.isFinite, profileVolume > 0 {
clauses.append("vol \(Int((profileVolume / srgbVolume * 100).rounded()))% of sRGB")
}
}
status = clauses.isEmpty ? "No gamut loaded" : clauses.joined(separator: " · ")
}
// MARK: - Inspect
/// Whether every manual Lab field parses as a number; the Inspect
/// button is disabled while this is false.
var canInspectLab: Bool {
[labEntryL, labEntryA, labEntryB].allSatisfy { Double($0) != nil }
}
/// Runs containment for a Lab point and publishes the inspect row.
func inspect(lab: LabColor, swatch: DisplayRGB?, isApproximate: Bool) {
inspectLab = lab
inspectSwatch = swatch ?? LabColorMath.labToSRGB(lab)
inspectIsApproximate = isApproximate
inspectResults = layers.map {
($0.id, $0.displayName, GamutGeometry.containment(of: lab, in: $0.mesh))
}
}
/// Click on the axis scaffold or empty background returns the panel
/// to idle.
func clearInspect() {
inspectLab = nil
inspectSwatch = nil
inspectIsApproximate = false
inspectResults = []
}
/// SceneKit hit callback: world `(x, y, z)` Lab `(xa*, yL*, zb*)`.
/// The swatch is the nearest vertex colour of the hit layer's mesh.
func inspectSceneHit(world: SIMD3<Float>, layerID: String) {
let lab = LabColor(l: Double(world.y), a: Double(world.x), b: Double(world.z))
var swatch: DisplayRGB?
var approximate = true
if let mesh = layer(id: layerID)?.mesh,
let nearest = mesh.vertices.min(by: {
simd_distance($0.position, world) < simd_distance($1.position, world)
}) {
swatch = nearest.rgb
approximate = false
}
inspect(lab: lab, swatch: swatch, isApproximate: approximate)
}
/// `btnGamutInspectLab` typed L*a*b* path. No clamping; out-of-axis
/// values still run containment and report `?` outside every hull.
func inspectEnteredLab() {
guard let l = Double(labEntryL),
let a = Double(labEntryA),
let b = Double(labEntryB) else { return }
inspect(lab: LabColor(l: l, a: a, b: b), swatch: nil, isApproximate: true)
}
// MARK: - TIFF sampling
/// `btnGamutSampleTiff` pick a target TIFF, decode a host-side PNG
/// preview (#58), and open the click-to-sample sheet.
func openTiffSample() {
let url = UITestHooks.isEnabled
? UITestHooks.gamutTiffURL
: fileDialogs.selectTiffFile()
guard let url else { return }
guard let png = TiffPreview.previewPNG(tiff: url) else {
noticeText = "Could not decode TIFF preview."
return
}
tiffPreviewPNG = png
showingTiffPreview = true
}
/// Pixel tap inside the preview sheet: sRGB8 approximate Lab D50.
func sampleTiffPixel(r: Int, g: Int, b: Int) {
inspect(
lab: ApproximateLab.srgb8ToLab(r: r, g: g, b: b),
swatch: DisplayRGB(
r: Double(r) / 255.0,
g: Double(g) / 255.0,
b: Double(b) / 255.0),
isApproximate: true)
showingTiffPreview = false
}
/// Parses a `.gam` file off the main actor so large meshes do not
/// stall the UI.
private func parse(url: URL) async throws -> GamutMesh {
try await Task.detached {
try GamutMeshParser.parse(url: url)
+34 -7
View File
@@ -5,11 +5,11 @@ import SwiftUI
@main
struct ICCeryApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@State private var workflow: TargetWorkflowViewModel
@StateObject private var workflow: TargetWorkflowViewModel
init() {
let environment = AppEnvironment.live()
_workflow = State(initialValue: TargetWorkflowViewModel(environment: environment))
_workflow = StateObject(wrappedValue: TargetWorkflowViewModel(environment: environment))
try? AppPaths.ensureDirectories()
// Log level is runtime state apply persisted settings at
// startup (#158); the Settings sheet re-applies on save.
@@ -17,15 +17,17 @@ struct ICCeryApp: App {
}
var body: some Scene {
// Single fixed window (docs/21 §Shell: 1280×800, min 1100×700).
Window("ICCery", id: "main") {
// Single fixed window (docs/21 §Shell: 1280×800, min 1100×700);
// metrics are applied by AppDelegate once the window exists.
WindowGroup("ICCery") {
RootView(workflow: workflow)
.frame(minWidth: 1100, minHeight: 700)
.preferredColorScheme(.dark)
}
.defaultSize(width: 1280, height: 800)
.windowResizability(.contentMinSize)
.defaultPosition(.center)
.commands {
// Single-window app: no File > New window.
CommandGroup(replacing: .newItem) {}
}
}
}
@@ -36,6 +38,31 @@ struct ICCeryApp: App {
final class AppDelegate: NSObject, NSApplicationDelegate {
private var terminationRequested = false
func applicationDidFinishLaunching(_ notification: Notification) {
// SwiftUI scenes launched by XCTest stay `.runningBackground`
// unless the app takes regular activation and orders the window
// front (CI run 29804).
NSApp.setActivationPolicy(.regular)
for window in NSApp.windows {
configureMainWindow(window)
window.makeKeyAndOrderFront(nil)
}
NSApp.activate(ignoringOtherApps: true)
}
/// docs/21 §Shell: 1280×800 content, min 1100×700, centred.
private func configureMainWindow(_ window: NSWindow) {
window.setContentSize(NSSize(width: 1280, height: 800))
window.contentMinSize = NSSize(width: 1100, height: 700)
window.center()
}
/// Dock-click reopen: let the WindowGroup re-show or recreate the
/// main window when none are visible.
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
true
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
true
}
@@ -1,5 +1,5 @@
import Combine
import Foundation
import Observation
import SwiftUI
import ICCeryCore
@@ -32,8 +32,7 @@ enum XYStep: Equatable, Sendable {
/// Stage 3 workflow state and interaction (issues #18#22).
@MainActor
@Observable
final class MeasurementWorkflowViewModel {
final class MeasurementWorkflowViewModel: ObservableObject {
// MARK: - Authorities
@@ -42,37 +41,37 @@ final class MeasurementWorkflowViewModel {
// MARK: - Settings-driven thresholds
private(set) var goodMax: Double = 2.0
private(set) var warningMax: Double = 5.0
private(set) var enableLEDs: Bool = false
@Published private(set) var goodMax: Double = 2.0
@Published private(set) var warningMax: Double = 5.0
@Published private(set) var enableLEDs: Bool = false
// MARK: - Instrument detection
var instruments: [InstrumentDevice] = []
var selectedInstrument: InstrumentSelection = .auto
var isDetecting = false
var detectionError: String?
@Published var instruments: [InstrumentDevice] = []
@Published var selectedInstrument: InstrumentSelection = .auto
@Published var isDetecting = false
@Published var detectionError: String?
// MARK: - Chartread session
var isChartreadRunning = false
var chartreadState: ChartreadState = .idle
var currentPrompt: String?
var requestedWarningKey: String?
var chartreadLog: [String] = []
var rows: [ChartreadRow] = []
var swatchRows: [SwatchRow] = []
var showRemoveSheetNotice = false
var lastError: String?
@Published var isChartreadRunning = false
@Published var chartreadState: ChartreadState = .idle
@Published var currentPrompt: String?
@Published var requestedWarningKey: String?
@Published var chartreadLog: [String] = []
@Published var rows: [ChartreadRow] = []
@Published var swatchRows: [SwatchRow] = []
@Published var showRemoveSheetNotice = false
/// Stage-local chartread error notice (`#chartreadLastError`, #80).
@Published var chartreadNotice: Notice?
private var chartreadTask: Task<Void, Never>?
// MARK: - Averaging
var passSnapshots: [URL] = []
var isFinishing = false
var finishNotice: String?
var finishNoticeIsError = false
var resumedFromTi2 = false
@Published var passSnapshots: [URL] = []
@Published var isFinishing = false
@Published var finishNotice: Notice?
@Published var resumedFromTi2 = false
init(wizard: WizardViewModel, environment: AppEnvironment) {
self.wizard = wizard
@@ -186,7 +185,7 @@ final class MeasurementWorkflowViewModel {
isChartreadRunning = true
chartreadState = .idle
currentPrompt = nil
lastError = nil
chartreadNotice = nil
chartreadLog.removeAll()
// Optional: reset rows when starting a fresh first pass.
@@ -228,7 +227,10 @@ final class MeasurementWorkflowViewModel {
case .exit(let code):
if code != 0 {
lastError = "chartread exited with code \(code)"
chartreadNotice = Notice(
kind: .error,
text: "chartread exited with code \(code)"
)
}
case .completed(let canonicalURL):
@@ -236,7 +238,7 @@ final class MeasurementWorkflowViewModel {
completePass(canonicalURL: canonicalURL)
case .failed(let error):
lastError = error.localizedDescription
chartreadNotice = Notice(kind: .error, text: error.localizedDescription)
chartreadState = .error
isChartreadRunning = false
}
@@ -382,7 +384,10 @@ final class MeasurementWorkflowViewModel {
discoverPassSnapshots()
wizard.refreshGating()
} catch {
lastError = "Could not snapshot pass: \(error.localizedDescription)"
chartreadNotice = Notice(
kind: .error,
text: "Could not snapshot pass: \(error.localizedDescription)"
)
}
}
@@ -398,33 +403,32 @@ final class MeasurementWorkflowViewModel {
func finishAndAverage() {
guard !isFinishing, let cwd = workingDirectory, !passSnapshots.isEmpty else { return }
isFinishing = true
finishNotice = nil
finishNoticeIsError = false
Task { @MainActor [weak self] in
guard let self else { return }
do {
let canonical: URL
if self.passSnapshots.count == 1, let pass = self.passSnapshots.first {
canonical = try MeasurementArtefacts.promotePass(
pass: pass,
basename: self.basename,
cwd: cwd
)
} else {
// No log reset: prior chartread output must be preserved.
let canonical = try await ProcessRunSupport.runLogged(
setRunning: { self.isFinishing = $0 },
resetLog: {},
onLog: { self.chartreadLog.append(contentsOf: $0) }
) { onLog in
if self.passSnapshots.count == 1, let pass = self.passSnapshots.first {
return try MeasurementArtefacts.promotePass(
pass: pass,
basename: self.basename,
cwd: cwd
)
}
let config = AverageConfig(
workingDirectory: cwd,
basename: self.basename,
passFiles: self.passSnapshots
)
canonical = try await self.environment.runner.runAverage(
return try await self.environment.runner.runAverage(
config: config,
onLogBatch: { [weak self] batch in
Task { @MainActor [weak self] in
self?.chartreadLog.append(contentsOf: batch)
}
}
onLogBatch: onLog
)
}
self.discoverPassSnapshots()
@@ -432,7 +436,11 @@ final class MeasurementWorkflowViewModel {
if self.wizard.isUnlocked(.buildProfile) {
self.wizard.go(to: .buildProfile)
} else {
self.finishNotice = "Finished: \(canonical.lastPathComponent) ready."
self.finishNotice = Notice(
kind: .info,
text: "Finished: \(canonical.lastPathComponent) ready.",
autoHideAfter: nil
)
}
} catch {
// Fallback to pass 1 promotion if averaging failed.
@@ -445,18 +453,26 @@ final class MeasurementWorkflowViewModel {
)
self.discoverPassSnapshots()
self.wizard.refreshGating()
self.finishNotice = "Averaging failed — promoted first pass."
self.finishNoticeIsError = true
self.finishNotice = Notice(
kind: .error,
text: "Averaging failed — promoted first pass.",
autoHideAfter: nil
)
} catch {
self.finishNotice = "Finish failed: \(error.localizedDescription)"
self.finishNoticeIsError = true
self.finishNotice = Notice(
kind: .error,
text: "Finish failed: \(error.localizedDescription)",
autoHideAfter: nil
)
}
} else {
self.finishNotice = "Finish failed: \(error.localizedDescription)"
self.finishNoticeIsError = true
self.finishNotice = Notice(
kind: .error,
text: "Finish failed: \(error.localizedDescription)",
autoHideAfter: nil
)
}
}
self.isFinishing = false
}
}
}
+255
View File
@@ -0,0 +1,255 @@
import SwiftUI
import ICCeryCore
/// `#saveMediaRecipeDialog` capture the current printer + paper +
/// ink + `.cal` bound to the selected preset (issue #146). Clones
/// `SavePresetDialog` chrome; names render via `Text` only (#114).
struct SaveMediaRecipeDialog: View {
@ObservedObject var workflow: TargetWorkflowViewModel
/// Observed directly: nested ObservableObjects are not tracked
/// through the parent's `objectWillChange`.
@ObservedObject private var media: MediaLibraryViewModel
@ObservedObject private var printSession: PrintSessionViewModel
init(workflow: TargetWorkflowViewModel) {
self.workflow = workflow
self._media = ObservedObject(wrappedValue: workflow.media)
self._printSession = ObservedObject(wrappedValue: workflow.print)
}
private var printerCaption: String {
let queue = printSession.selectedPrinter
guard !queue.isEmpty else { return "None" }
let display = printSession.printers
.first { $0.name == queue }?.displayName ?? queue
return "\(display) (\(queue))"
}
private func captureRow(
_ label: String, value: String, identifier: String
) -> some View {
HStack {
Text(label).foregroundStyle(.secondary)
Spacer()
Text(value)
.foregroundStyle(Theme.text)
.lineLimit(1)
.truncationMode(.middle)
.accessibilityIdentifier(identifier)
}
}
private var saveDisabled: Bool {
media.saveMediaName.trimmingCharacters(in: .whitespaces).isEmpty
|| media.saveMediaPaper.trimmingCharacters(in: .whitespaces).isEmpty
|| media.saveMediaInk.trimmingCharacters(in: .whitespaces).isEmpty
|| media.captureColourSpaceMismatch
}
var body: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Save Media Recipe").font(.title3).foregroundStyle(Theme.text)
TextField("Name", text: $media.saveMediaName)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("saveMediaName")
TextField("Notes (optional)", text: $media.saveMediaNotes)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("saveMediaNotes")
TextField("Paper", text: $media.saveMediaPaper)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("saveMediaPaper")
TextField("Ink set", text: $media.saveMediaInk)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("saveMediaInk")
captureRow("Printer", value: printerCaption,
identifier: "saveMediaPrinter")
captureRow("Preset",
value: workflow.selectedPreset?.name ?? "No preset",
identifier: "saveMediaPreset")
captureRow("Colour space",
value: workflow.colourSpace.rawValue.uppercased(),
identifier: "saveMediaColourSpace")
captureRow("Calibration",
value: workflow.profile.calibrationFile.isEmpty
? "None" : workflow.profile.calibrationFile,
identifier: "saveMediaCal")
Toggle("Apply calibration to profile",
isOn: $media.saveMediaApplyCal)
.disabled(!media.calApplyable)
.accessibilityIdentifier("saveMediaApplyCal")
if media.captureColourSpaceMismatch {
Text("Colour space does not match the selected preset.")
.font(.caption).foregroundStyle(.orange)
}
if let error = media.saveMediaError {
Text(error).font(.caption).foregroundStyle(.orange)
}
HStack {
Spacer()
Button("Cancel") { workflow.showingSaveMedia = false }
.accessibilityIdentifier("btnCloseSaveMediaDialog")
Button("Save") {
Task {
if await media.captureFromSession() {
workflow.showingSaveMedia = false
}
}
}
.disabled(saveDisabled)
.accessibilityIdentifier("btnConfirmSaveMedia")
}
}
.padding(20)
.frame(width: 380)
.background(Theme.background)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("saveMediaRecipeDialog")
}
}
/// `#manageMediaDialog` list, apply, delete, capture (issue #146).
/// `List`, not `Table` macOS 12 target. Clones `ManagePresetsDialog`.
struct ManageMediaDialog: View {
@ObservedObject var workflow: TargetWorkflowViewModel
/// Observed directly: nested ObservableObjects are not tracked
/// through the parent's `objectWillChange`.
@ObservedObject private var media: MediaLibraryViewModel
@State private var selection: String?
@State private var pendingDelete: MediaRecipe?
init(workflow: TargetWorkflowViewModel) {
self.workflow = workflow
self._media = ObservedObject(wrappedValue: workflow.media)
}
private func presetCaption(for recipe: MediaRecipe) -> String {
workflow.presets.first { $0.id == recipe.presetID }?.name
?? "Missing preset"
}
private func calCaption(for recipe: MediaRecipe) -> String {
if media.staleReasons[recipe.id]?.contains(.calibration) == true {
return "Stale"
}
if let days = media.calAgeDays[recipe.id] {
return "Cal \(days)d"
}
return "No cal"
}
private func applyAndDismiss(_ recipe: MediaRecipe) {
Task {
if await media.apply(recipe) {
workflow.showingManageMedia = false
}
}
}
private func presetMissing(_ recipe: MediaRecipe) -> Bool {
!workflow.presets.contains { $0.id == recipe.presetID }
}
private func calStale(_ recipe: MediaRecipe) -> Bool {
media.staleReasons[recipe.id]?.contains(.calibration) == true
}
@ViewBuilder
private func row(_ recipe: MediaRecipe) -> some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(recipe.name).foregroundStyle(Theme.text)
Text("\(recipe.printerDisplayName) · \(recipe.paperName) · \(recipe.inkSet)")
.font(.caption).foregroundStyle(.secondary)
HStack(spacing: 8) {
Text(presetCaption(for: recipe))
.font(.caption)
.foregroundStyle(presetMissing(recipe) ? .orange : .secondary)
Text(calCaption(for: recipe))
.font(.caption)
.foregroundStyle(calStale(recipe) ? .orange : .secondary)
}
}
Spacer()
Button("Apply") { applyAndDismiss(recipe) }
.accessibilityIdentifier("btnMediaLibraryApply-\(recipe.id)")
Button("Delete", role: .destructive) {
pendingDelete = recipe
}
.accessibilityIdentifier("btnMediaLibraryDelete-\(recipe.id)")
}
.accessibilityElement(children: .contain)
.accessibilityIdentifier("mediaRow-\(recipe.id)")
.tag(recipe.id)
.contentShape(Rectangle())
.simultaneousGesture(
TapGesture(count: 2).onEnded { applyAndDismiss(recipe) }
)
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Manage Media Recipes").font(.title3).foregroundStyle(Theme.text)
List(selection: $selection) {
if media.recipes.isEmpty {
Text("No media recipes yet. Capture the current printer, paper and preset.")
.font(.callout).foregroundStyle(.secondary)
.accessibilityIdentifier("mediaLibraryEmpty")
}
ForEach(media.recipes) { recipe in
row(recipe)
}
}
.accessibilityIdentifier("mediaLibraryList")
.frame(minHeight: 260)
HStack {
Button("Apply selected") {
if let id = selection,
let recipe = media.recipes.first(where: { $0.id == id }) {
applyAndDismiss(recipe)
}
}
.disabled(selection == nil)
.keyboardShortcut(.defaultAction)
.accessibilityIdentifier("btnMediaLibraryApply")
Button("Capture current…") {
media.captureAfterManageDismiss = true
workflow.showingManageMedia = false
}
.accessibilityIdentifier("btnMediaLibraryCaptureFromManage")
Spacer()
Button("Close") { workflow.showingManageMedia = false }
.accessibilityIdentifier("btnCloseManageMediaDialog")
}
}
.padding(20)
.frame(width: 640)
.background(Theme.background)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("manageMediaDialog")
.onAppear {
media.reload()
media.refreshStaleness()
}
.alert(
"Delete media recipe?",
isPresented: Binding(
get: { pendingDelete != nil },
set: { if !$0 { pendingDelete = nil } }
),
presenting: pendingDelete
) { recipe in
Button("Cancel", role: .cancel) { pendingDelete = nil }
Button("Delete", role: .destructive) {
media.delete(recipe)
pendingDelete = nil
}
} message: { recipe in
Text("Delete \"\(recipe.name)\"? This does not delete the .cal or the preset.")
}
}
}
+432
View File
@@ -0,0 +1,432 @@
import Combine
import Foundation
import ICCeryCore
/// Media recipe library: capture / apply / staleness for issue #146.
///
/// A recipe binds a CUPS queue + paper + ink + `.cal` to a
/// `ProfilingPreset`. Applying a recipe goes through the existing
/// `applyPreset` (#82) path there is no second Stage 1 form. Paper
/// and ink are library metadata only; they are never written to the
/// targen label fields.
@MainActor
final class MediaLibraryViewModel: ObservableObject {
/// Why a recipe row is flagged stale.
struct StaleReason: OptionSet {
let rawValue: Int
static let calibration = StaleReason(rawValue: 1 << 0)
static let printer = StaleReason(rawValue: 1 << 1)
}
let workflow: TargetWorkflowViewModel
let environment: AppEnvironment
private let store: MediaLibraryStore
private var cancellables = Set<AnyCancellable>()
@Published var recipes: [MediaRecipe] = []
/// Sidebar picker selection; `"none"` = no media recipe. Written
/// only on a successful apply so a failed apply snaps back.
@Published var selectedRecipeID = "none"
/// `recipe.id` stale reasons for the sidebar badge / manage sheet.
@Published var staleReasons: [String: StaleReason] = [:]
/// `recipe.id` whole days since the bound `.cal` was created.
@Published var calAgeDays: [String: Int] = [:]
// Save-sheet state (mirrors savePresetName/savePresetDesc).
@Published var saveMediaName = ""
@Published var saveMediaNotes = ""
@Published var saveMediaPaper = ""
@Published var saveMediaInk = ""
@Published var saveMediaApplyCal = false
/// Inline caption inside the capture sheet (no a11y id roster complete).
@Published var saveMediaError: String?
/// Pure flow flag the manage sheet's "Capture current" asks the
/// sheet's `onDismiss` to open the capture sheet, avoiding a
/// present-while-dismissing race.
var captureAfterManageDismiss = false
init(workflow: TargetWorkflowViewModel, environment: AppEnvironment) {
self.workflow = workflow
self.environment = environment
self.store = environment.mediaStore
reload()
refreshStaleness()
// The library needs queues enumerated at launch so Capture can
// enable and the not-installed badge is computable; Stage 2 only
// enumerates when a printtarg manifest exists.
if workflow.print.printers.isEmpty {
workflow.print.refreshPrinters()
}
NotificationCenter.default
.publisher(for: SettingsStore.settingsDidChange)
.sink { [weak self] _ in self?.refreshStaleness() }
.store(in: &cancellables)
workflow.print.$printers
.sink { [weak self] _ in self?.refreshStaleness() }
.store(in: &cancellables)
workflow.print.$selectedPrinter
.sink { [weak self] _ in self?.refreshStaleness() }
.store(in: &cancellables)
}
deinit { cancellables.removeAll() }
// MARK: - Load / corrupt
func reload() {
Task { await reloadAsync() }
}
func reloadAsync() async {
do {
recipes = try await store.load()
} catch {
// Corrupt-file policy: keep the file, keep the cache,
// persistent warning; the picker falls back to "none".
workflow.wizard.showNotice(
"Media library is unreadable — the existing file was kept.",
kind: .warning,
autoHideAfter: nil
)
if recipes.isEmpty { selectedRecipeID = "none" }
}
}
// MARK: - Selection / apply
/// Sidebar `mediaSelect` binding. `"none"` clears the selection
/// without resetting any Stage 1/2/4 field it is not "reset to
/// factory".
func selectRecipe(_ id: String) {
if id == "none" {
selectedRecipeID = "none"
return
}
guard let recipe = recipes.first(where: { $0.id == id }) else { return }
Task { _ = await apply(recipe) }
}
/// The single apply path sidebar picker, manage-row Apply, and
/// the manage footer all funnel here.
///
/// Returns `false` when any bound resource is unresolved (missing
/// preset, colour-space mismatch, queue absent, missing/unparseable
/// `.cal`) so the manage sheet stays open and the picker reverts.
/// A `CAL_`-blocked calibration counts as applied (`true` success
/// with warning; the refusal is permanent so re-clicking can't help).
@discardableResult
func apply(_ recipe: MediaRecipe) async -> Bool {
guard let r = try? recipe.validated() else {
workflow.wizard.showNotice(
"Media recipe is invalid — not applied.", kind: .error)
return false
}
guard let preset = environment.presetStore.all()
.first(where: { $0.id == r.presetID })
else {
workflow.wizard.showNotice(
"Preset \(r.presetID) no longer exists — recipe not applied.",
kind: .error)
return false
}
guard preset.colourSpace.lowercased() == r.colourSpace.lowercased() else {
workflow.wizard.showNotice(
"Recipe colour space does not match its preset — not applied.",
kind: .error)
return false
}
// Existing #82 mapping: presetSelect jumps, Stage 1/2/4 fields.
workflow.applyPreset(preset)
// Literal per issue: displayName, not the queue id.
workflow.wizard.printerName = r.printerDisplayName
var succeeded = true
// Queue: enumerate fresh via the session's serialized path
// listPrinters uses fixed process ids, so an overlapping
// enumeration would throw duplicateID. An empty result is a
// valid list.
if let queues = await workflow.print.enumeratePrinters() {
if queues.contains(where: { $0.name == r.printerID }) {
workflow.print.selectedPrinter = r.printerID
await workflow.print.reloadSelectedCapabilities()
} else {
workflow.wizard.showNotice(
"Printer \(r.printerDisplayName) is not installed.",
kind: .warning)
succeeded = false
}
} else {
workflow.wizard.showNotice(
"Could not enumerate printers — queue left unchanged.",
kind: .warning)
succeeded = false
}
// Calibration the recipe is authoritative and runs after
// applyPreset so the preset's own cal fields don't win.
let calPath = r.calibrationURL?.trimmingCharacters(in: .whitespaces) ?? ""
let calStem = URL(fileURLWithPath: calPath)
.deletingPathExtension().lastPathComponent
let blocked = r.applyCalibration && !calPath.isEmpty
&& (CalibrationIdentity.isCalibration(calStem)
|| CalibrationIdentity.isCalibration(workflow.wizard.basename))
if blocked {
// Literal CAL_ refusal on both names (decision 1): keep the
// path for display but never let `printtarg -K` see it.
workflow.profile.applyCalibration = false
workflow.profile.calibrationFile = calPath
selectedRecipeID = r.id
refreshStaleness()
workflow.wizard.showNotice(
"Applied \(r.name) — CAL_ calibrations cannot enable printtarg -K.",
kind: .warning,
autoHideAfter: nil)
return true
}
if r.applyCalibration && !calPath.isEmpty {
guard FileManager.default.fileExists(atPath: calPath) else {
workflow.profile.applyCalibration = false
workflow.profile.calibrationFile = calPath
workflow.wizard.showNotice(
"Calibration file is missing: \(calPath)", kind: .error)
refreshStaleness()
return false
}
do {
let staleDays = environment.settingsStore.load().calibrationStaleDays
let calStore = CalibrationStore(staleDays: staleDays)
try await calStore.load(url: URL(fileURLWithPath: calPath))
workflow.profile.calibrationFile = calPath
workflow.profile.applyCalibration = true
// Age check only the .cal DESCRIPTOR is free text, not
// a queue id, so a name compare false-positives.
if await calStore.isStale() {
workflow.wizard.showNotice(
"Applied \(r.name) — calibration is stale.",
kind: .warning)
}
} catch {
workflow.profile.applyCalibration = false
workflow.wizard.showNotice(
"Could not load calibration: \(error.localizedDescription)",
kind: .error)
refreshStaleness()
return false
}
} else {
workflow.profile.applyCalibration = false
workflow.profile.calibrationFile = calPath
}
if succeeded {
selectedRecipeID = r.id
workflow.wizard.showNotice("Applied \(r.name)")
}
refreshStaleness()
return succeeded
}
// MARK: - Capture
/// Whether the live `profile.calibrationFile` may be applied:
/// non-empty, not a `CAL_` stem, and present on disk.
var calApplyable: Bool {
let path = workflow.profile.calibrationFile
guard !path.isEmpty else { return false }
let stem = URL(fileURLWithPath: path)
.deletingPathExtension().lastPathComponent
guard !CalibrationIdentity.isCalibration(stem) else { return false }
return FileManager.default.fileExists(atPath: path)
}
/// A bound preset whose colour space disagrees with the live form
/// the sheet shows the mismatch caption and disables Save.
var captureColourSpaceMismatch: Bool {
guard let preset = workflow.selectedPreset else { return false }
return preset.colourSpace.lowercased() != workflow.colourSpace.rawValue
}
/// Opens the capture sheet, prefilled from the selected recipe else
/// the most recently captured one ("last recipe or empty").
func beginCapture() {
let source = recipes.first(where: { $0.id == selectedRecipeID })
?? recipes.last
saveMediaPaper = source?.paperName ?? ""
saveMediaInk = source?.inkSet ?? ""
saveMediaName = ""
saveMediaNotes = ""
saveMediaError = nil
saveMediaApplyCal = workflow.profile.applyCalibration && calApplyable
if workflow.print.printers.isEmpty {
workflow.print.refreshPrinters()
}
workflow.showingSaveMedia = true
}
/// Save button the sheet closes only on `true`.
func captureFromSession() async -> Bool {
let name = saveMediaName.trimmingCharacters(in: .whitespacesAndNewlines)
let paper = saveMediaPaper.trimmingCharacters(in: .whitespacesAndNewlines)
let ink = saveMediaInk.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty, !paper.isEmpty, !ink.isEmpty else {
saveMediaError = "Name, paper and ink are required."
return false
}
let queue = workflow.print.selectedPrinter
guard !queue.isEmpty else {
saveMediaError = "Select a printer in Stage 2 first."
return false
}
// Preset binding: the selected preset when its colour space
// matches the live form; otherwise auto-snapshot the live form
// as a custom preset (decision 2).
let presetID: String
if let bound = workflow.selectedPreset {
guard bound.colourSpace.lowercased() == workflow.colourSpace.rawValue else {
saveMediaError = "Colour space does not match the selected preset."
return false
}
presetID = bound.id
} else {
let snapshot = ProfilingPreset(
id: "custom-\(UUID().uuidString.lowercased())",
name: name,
description: "Auto-saved for media recipe",
targen: workflow.buildTargenConfig(),
printtarg: workflow.buildPrinttargConfig(),
colprof: workflow.profile.buildColprofConfig(),
calibrationFile: nil,
applyCalibration: nil
)
do {
try environment.presetStore.saveCustom(snapshot)
workflow.reloadPresets()
workflow.selectedPresetID = snapshot.id
} catch {
saveMediaError = "Could not save preset: \(error.localizedDescription)"
return false
}
presetID = snapshot.id
}
// The cal path is stored verbatim; applyCalibration is forced
// off for CAL_/missing paths via calApplyable.
let calPath = workflow.profile.calibrationFile
let printer = workflow.print.printers.first { $0.name == queue }
let now = Date()
let recipe = MediaRecipe(
id: "recipe-\(UUID().uuidString.lowercased())",
name: name,
notes: saveMediaNotes.trimmingCharacters(in: .whitespacesAndNewlines),
printerID: queue,
printerDisplayName: printer?.displayName ?? queue,
paperName: paper,
driverMediaType: workflow.print.selectedMediaType,
inkSet: ink,
colourSpace: workflow.colourSpace.rawValue,
presetID: presetID,
calibrationURL: calPath.isEmpty ? nil : calPath,
applyCalibration: saveMediaApplyCal && calApplyable,
created: now,
updated: now
)
do {
let validated = try recipe.validated()
try await store.upsert(validated)
await reloadAsync()
selectedRecipeID = validated.id
workflow.wizard.showNotice("Media recipe saved: \(validated.name)")
return true
} catch let error as MediaLibraryStore.MediaLibraryError {
saveMediaError = error.errorDescription
return false
} catch {
saveMediaError = "Could not save: \(error.localizedDescription)"
return false
}
}
// MARK: - Delete
func delete(_ recipe: MediaRecipe) {
Task {
do {
try await store.delete(id: recipe.id)
await reloadAsync()
if selectedRecipeID == recipe.id {
selectedRecipeID = "none"
}
} catch {
workflow.wizard.showNotice(
"Could not delete: \(error.localizedDescription)",
kind: .error)
}
}
}
// MARK: - Staleness
func refreshStaleness() {
Task { await refreshStalenessAsync() }
}
/// Recomputes `staleReasons` + `calAgeDays` for every recipe.
///
/// `.printer` fires only when `printerID` is absent from a
/// **non-empty** enumerated queue list an un-enumerated list is
/// indeterminate, not stale (decision 5). `.calibration` is a pure
/// age check (`isStale()` with no `comparedTo:`) the `.cal`
/// DESCRIPTOR is free text, not a queue id.
func refreshStalenessAsync() async {
let staleDays = environment.settingsStore.load().calibrationStaleDays
let queues = workflow.print.printers
let now = Date()
let calStore = CalibrationStore(staleDays: staleDays)
var reasons: [String: StaleReason] = [:]
var ages: [String: Int] = [:]
for r in recipes {
var flags: StaleReason = []
if !queues.isEmpty, !queues.contains(where: { $0.name == r.printerID }) {
flags.insert(.printer)
}
if let raw = r.calibrationURL?.trimmingCharacters(in: .whitespaces),
!raw.isEmpty,
FileManager.default.fileExists(atPath: raw),
(try? await calStore.load(url: URL(fileURLWithPath: raw))) != nil,
let created = await calStore.data?.created {
ages[r.id] = Calendar.current
.dateComponents([.day], from: created, to: now).day ?? 0
if await calStore.isStale() {
flags.insert(.calibration)
}
}
if !flags.isEmpty { reasons[r.id] = flags }
}
staleReasons = reasons
calAgeDays = ages
}
// MARK: - Manage sheet flow
/// Called from the manage sheet's `onDismiss`. A deferred capture
/// request opens the save sheet only now, after the manage sheet has
/// fully dismissed.
func manageDismissed() {
if captureAfterManageDismiss {
captureAfterManageDismiss = false
beginCapture()
}
}
}
+8
View File
@@ -21,6 +21,14 @@ struct Notice: Identifiable, Equatable {
case .error: return .red
}
}
var accessibilityValue: String {
switch self {
case .info: return "info"
case .warning: return "warning"
case .error: return "error"
}
}
}
let id = UUID()
+2 -2
View File
@@ -4,7 +4,7 @@ import ICCeryCore
/// `#savePresetDialog` save the live Stage 1/2 form as a custom
/// preset (issue #11). Names/descriptions render via `Text` only (#114).
struct SavePresetDialog: View {
@Bindable var workflow: TargetWorkflowViewModel
@ObservedObject var workflow: TargetWorkflowViewModel
var body: some View {
VStack(alignment: .leading, spacing: 14) {
@@ -35,7 +35,7 @@ struct SavePresetDialog: View {
/// `#managePresetsDialog` list, delete (custom only), import, export.
struct ManagePresetsDialog: View {
@Bindable var workflow: TargetWorkflowViewModel
@ObservedObject var workflow: TargetWorkflowViewModel
var body: some View {
VStack(alignment: .leading, spacing: 12) {
@@ -0,0 +1,200 @@
import Combine
import Foundation
import ICCeryCore
/// CUPS queue selection, bound print panel, and `lp` spool (issues 1215, 17 / #85).
@MainActor
final class PrintSessionViewModel: ObservableObject {
let wizard: WizardViewModel
let environment: AppEnvironment
@Published var printers: [Printer] = []
@Published var selectedPrinter = ""
@Published var printerCaps = PrinterCapabilities()
@Published var selectedTray: Int?
@Published var selectedMediaType: String?
@Published var printOrientation = "portrait"
@Published var capturedCupsOptions: [String: String] = [:]
@Published var printNotice: Notice?
@Published var isPrinting = false
private var printTask: Task<Void, Never>?
init(wizard: WizardViewModel, environment: AppEnvironment) {
self.wizard = wizard
self.environment = environment
}
private var printerEnumTask: Task<[Printer]?, Never>?
func refreshPrinters() {
Task { @MainActor in _ = await enumeratePrinters() }
}
/// Serialized queue enumeration `listPrinters` uses fixed process
/// ids, so overlapping calls would throw `duplicateID`. Concurrent
/// callers coalesce onto the in-flight task (#146).
@discardableResult
func enumeratePrinters() async -> [Printer]? {
if let pending = printerEnumTask { return await pending.value }
let task = Task { @MainActor [weak self] () -> [Printer]? in
guard let self else { return nil }
do {
let list = try await self.environment.cupsService.listPrinters()
self.printers = list
if !list.contains(where: { $0.name == self.selectedPrinter }) {
self.selectedPrinter = list.first { $0.isDefault }?.name
?? list.first?.name ?? ""
}
await self.reloadSelectedCapabilities()
return list
} catch {
self.printNotice = Notice(
kind: .error,
text: "Could not list printers: \(error.localizedDescription)"
)
return nil
}
}
printerEnumTask = task
let result = await task.value
printerEnumTask = nil
return result
}
func reloadSelectedCapabilities() async {
guard !selectedPrinter.isEmpty else {
printerCaps = PrinterCapabilities()
return
}
do {
printerCaps = try await environment.cupsService
.capabilities(for: selectedPrinter)
if selectedMediaType == nil {
selectedMediaType = printerCaps.mediaTypes.first?.id
}
if selectedTray == nil {
selectedTray = printerCaps.trays.first?.id
}
} catch {
printerCaps = PrinterCapabilities()
}
}
func openPrinterPreferences() {
guard !selectedPrinter.isEmpty else { return }
let queue = selectedPrinter
let displayName = printers.first { $0.name == queue }?.displayName
let cups = environment.cupsService
Task { @MainActor in
do {
guard let result = try await PrintPanelService()
.showProperties(
queue: queue, displayName: displayName,
cupsService: cups)
else {
printNotice = Notice(
kind: .info,
text: "Printer properties dialog cancelled.",
autoHideAfter: nil
)
return
}
if let selected = result.selectedPrinter,
printers.contains(where: { $0.name == selected }),
selected != queue {
selectedPrinter = selected
await reloadSelectedCapabilities()
}
if let captured = result.options.cupsOptions {
capturedCupsOptions[selectedPrinter] = captured
}
if let media = result.options.mediaType {
selectedMediaType = media
}
printNotice = Notice(
kind: .info,
text: "Settings captured for \(selectedPrinter).",
autoHideAfter: nil
)
} catch {
printNotice = Notice(kind: .error, text: error.localizedDescription)
}
}
}
func printAllPages(from result: PrinttargResult, pageSize: PageSize) {
guard !isPrinting else { return }
isPrinting = true
let task = Task { @MainActor [weak self] in
guard let self else { return }
// `defer` cannot mutate isolated state under Swift 5.7
// (Xcode 14.2 / macOS 12 runner), so clear explicitly (#113).
var printed = 0
for page in result.pages {
do {
try await spool(page, index: page.index, pageSize: pageSize)
printed += 1
} catch {
printNotice = Notice(
kind: .error,
text: "Print failed on \(page.page.filename): "
+ error.localizedDescription
)
isPrinting = false
self.printTask = nil
return
}
}
printNotice = Notice(
kind: .info,
text: "Sent \(printed) page(s) to \(selectedPrinter).",
autoHideAfter: nil
)
isPrinting = false
self.printTask = nil
}
printTask = task
}
func printPage(_ page: GalleryPage, pageSize: PageSize) {
guard !isPrinting else { return }
isPrinting = true
let task = Task { @MainActor [weak self] in
guard let self else { return }
do {
try await spool(page, index: page.index, pageSize: pageSize)
printNotice = Notice(
kind: .info,
text: "Sent \(page.page.filename) to \(selectedPrinter).",
autoHideAfter: nil
)
} catch {
printNotice = Notice(
kind: .error,
text: "Print failed: \(error.localizedDescription)"
)
}
isPrinting = false
self.printTask = nil
}
printTask = task
}
private func spool(_ page: GalleryPage, index: Int, pageSize: PageSize) async throws {
guard !selectedPrinter.isEmpty else {
throw CupsError.noPrinterSelected
}
let options = PrintOptions(
orientation: printOrientation,
paperSize: pageSize == .custom ? nil : pageSize.rawValue,
mediaType: selectedMediaType,
ppdUncorrectedPassthrough: true,
cupsOptions: capturedCupsOptions[selectedPrinter])
try await environment.cupsService.printTarget(
queue: selectedPrinter,
tiffPath: page.fileURL.path,
options: options,
page: index)
wizard.printerName = selectedPrinter
}
}
+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))
}
}
+96 -128
View File
@@ -1,31 +1,11 @@
import Combine
import Foundation
import Observation
import SwiftUI
import ICCeryCore
/// User-facing FWA selection for the Stage 4 form.
enum ColprofFwaSelection: String, CaseIterable, Sendable, Equatable {
case none = "none"
case empty = ""
case D50 = "D50"
case D65 = "D65"
case custom = "custom"
var displayName: String {
switch self {
case .none: return "None"
case .empty: return "Bare (-f)"
case .D50: return "D50"
case .D65: return "D65"
case .custom: return "Custom .sp"
}
}
}
/// Stage 4/5 workflow: build a profile, verify it, track drift, and install.
@MainActor
@Observable
final class ProfileWorkflowViewModel {
final class ProfileWorkflowViewModel: ObservableObject {
let wizard: WizardViewModel
let environment: AppEnvironment
@@ -33,51 +13,50 @@ final class ProfileWorkflowViewModel {
// MARK: - Stage 4 form
var algorithm: String = "l" // l | x | X | m
var quality: String = "m" // l | m | h | u
var intent: String = "" // usually empty at Stage 4
var fwaSelection: ColprofFwaSelection = .none
var fwaCustomPath: String = ""
var illuminant: String = ""
var observer: String = ""
var inputViewingCond: String = ""
var outputViewingCond: String = ""
var profileDescription: String = ""
var copyright: String = ""
@Published var algorithm: String = "l" // l | x | X | m
@Published var quality: String = "m" // l | m | h | u
@Published var intent: String = "" // usually empty at Stage 4
@Published var fwaSelection: ColprofFwaSelection = .none
@Published var fwaCustomPath: String = ""
@Published var illuminant: String = ""
@Published var observer: String = ""
@Published var inputViewingCond: String = ""
@Published var outputViewingCond: String = ""
@Published var profileDescription: String = ""
@Published var copyright: String = ""
// MARK: - Run state
var isColprofRunning = false
var colprofLog: [String] = []
var colprofProgress: String?
var lastError: String?
var createdProfileURL: URL?
@Published var isColprofRunning = false
@Published var colprofLog: [String] = []
@Published var colprofProgress: String?
@Published var createdProfileURL: URL?
/// Path to the `.gam` gamut mesh extracted post-`colprof` (issue #28).
var createdGamutURL: URL?
@Published var createdGamutURL: URL?
// MARK: - Stage 4/5 calibration (issue #24)
var applyCalibration = false
var calibrationFile: String = ""
@Published var applyCalibration = false
@Published var calibrationFile: String = ""
// MARK: - Stage 5 verification (issue #25)
var profcheckReport: ProfcheckReport?
var profcheckWarning: String?
var isProfcheckRunning = false
@Published var profcheckReport: ProfcheckReport?
@Published var profcheckWarning: String?
@Published var isProfcheckRunning = false
// MARK: - History / drift (issue #26)
var verificationHistory: [VerificationRecord] = []
var driftPrinterFilter: String? = nil
var driftAlert: String?
var isHistoryStoreError: String?
@Published var verificationHistory: [VerificationRecord] = []
@Published var driftPrinterFilter: String? = nil
@Published var driftAlert: String?
@Published var isHistoryStoreError: String?
// MARK: - Install (issue #27)
var installResult: InstallProfileResult?
var showingInstallCollision = false
var installCollisionMessage: String = ""
@Published var installResult: InstallProfileResult?
@Published var showingInstallCollision = false
@Published var installCollisionMessage: String = ""
var pendingInstallOptions: InstallProfileOptions?
init(wizard: WizardViewModel, environment: AppEnvironment) {
@@ -110,39 +89,31 @@ final class ProfileWorkflowViewModel {
}
var fwaValue: String? {
switch fwaSelection {
case .none: return nil
case .empty: return ""
case .D50: return "D50"
case .D65: return "D65"
case .custom: return fwaCustomPath
}
fwaSelection.presetValue(customPath: fwaCustomPath)
}
// MARK: - Preset application
func applyPreset(_ preset: ProfilingPreset?) {
guard let preset else { return }
algorithm = preset.colprofAlgorithm ?? "l"
quality = preset.colprofQuality ?? "m"
intent = preset.colprofIntent ?? ""
if let fwa = preset.colprofFwa {
switch fwa.lowercased() {
case "none": fwaSelection = .none
case "": fwaSelection = .empty
case "d50": fwaSelection = .D50
case "d65": fwaSelection = .D65
default:
fwaSelection = .custom
fwaCustomPath = fwa
}
}
illuminant = preset.colprofIlluminant ?? ""
observer = preset.colprofObserver ?? ""
inputViewingCond = preset.colprofInputViewingCond ?? ""
outputViewingCond = preset.colprofOutputViewingCond ?? ""
let config = ColprofConfig(
preset: preset,
basename: wizard.basename,
workingDirectory: wizard.effectiveWorkingDirectory
)
algorithm = config.algorithm
quality = config.quality
intent = config.intent ?? ""
fwaSelection = ColprofFwaSelection(presetValue: config.fwa)
fwaCustomPath = fwaSelection == .custom ? (config.fwa ?? "") : ""
illuminant = config.illuminant ?? ""
observer = config.observer ?? ""
inputViewingCond = config.inputViewingCond ?? ""
outputViewingCond = config.outputViewingCond ?? ""
profileDescription = ""
copyright = ""
applyCalibration = preset.applyCalibration == true
calibrationFile = preset.calibrationFile ?? ""
}
/// Stage 4 form values for saving into a custom preset.
@@ -192,65 +163,59 @@ final class ProfileWorkflowViewModel {
guard canCreateProfile, let _ = wizard.effectiveWorkingDirectory else { return }
let config = buildColprofConfig()
isColprofRunning = true
colprofLog = []
colprofProgress = nil
lastError = nil
createdProfileURL = nil
createdGamutURL = nil
let runner = environment.runner
Task { @MainActor [weak self] in
guard let self else { return }
defer { self.isColprofRunning = false }
do {
let url = try await runner.runColprof(config: config) { [weak self] batch in
Task { @MainActor [weak self] in
guard let self else { return }
let outcome = try await ProcessRunSupport.runLogged(
setRunning: { self.isColprofRunning = $0 },
resetLog: { self.colprofLog = [] },
onLog: { batch in
self.colprofLog.append(contentsOf: batch)
if let last = batch.last {
let progress = ColprofProgressClassifier.classify(line: last)
self.updateProgress(progress)
self.updateProgress(ColprofProgressClassifier.classify(line: last))
}
}
}
) { onLog in
let url = try await runner.runColprof(config: config, onLogBatch: onLog)
var finalProfileURL = url
var finalProfileURL = url
if self.applyCalibration, !self.calibrationFile.isEmpty {
let applyConfig = ApplycalConfig(
calibrationPath: self.calibrationFile,
inputProfileURL: url
)
assert(!applyConfig.unapply, "applycal unapply is not supported in v2.0")
finalProfileURL = try await runner.runApplycal(config: applyConfig)
self.colprofLog.append("Calibration embedded: \(self.calibrationFile)")
}
// Gamut extraction is best-effort for Stage 5 / M6 viewer.
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)
}
if self.applyCalibration, !self.calibrationFile.isEmpty {
let applyConfig = ApplycalConfig(
calibrationPath: self.calibrationFile,
inputProfileURL: url
)
assert(!applyConfig.unapply, "applycal unapply is not supported in v2.0")
finalProfileURL = try await runner.runApplycal(config: applyConfig)
self.colprofLog.append("Calibration embedded: \(self.calibrationFile)")
}
self.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.showNotice("Profile created: \(finalProfileURL.lastPathComponent)")
self.wizard.showNotice("Profile created: \(outcome.profileURL.lastPathComponent)")
self.wizard.go(to: .verifyInstall)
} catch {
self.lastError = error.localizedDescription
self.wizard.showNotice(
"Profile creation failed: \(error.localizedDescription)",
kind: .error
@@ -317,25 +282,28 @@ final class ProfileWorkflowViewModel {
let ti3URL = ArtefactProbe.artefact(wizard.basename, "ti3", cwd)
let config = ProfcheckConfig(ti3URL: ti3URL, iccURL: profileURL)
isProfcheckRunning = true
profcheckReport = nil
profcheckWarning = nil
let runner = environment.runner
Task { @MainActor [weak self] in
guard let self else { return }
defer { self.isProfcheckRunning = false }
do {
let report = try await runner.runProfcheck(config: config) { [weak self] batch in
Task { @MainActor [weak self] in
self?.colprofLog.append(contentsOf: batch)
let outcome = try await ProcessRunSupport.runLogged(
setRunning: { self.isProfcheckRunning = $0 },
resetLog: {},
onLog: { self.colprofLog.append(contentsOf: $0) }
) { onLog in
let report = try await runner.runProfcheck(config: config, onLogBatch: onLog)
var history: [VerificationRecord]?
if let record = self.makeVerificationRecord(from: report) {
history = try await self.environment.historyStore.append(record)
}
return (report: report, history: history)
}
self.profcheckReport = report
if let record = self.makeVerificationRecord(from: report) {
let updated = try await self.environment.historyStore.append(record)
self.verificationHistory = updated
self.profcheckReport = outcome.report
if let history = outcome.history {
self.verificationHistory = history
self.driftAlert = DriftAlert.compute(from: self.filteredHistory)
}
} catch let error as ArgyllRunnerError where error == .profcheckUnparseable {
+36 -8
View File
@@ -5,12 +5,18 @@ import ICCeryCore
/// Root layout: 270 pt sidebar + main stage area with the notification
/// banner pinned to the top (docs/21 §Shell).
struct RootView: View {
@Bindable var workflow: TargetWorkflowViewModel
@ObservedObject var workflow: TargetWorkflowViewModel
/// Observed directly: nested ObservableObjects are not tracked
/// through the parent's `objectWillChange`.
@ObservedObject private var model: WizardViewModel
@State private var showingSettings = false
@State private var showingAbout = false
@State private var showingAllHelp = false
private var model: WizardViewModel { workflow.wizard }
init(workflow: TargetWorkflowViewModel) {
self.workflow = workflow
self._model = ObservedObject(wrappedValue: workflow.wizard)
}
var body: some View {
HStack(spacing: 0) {
@@ -50,6 +56,25 @@ struct RootView: View {
.sheet(isPresented: $workflow.showingManagePresets) {
ManagePresetsDialog(workflow: workflow)
}
// Media library sheets live on RootView, never inside the
// 270 pt sidebar column (#146).
.sheet(isPresented: $workflow.showingSaveMedia) {
SaveMediaRecipeDialog(workflow: workflow)
}
.sheet(
isPresented: $workflow.showingManageMedia,
onDismiss: { workflow.media.manageDismissed() }
) {
ManageMediaDialog(workflow: workflow)
}
// Spot-read console sheet (issue #148). Dismiss runs the same
// `q\n` + ~500 ms + kill path as the sheet's Stop button.
.sheet(
isPresented: $workflow.showingSpotRead,
onDismiss: { workflow.spotRead.sheetClosed() }
) {
SpotReadView(model: workflow.spotRead)
}
.sheet(isPresented: $showingAbout) {
AboutView { showingAbout = false }
}
@@ -57,18 +82,21 @@ struct RootView: View {
get: { workflow.wizard.showingGamutViewer },
set: { workflow.wizard.showingGamutViewer = $0 }
)) {
GamutView(profileGamURL: workflow.wizard.gamutProfileURL)
GamutView(
environment: workflow.environment,
profileGamURL: workflow.wizard.gamutProfileURL,
showingAllHelp: $showingAllHelp)
}
}
}
/// Content for the active wizard stage. Isolated into its own view so that
/// `WizardViewModel` is tracked via `@Bindable` instead of the parent's
/// `WizardViewModel` is tracked via `@ObservedObject` instead of the parent's
/// `TargetWorkflowViewModel`, which does not observe nested `wizard` mutations.
private struct WizardStageContent: View {
@Bindable var model: WizardViewModel
var workflow: TargetWorkflowViewModel
@ObservedObject var model: WizardViewModel
@ObservedObject var workflow: TargetWorkflowViewModel
var body: some View {
switch model.stage {
@@ -83,9 +111,9 @@ private struct WizardStageContent: View {
case .verifyInstall:
Stage5View(model: workflow.profile)
case .calibrate:
CalibrationView(model: workflow.calibration)
CalibrationView(model: workflow.calibration, wizard: workflow.wizard)
@unknown default:
StagePlaceholderView(stage: model.stage)
Stage1View(workflow: workflow)
}
}
}
+2 -3
View File
@@ -4,7 +4,7 @@ import ICCeryCore
/// Settings sheet (issue #5, docs/21 §Settings). Dark-theme Form with
/// the full v1 field set; ΔE validation shows inline under the fields.
struct SettingsView: View {
@State var model = SettingsViewModel()
@StateObject var model = SettingsViewModel()
@Environment(\.dismiss) private var dismiss
private static let instruments: [(code: String, label: String)] = [
@@ -58,7 +58,7 @@ struct SettingsView: View {
Text($0.label).tag($0.code)
}
}
Text("Display-only — Stage 2's instrument select is used for actual runs.")
Text("Seeds Spot Read and Stage 3 when the instrument is plugged in. printtarg -i is still chosen on Stage 2.")
.font(.caption)
.foregroundStyle(.secondary)
@@ -143,7 +143,6 @@ struct SettingsView: View {
}
}
}
.formStyle(.grouped)
Divider()
+6 -6
View File
@@ -1,4 +1,5 @@
import AppKit
import Combine
import Foundation
import ICCeryCore
@@ -6,12 +7,11 @@ import ICCeryCore
/// validation; the log level is applied live via `LogSink` (#158) and a
/// `settingsDidChange` notification fans out to #20.
@MainActor
@Observable
final class SettingsViewModel {
final class SettingsViewModel: ObservableObject {
var settings: AppSettings
var validationErrors: [String] = []
var savedFlash = false
@Published var settings: AppSettings
@Published var validationErrors: [String] = []
@Published var savedFlash = false
private let store: SettingsStore
private let sink: LogSink
@@ -33,7 +33,7 @@ final class SettingsViewModel {
sink.applySettings(settings)
savedFlash = true
Task {
try? await Task.sleep(for: .seconds(1.5))
try? await Task.sleep(nanoseconds: 1_500_000_000)
savedFlash = false
}
return true
+97 -2
View File
@@ -4,12 +4,34 @@ import ICCeryCore
/// 270 pt sidebar (docs/21 §Shell): logo, settings/about buttons, preset
/// select, Calibrate Printer + status chip, and the 15 stepper.
struct SidebarView: View {
@Bindable var workflow: TargetWorkflowViewModel
@ObservedObject var workflow: TargetWorkflowViewModel
/// Observed directly: nested ObservableObjects are not tracked
/// through the parent's `objectWillChange`.
@ObservedObject private var model: WizardViewModel
@ObservedObject private var profile: ProfileWorkflowViewModel
@ObservedObject private var media: MediaLibraryViewModel
@ObservedObject private var printSession: PrintSessionViewModel
@ObservedObject private var measurement: MeasurementWorkflowViewModel
var onOpenSettings: () -> Void
var onOpenAbout: () -> Void
@Binding var showingAllHelp: Bool
private var model: WizardViewModel { workflow.wizard }
init(
workflow: TargetWorkflowViewModel,
onOpenSettings: @escaping () -> Void,
onOpenAbout: @escaping () -> Void,
showingAllHelp: Binding<Bool>
) {
self.workflow = workflow
self._model = ObservedObject(wrappedValue: workflow.wizard)
self._profile = ObservedObject(wrappedValue: workflow.profile)
self._media = ObservedObject(wrappedValue: workflow.media)
self._printSession = ObservedObject(wrappedValue: workflow.print)
self._measurement = ObservedObject(wrappedValue: workflow.measurement)
self.onOpenSettings = onOpenSettings
self.onOpenAbout = onOpenAbout
self._showingAllHelp = showingAllHelp
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
@@ -74,6 +96,56 @@ struct SidebarView: View {
.padding(.horizontal, 12)
.padding(.bottom, 8)
// Media library (`#mediaSelect`) issue #146. Selection
// applies the recipe immediately, like presets; names render
// via Text only (#114). Never reuses `presetSelect` (#137).
Picker("Media", selection: Binding(
get: { media.selectedRecipeID },
set: { media.selectRecipe($0) }
)) {
Text("No media recipe").tag("none")
ForEach(media.recipes) { recipe in
Text(recipe.name).tag(recipe.id)
}
}
.pickerStyle(.menu)
.accessibilityIdentifier("mediaSelect")
.padding(.horizontal, 12)
.padding(.vertical, 8)
.helpOverlay(
"Saved printer + paper + ink + .cal bound to a preset.",
showing: $showingAllHelp)
if let reasons = media.staleReasons[media.selectedRecipeID],
!reasons.isEmpty {
Text(reasons.contains(.printer)
? "Printer not installed" : "Calibration stale")
.font(.caption)
.foregroundStyle(.orange)
.padding(.horizontal, 12)
.accessibilityIdentifier("mediaRecipeStale")
.helpOverlay(
"Re-run Stage 0 or pick a different recipe.",
showing: $showingAllHelp)
}
HStack(spacing: 8) {
Button("Capture") { media.beginCapture() }
.disabled(printSession.selectedPrinter.isEmpty)
.accessibilityIdentifier("btnMediaLibraryCapture")
.helpOverlay(
"Select a printer in Stage 2 first",
showing: $showingAllHelp)
Button("Manage") { workflow.showingManageMedia = true }
.accessibilityIdentifier("btnMediaLibraryManage")
.helpOverlay(
"Apply or delete saved media recipes.",
showing: $showingAllHelp)
Spacer()
}
.padding(.horizontal, 12)
.padding(.bottom, 8)
// Calibrate Printer (`#btnCalibratePrinter`).
Button(action: { model.enterCalibration() }) {
Label("Calibrate Printer", systemImage: "slider.horizontal.3")
@@ -88,9 +160,32 @@ struct SidebarView: View {
.frame(maxWidth: .infinity)
}
.controlSize(.large)
.helpOverlay(
"View the profile gamut in 3D against sRGB.",
showing: $showingAllHelp)
.accessibilityIdentifier("btnViewGamut")
.padding(.horizontal, 12)
// Spot Read sheet (`#btnSpotRead`) issue #148. Enabled
// only with a working folder (#59) and while no Stage 3
// chartread child is live; opening never kills
// `chartread_{basename}`.
Button(action: { workflow.showingSpotRead = true }) {
Label("Spot Read", systemImage: "eyedropper")
.frame(maxWidth: .infinity)
}
.controlSize(.large)
.disabled(model.workingDirectory == nil || measurement.isChartreadRunning)
.helpOverlay(
model.workingDirectory == nil
? "Set a working folder in Stage 1 first."
: (measurement.isChartreadRunning
? "Stop the Stage 3 chart read first."
: "Read a single patch as Lab/XYZ from the instrument."),
showing: $showingAllHelp)
.accessibilityIdentifier("btnSpotRead")
.padding(.horizontal, 12)
Divider().overlay(Theme.border)
.padding(.vertical, 8)
+360
View File
@@ -0,0 +1,360 @@
import SwiftUI
import ICCeryCore
/// Spot Read sheet (issue #148) one patch Lab/XYZ from the live
/// instrument. A `RootView` sheet, not a wizard stage and not a Stage 3
/// tab; all identifiers are `spot*` Stage 3 `chartread` ids are never
/// reused here.
struct SpotReadView: View {
@ObservedObject var model: SpotReadViewModel
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(alignment: .leading, spacing: 12) {
header
if !model.sidecarAvailable {
missingSidecar
} else {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
instrumentCard
promptLine
transport
lastSampleCard
historySection
}
}
}
footer
}
.padding(16)
.frame(width: 560, height: 640)
.background(Theme.background)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("spotReadView")
.onAppear { model.sheetOpened() }
.onDisappear { model.sheetClosed() }
}
// MARK: - Header / missing sidecar
private var header: some View {
HStack(alignment: .firstTextBaseline) {
Text("Spot Read")
.font(.title3)
.foregroundStyle(Theme.text)
Spacer()
if model.isRunning {
ProgressView()
.scaleEffect(0.8)
}
}
}
private var missingSidecar: some View {
VStack(alignment: .leading, spacing: 12) {
Text("spotread sidecar missing — run fetch-argyll")
.foregroundStyle(Theme.text)
.accessibilityIdentifier("spotSidecarMissing")
Spacer()
}
}
// MARK: - Instrument card (clones Stage 3 look, own ids)
private var instrumentCard: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("Instrument")
.font(.headline)
.foregroundStyle(Theme.text)
Spacer()
Button(action: { model.detectInstruments() }) {
Image(systemName: "arrow.clockwise")
}
.disabled(!model.canDetect)
.accessibilityIdentifier("btnSpotDetectInstruments")
}
if let error = model.detectionError {
Text(error)
.font(.caption)
.foregroundStyle(.red)
.accessibilityIdentifier("spotDetectError")
}
Picker("Instrument", selection: Binding(
get: { instrumentTag },
set: { newTag in
if newTag.isEmpty {
model.selectedInstrument = .auto
} else if let device = model.instruments.first(where: { "\($0.port)" == newTag }) {
model.selectedInstrument = .device(device)
}
}
)) {
Text("Auto (first available port)").tag("")
ForEach(model.instruments) { device in
Text(device.displayName).tag("\(device.port)")
}
}
.pickerStyle(.menu)
.disabled(model.isRunning)
.accessibilityIdentifier("spotInstrumentSelect")
if model.defaultMissing {
Text("Saved default instrument not present")
.font(.caption)
.foregroundStyle(.orange)
.accessibilityIdentifier("spotDefaultMissing")
}
Toggle("Also set as default instrument", isOn: Binding(
get: { model.setAsDefault },
set: { model.applyDefaultToggle($0) }
))
.accessibilityIdentifier("spotSetDefault")
if model.selectedInstrument.isXY {
Text("XY tables use Stage 3. Spot Read is a handheld / reflective probe.")
.font(.caption)
.foregroundStyle(Theme.accent)
.accessibilityIdentifier("spotXYHint")
}
}
.padding(16)
.background(Theme.panel)
}
private var instrumentTag: String {
switch model.selectedInstrument {
case .auto:
return ""
case .device(let device):
return "\(device.port)"
}
}
// MARK: - Prompt line
private var promptLine: some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text("Status")
.font(.headline)
.foregroundStyle(Theme.text)
Spacer()
Text(model.prompt)
.font(.callout)
.foregroundStyle(Theme.text)
.accessibilityIdentifier("spotPrompt")
}
if let error = model.lastError {
Text(error)
.font(.caption)
.foregroundStyle(.red)
.accessibilityIdentifier("spotLastError")
.accessibilityValue(error)
}
if !model.log.isEmpty {
ProcessLogView(
lines: model.log,
minHeight: 60,
maxHeight: 100,
containerId: "spotLogContainer",
logId: "spotLog"
)
}
}
.padding(16)
.background(Theme.panel)
}
// MARK: - Transport
private var transport: some View {
HStack(spacing: 12) {
if !model.isRunning {
Button("Start") { model.start() }
.disabled(!model.canStart)
.accessibilityIdentifier("btnSpotStart")
} else {
switch model.state {
case .calibrating:
Button("Calibrate") { model.calibrate() }
.accessibilityIdentifier("btnSpotCalibrate")
case .awaitingStrip:
Button("Read") { model.trigger() }
.accessibilityIdentifier("btnSpotTrigger")
default:
EmptyView()
}
Button("Stop") { model.stopIfNeeded() }
.accessibilityIdentifier("btnSpotStop")
}
Spacer()
}
.padding(.horizontal, 4)
}
// MARK: - Last sample
@ViewBuilder
private var lastSampleCard: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Last sample")
.font(.headline)
.foregroundStyle(Theme.text)
if let sample = model.displayedSample {
HStack(spacing: 16) {
let rgb = LabColorMath.labToSRGB(sample.lab)
RoundedRectangle(cornerRadius: 4)
.fill(Color(red: rgb.r, green: rgb.g, blue: rgb.b))
.frame(width: 32, height: 32)
.overlay(RoundedRectangle(cornerRadius: 4).stroke(Theme.border))
.accessibilityIdentifier("spotSwatch")
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 12) {
Text(String(format: "L* %.1f", sample.lab.l))
.accessibilityIdentifier("spotLabL")
Text(String(format: "a* %.1f", sample.lab.a))
.accessibilityIdentifier("spotLabA")
Text(String(format: "b* %.1f", sample.lab.b))
.accessibilityIdentifier("spotLabB")
}
.font(.callout)
.foregroundStyle(Theme.text)
if let xyz = sample.xyz {
Text(String(format: "XYZ %.2f %.2f %.2f", xyz.x, xyz.y, xyz.z))
.font(.caption)
.foregroundStyle(.secondary)
.accessibilityIdentifier("spotXYZ")
}
HStack(spacing: 8) {
Text(sample.port.map { "\(sample.instrumentName) · port \($0)" }
?? sample.instrumentName)
.font(.caption)
.foregroundStyle(.secondary)
.accessibilityIdentifier("spotLastInstrument")
if let de = model.displayedDeltaE {
HStack(spacing: 6) {
Circle()
.fill(deltaEColor)
.frame(width: 8, height: 8)
Text(String(format: "ΔE %.2f", de))
.font(.caption)
.foregroundStyle(.secondary)
}
.accessibilityElement(children: .contain)
.accessibilityIdentifier("spotDeltaE")
}
}
if model.isDisplayedLabImplausible {
Text("Implausible L*")
.font(.caption)
.foregroundStyle(.orange)
.accessibilityIdentifier("spotLabImplausible")
}
}
Spacer()
}
.accessibilityElement(children: .contain)
.accessibilityIdentifier("spotLastSample")
} else {
Text("No readings yet.")
.font(.caption)
.foregroundStyle(.secondary)
.accessibilityIdentifier("spotLastEmpty")
}
}
.padding(16)
.background(Theme.panel)
}
private var deltaEColor: Color {
switch model.deltaEClassification {
case .good, nil: return .green
case .warning: return .orange
case .bad: return .red
}
}
// MARK: - History
@ViewBuilder
private var historySection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("History")
.font(.headline)
.foregroundStyle(Theme.text)
if model.samples.isEmpty {
Text("No history.")
.font(.caption)
.foregroundStyle(.secondary)
.accessibilityIdentifier("spotHistoryEmpty")
} else {
List {
ForEach(Array(model.samples.enumerated()), id: \.element.id) { index, sample in
historyRow(index: index, sample: sample)
}
}
.frame(minHeight: 120)
.accessibilityIdentifier("spotHistoryTable")
}
}
.padding(16)
.background(Theme.panel)
}
private func historyRow(index: Int, sample: SpotReadSample) -> some View {
let previous = index + 1 < model.samples.count ? model.samples[index + 1] : nil
let deltaE = previous.map { ColorDifference.deltaE00($0.lab, sample.lab) }
return Button(action: { model.selectFromHistory(sample) }) {
HStack(spacing: 10) {
Text(sample.timestamp, style: .time)
.frame(width: 70, alignment: .leading)
Text(String(format: "%.1f", sample.lab.l))
.frame(width: 44, alignment: .trailing)
Text(String(format: "%.1f", sample.lab.a))
.frame(width: 44, alignment: .trailing)
Text(String(format: "%.1f", sample.lab.b))
.frame(width: 44, alignment: .trailing)
Text(deltaE.map { String(format: "%.2f", $0) } ?? "")
.frame(width: 44, alignment: .trailing)
Text(sample.instrumentName)
.lineLimit(1)
Spacer()
}
.font(.caption)
.foregroundStyle(Theme.text)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityIdentifier("spotHistoryRow-\(sample.id.uuidString)")
}
// MARK: - Footer
private var footer: some View {
HStack(spacing: 12) {
Button("Copy Lab") { model.copyLab() }
.disabled(model.displayedSample == nil)
.accessibilityIdentifier("btnSpotCopyLab")
Button("Export CSV…") { model.exportCsv() }
.disabled(model.samples.isEmpty)
.accessibilityIdentifier("btnSpotExportCsv")
Spacer()
Button("Close") { dismiss() }
.keyboardShortcut(.cancelAction)
.accessibilityIdentifier("btnCloseSpotRead")
}
.padding(.top, 4)
}
}
+420
View File
@@ -0,0 +1,420 @@
import AppKit
import Combine
import Foundation
import ICCeryCore
/// Spot-read console state and interaction (issue #148).
///
/// Runs the bundled `spotread` sidecar under the single-lease process id
/// `spotread`; Stage 3 `chartread` is untouched. `defaultInstrument`
/// seeds the instrument picker on sheet open it is never written into
/// `printtarg -i` or `targen` argv (R15).
@MainActor
final class SpotReadViewModel: ObservableObject {
let workflow: TargetWorkflowViewModel
let environment: AppEnvironment
private let fileDialogs = FileDialogService.shared
// MARK: - Instrument card
@Published var instruments: [InstrumentDevice] = []
@Published var selectedInstrument: InstrumentSelection = .auto
@Published var isDetecting = false
@Published var detectionError: String?
/// `spotDefaultMissing` set when `defaultInstrument` is saved but
/// no detected device matches it.
@Published var defaultMissing = false
/// `spotSetDefault` toggle state.
@Published var setAsDefault = false
// MARK: - Session
@Published var isRunning = false
@Published var state: ChartreadState = .idle
@Published var prompt = "Press Start to open the instrument."
@Published var lastError: String?
@Published var log: [String] = []
// MARK: - Samples / history (in-memory, cap 50, newest first)
@Published private(set) var samples: [SpotReadSample] = []
@Published private(set) var displayedSample: SpotReadSample?
@Published private(set) var displayedDeltaE: Double?
private let historyLimit = 50
private var streamTask: Task<Void, Never>?
init(workflow: TargetWorkflowViewModel, environment: AppEnvironment) {
self.workflow = workflow
self.environment = environment
}
// MARK: - Derived state
/// Whether the bundled `spotread` sidecar resolves to an executable.
/// `BinaryResolver` only never `$PATH`, never `chartread`.
var sidecarAvailable: Bool {
let url = environment.runner.binaryResolver.resolve("spotread")
return environment.runner.binaryResolver.exists(url)
}
var isChartreadRunning: Bool { workflow.measurement.isChartreadRunning }
var canStart: Bool {
sidecarAvailable && !isDetecting && !isRunning && !isChartreadRunning
&& workflow.wizard.effectiveWorkingDirectory != nil
}
var canDetect: Bool { !isDetecting && !isRunning }
var deltaEClassification: SwatchClassification? {
guard let de = displayedDeltaE else { return nil }
let settings = environment.settingsStore.load()
return ColorDifference.classify(
deltaE: de,
goodMax: settings.deltaEGoodMax,
warningMax: settings.deltaEWarningMax)
}
/// `spotLabImplausible` L* outside 0100 still displays, unclamped.
var isDisplayedLabImplausible: Bool {
guard let l = displayedSample?.lab.l else { return false }
return l < 0 || l > 100
}
// MARK: - Sheet lifecycle
/// Called from `SpotReadView.onAppear`. Resets the in-memory session
/// and runs detection once; a missing sidecar gets a wizard notice.
func sheetOpened() {
samples = []
displayedSample = nil
displayedDeltaE = nil
log = []
lastError = nil
state = .idle
prompt = "Press Start to open the instrument."
guard sidecarAvailable else {
workflow.wizard.showNotice(
"spotread sidecar missing — run fetch-argyll", kind: .error)
return
}
detectInstruments()
}
/// Called from `onDisappear` *and* the sheet's `onDismiss` clearing
/// the flag alone is not enough; a live child must be quit and
/// killed (R14).
func sheetClosed() {
stopIfNeeded()
samples = []
displayedSample = nil
displayedDeltaE = nil
log = []
defaultMissing = false
}
// MARK: - Detection
func detectInstruments() {
guard canDetect else { return }
isDetecting = true
detectionError = nil
Task { @MainActor [weak self] in
guard let self else { return }
// `instlist` is an exclusive lease (#116) never spawn a
// second one; surface the busy state instead.
if await self.environment.runner.processManager.isRunning(ProcessID.instlist) {
self.detectionError = "Instrument detection is already running."
self.isDetecting = false
return
}
do {
let devices = try await self.environment.runner.detectInstruments()
self.instruments = devices
self.seedDefault(from: devices)
if case .device(let selected) = self.selectedInstrument,
!devices.contains(where: { $0.port == selected.port }) {
self.selectedInstrument = .auto
}
} catch {
self.detectionError = error.localizedDescription
}
self.isDetecting = false
}
}
/// Seed the picker from `AppSettings.defaultInstrument`; no match
/// `.auto` + `spotDefaultMissing`.
private func seedDefault(from devices: [InstrumentDevice]) {
guard let code = environment.settingsStore.load().defaultInstrument,
!code.isEmpty else {
defaultMissing = false
return
}
if let match = devices.first(where: { Self.matches(code: code, device: $0) }) {
selectedInstrument = .device(match)
defaultMissing = false
} else {
selectedInstrument = .auto
defaultMissing = true
}
}
/// Whether an `instlist` device corresponds to a `printtarg -i` /
/// settings instrument code (`i1`, `CM`, `p3`, `SS`, `20`/`22`/`41`/`51`).
static func matches(code: String, device: InstrumentDevice) -> Bool {
let haystack = "\(device.name) \(device.type)".lowercased()
switch code {
case "i1": return haystack.contains("i1pro") && !haystack.contains("i1pro 3") && !haystack.contains("i1pro3")
case "p3": return haystack.contains("i1pro 3") || haystack.contains("i1pro3")
case "CM": return haystack.contains("colormunki")
case "SS": return haystack.contains("specbos") || haystack.contains("spectraval") || haystack.contains("spectroscan") || haystack.contains("spectro scan")
case "20": return haystack.contains("display 2")
case "22": return haystack.contains("display")
case "41": return haystack.contains("spyder 4") || haystack.contains("spyder 5") || haystack.contains("spyder4") || haystack.contains("spyder5")
case "51": return haystack.contains("spyder x")
default: return false
}
}
/// Reverse of `matches` most specific codes first.
static func code(for device: InstrumentDevice) -> String? {
for code in ["p3", "51", "41", "22", "20", "CM", "SS", "i1"]
where matches(code: code, device: device) {
return code
}
return nil
}
/// `spotSetDefault` writes `AppSettings.defaultInstrument` only.
/// Never touches `printtarg -i` or `targen`.
func applyDefaultToggle(_ on: Bool) {
setAsDefault = on
var settings = environment.settingsStore.load()
if on, case .device(let device) = selectedInstrument {
settings.defaultInstrument = Self.code(for: device)
} else if !on {
settings.defaultInstrument = nil
}
try? environment.settingsStore.save(settings)
}
// MARK: - Session control
func start() {
guard sidecarAvailable else {
lastError = "spotread sidecar missing — run fetch-argyll"
return
}
guard !isChartreadRunning else {
lastError = "Stop the Stage 3 chart read first."
return
}
guard let cwd = workflow.wizard.effectiveWorkingDirectory else {
lastError = "Set a working folder in Stage 1 first."
return
}
Task { @MainActor [weak self] in
guard let self else { return }
// `spotread` is an exclusive lease a second Start while a
// child is live is an error, not a kill + respawn (#116).
if await self.environment.runner.processManager.isRunning(ProcessID.spotread) {
self.lastError = "A spotread session is already running."
return
}
self.begin(config: self.buildConfig(cwd: cwd))
}
}
private func buildConfig(cwd: URL) -> SpotReadConfig {
let port: Int?
let name: String
switch selectedInstrument {
case .auto:
port = nil
name = "Auto"
case .device(let device):
port = device.port
name = device.name
}
return SpotReadConfig(
workingDirectory: cwd,
selectedPort: selectedInstrument.chartreadPort,
enableLEDs: environment.settingsStore.load().enableI1Pro2Leds,
isXY: selectedInstrument.isXY,
instrumentName: name,
instrumentPort: port
)
}
private func begin(config: SpotReadConfig) {
isRunning = true
state = .idle
lastError = nil
prompt = "Waiting for a reading…"
let stream = environment.runner.runSpotread(config: config)
streamTask = Task { @MainActor [weak self] in
guard let self else { return }
for await event in stream {
self.handle(event: event)
}
self.isRunning = false
self.state = .idle
if self.lastError == nil {
self.prompt = "Press Start to open the instrument."
}
}
}
private func handle(event: SpotReadEvent) {
switch event {
case .prompt(let result):
state = result.state
prompt = promptText(for: result.state)
case .sample(let sample):
let previous = samples.first
samples.insert(sample, at: 0)
if samples.count > historyLimit {
samples.removeLast()
}
displayedSample = sample
displayedDeltaE = previous.map {
ColorDifference.deltaE00($0.lab, sample.lab)
}
case .log(let batch):
log.append(contentsOf: batch)
case .exit(let code):
if code != 0 {
lastError = "spotread exited with code \(code)"
}
case .failed(let error):
lastError = error.localizedDescription
}
}
private func promptText(for state: ChartreadState) -> String {
switch state {
case .calibrating:
return "Place the instrument on the calibration tile, then Calibrate."
case .awaitingStrip:
return "Place on the patch, then Read."
case .reading, .promptContinue:
return "Waiting for a reading…"
case .warning:
return "Instrument warning — stop and restart if it persists."
case .error:
return "Read error — Stop, then Start again."
default:
return "Waiting for a reading…"
}
}
// MARK: - Transport
/// `btnSpotCalibrate` same bytes Stage 3 sends for calibrate.
func calibrate() {
send(.trigger)
}
/// `btnSpotTrigger` the Read key (`" \n"`).
func trigger() {
send(.trigger)
}
private func send(_ input: ChartreadInput) {
Task { @MainActor [weak self] in
guard let self, self.isRunning else { return }
try? await self.environment.runner.sendSpotreadInput(input)
}
}
/// `btnSpotStop` / sheet dismiss: `q\n`, ~500 ms, then kill if the
/// child is still live.
func stopIfNeeded() {
guard isRunning else { return }
streamTask?.cancel()
streamTask = nil
let processManager = environment.runner.processManager
Task { @MainActor in
try? await processManager.sendStdin(
id: ProcessID.spotread, bytes: ChartreadInput.quit.bytes)
try? await Task.sleep(nanoseconds: 500_000_000)
await processManager.kill(id: ProcessID.spotread)
}
isRunning = false
state = .idle
prompt = "Press Start to open the instrument."
}
// MARK: - History / export
/// Click a history row: copies that sample into the last-sample card.
/// Never re-triggers the instrument.
func selectFromHistory(_ sample: SpotReadSample) {
displayedSample = sample
if let index = samples.firstIndex(of: sample), index + 1 < samples.count {
displayedDeltaE = ColorDifference.deltaE00(samples[index + 1].lab, sample.lab)
} else {
displayedDeltaE = nil
}
}
/// `btnSpotCopyLab` `L* a* b*` of the displayed sample as plain
/// text (`50.0 1.2 -3.4`).
func copyLab() {
guard let sample = displayedSample else { return }
let text = String(format: "%.1f %.1f %.1f", sample.lab.l, sample.lab.a, sample.lab.b)
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(text, forType: .string)
}
/// `btnSpotExportCsv` RFC-4180 via `selectCsvSavePath`. Cancel is
/// a no-op. Rows are newest-first, matching the history list.
func exportCsv() {
guard !samples.isEmpty else { return }
let url = UITestHooks.isEnabled
? UITestHooks.csvExportURL
: fileDialogs.selectCsvSavePath()
guard let url else { return }
var out = "timestamp,L,a,b,dE00,instrument,port\r\n"
for (index, sample) in samples.enumerated() {
let deltaE = index + 1 < samples.count
? String(format: "%.2f", ColorDifference.deltaE00(samples[index + 1].lab, sample.lab))
: ""
out += "\(csvField(iso8601(sample.timestamp))),\(f1(sample.lab.l)),\(f1(sample.lab.a)),\(f1(sample.lab.b)),\(deltaE),\(csvField(sample.instrumentName)),\(sample.port.map(String.init) ?? "")\r\n"
}
do {
try out.write(to: url, atomically: true, encoding: .utf8)
workflow.wizard.showNotice("Spot readings exported: \(url.lastPathComponent)")
} catch {
workflow.wizard.showNotice(
"Export failed: \(error.localizedDescription)", kind: .error)
}
}
private func f1(_ value: Double) -> String {
String(format: "%.1f", value)
}
private func iso8601(_ date: Date) -> String {
ISO8601DateFormatter().string(from: date)
}
private func csvField(_ text: String) -> String {
guard text.contains(",") || text.contains("\"") || text.contains("\n") else {
return text
}
return "\"\(text.replacingOccurrences(of: "\"", with: "\"\""))\""
}
}
+8 -15
View File
@@ -5,7 +5,7 @@ import ICCeryCore
/// docs/08). All documented element ids are wired as accessibility
/// identifiers so the UI-test contract stays stable.
struct Stage1View: View {
@Bindable var workflow: TargetWorkflowViewModel
@ObservedObject var workflow: TargetWorkflowViewModel
var body: some View {
ScrollView {
@@ -238,19 +238,12 @@ struct Stage1View: View {
}
private var logSection: some View {
DisclosureGroup("Process log") {
ScrollView {
Text(workflow.targenLog.joined(separator: "\n"))
.font(.system(.caption, design: .monospaced))
.foregroundStyle(Theme.text)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
}
.frame(minHeight: 120, maxHeight: 200)
.accessibilityIdentifier("targenLog")
}
.foregroundStyle(Theme.text)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("targenLogContainer")
ProcessLogView(
lines: workflow.targenLog,
minHeight: 120,
maxHeight: 200,
containerId: "targenLogContainer",
logId: "targenLog"
)
}
}
+83 -55
View File
@@ -5,7 +5,19 @@ import ICCeryCore
/// issues #9/#10, docs/09). Print controls are visible but inert
/// real spooling lands in M3.
struct Stage2View: View {
@Bindable var workflow: TargetWorkflowViewModel
@ObservedObject var workflow: TargetWorkflowViewModel
/// Observed directly: nested ObservableObjects are not tracked
/// through the parent's `objectWillChange`.
@ObservedObject private var printSession: PrintSessionViewModel
@ObservedObject private var wizard: WizardViewModel
@State private var printGenerationTask: Task<Void, Never>?
init(workflow: TargetWorkflowViewModel) {
self.workflow = workflow
self._printSession = ObservedObject(wrappedValue: workflow.print)
self._wizard = ObservedObject(wrappedValue: workflow.wizard)
}
var body: some View {
ScrollView {
@@ -171,20 +183,13 @@ struct Stage2View: View {
}
private var logSection: some View {
DisclosureGroup("Process log") {
ScrollView {
Text(workflow.printtargLog.joined(separator: "\n"))
.font(.system(.caption, design: .monospaced))
.foregroundStyle(Theme.text)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
}
.frame(minHeight: 100, maxHeight: 180)
.accessibilityIdentifier("printtargLog")
}
.foregroundStyle(Theme.text)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("printtargLogContainer")
ProcessLogView(
lines: workflow.printtargLog,
minHeight: 100,
maxHeight: 180,
containerId: "printtargLogContainer",
logId: "printtargLog"
)
}
// MARK: - TIFF gallery (#tiffGallery) host-side PNG only (#58)
@@ -219,18 +224,19 @@ struct Stage2View: View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 12) {
Text("Print").font(.headline).foregroundStyle(Theme.text)
if let notice = workflow.printNotice {
Image(systemName: workflow.printNoticeIsError
if let notice = workflow.print.printNotice {
Image(systemName: notice.kind == .error
? "xmark.circle.fill" : "info.circle.fill")
.foregroundStyle(workflow.printNoticeIsError
.foregroundStyle(notice.kind == .error
? .red : .blue)
.accessibilityIdentifier("printNotificationIcon")
Text(notice)
.accessibilityValue(notice.kind.accessibilityValue)
Text(notice.text)
.font(.caption)
.foregroundStyle(workflow.printNoticeIsError
.foregroundStyle(notice.kind == .error
? .red : .secondary)
.accessibilityIdentifier("printNotificationText")
.accessibilityValue(notice)
.accessibilityValue(notice.text)
}
Spacer()
}
@@ -238,21 +244,21 @@ struct Stage2View: View {
// Printer row: select + status + refresh + Preferences.
HStack(spacing: 10) {
Picker("Printer", selection: $workflow.selectedPrinter) {
ForEach(workflow.printers, id: \.name) { printer in
Picker("Printer", selection: $workflow.print.selectedPrinter) {
ForEach(workflow.print.printers, id: \.name) { printer in
Text(printer.displayName ?? printer.name)
.tag(printer.name)
}
}
.frame(maxWidth: 320)
.accessibilityIdentifier("printerSelect")
.onChange(of: workflow.selectedPrinter) { _, _ in
workflow.selectedTray = nil
workflow.selectedMediaType = nil
Task { @MainActor in await workflow.reloadSelectedCapabilities() }
.onChange(of: workflow.print.selectedPrinter) { _ in
workflow.print.selectedTray = nil
workflow.print.selectedMediaType = nil
Task { @MainActor in await workflow.print.reloadSelectedCapabilities() }
}
if let selected = workflow.printers
.first(where: { $0.name == workflow.selectedPrinter }) {
if let selected = workflow.print.printers
.first(where: { $0.name == workflow.print.selectedPrinter }) {
Text(selected.status.rawValue)
.font(.caption).foregroundStyle(.secondary)
.padding(.horizontal, 8).padding(.vertical, 3)
@@ -260,33 +266,33 @@ struct Stage2View: View {
.clipShape(Capsule())
.accessibilityIdentifier("printerStatusBadge")
}
Button(action: workflow.refreshPrinters) {
Button(action: workflow.print.refreshPrinters) {
Image(systemName: "arrow.clockwise")
}
.help("Refresh printer list")
.accessibilityIdentifier("btnRefreshPrinters")
Button(action: workflow.openPrinterPreferences) {
Button(action: workflow.print.openPrinterPreferences) {
Image(systemName: "gearshape")
}
.help("Printer properties — bound NSPrintPanel")
.disabled(workflow.selectedPrinter.isEmpty)
.disabled(workflow.print.selectedPrinter.isEmpty)
.accessibilityIdentifier("btnPrinterProperties")
}
// Tray / media / orientation from queue capabilities.
HStack(spacing: 14) {
if !workflow.printerCaps.trays.isEmpty {
Picker("Tray", selection: $workflow.selectedTray) {
ForEach(workflow.printerCaps.trays, id: \.id) {
if !workflow.print.printerCaps.trays.isEmpty {
Picker("Tray", selection: $workflow.print.selectedTray) {
ForEach(workflow.print.printerCaps.trays, id: \.id) {
Text($0.name).tag(Optional($0.id))
}
}
.frame(maxWidth: 200)
.accessibilityIdentifier("printerTraySelect")
}
if !workflow.printerCaps.mediaTypes.isEmpty {
Picker("Media", selection: $workflow.selectedMediaType) {
ForEach(workflow.printerCaps.mediaTypes, id: \.id) {
if !workflow.print.printerCaps.mediaTypes.isEmpty {
Picker("Media", selection: $workflow.print.selectedMediaType) {
ForEach(workflow.print.printerCaps.mediaTypes, id: \.id) {
Text($0.name).tag(Optional($0.id))
}
}
@@ -296,27 +302,31 @@ struct Stage2View: View {
.accessibilityIdentifier("printerMediaTypeSelect")
}
HStack(spacing: 0) {
Button("Portrait") { workflow.printOrientation = "portrait" }
Button("Portrait") { workflow.print.printOrientation = "portrait" }
.buttonStyle(.bordered)
.tint(workflow.printOrientation == "portrait" ? .accentColor : .gray)
.tint(workflow.print.printOrientation == "portrait" ? .accentColor : .gray)
.accessibilityIdentifier("btnOrientPortrait")
Button("Landscape") { workflow.printOrientation = "landscape" }
Button("Landscape") { workflow.print.printOrientation = "landscape" }
.buttonStyle(.bordered)
.tint(workflow.printOrientation == "landscape" ? .accentColor : .gray)
.tint(workflow.print.printOrientation == "landscape" ? .accentColor : .gray)
.accessibilityIdentifier("btnOrientLandscape")
}
Spacer()
}
HStack(spacing: 8) {
Button(action: workflow.printAllPages) {
Label(workflow.isPrinting ? "Printing…" : "Print All",
Button(action: {
if let result = workflow.printtargResult {
workflow.print.printAllPages(from: result, pageSize: workflow.pageSize)
}
}) {
Label(workflow.print.isPrinting ? "Printing…" : "Print All",
systemImage: "printer")
}
.controlSize(.large)
.disabled(workflow.isPrinting
.disabled(workflow.print.isPrinting
|| workflow.printtargResult == nil
|| workflow.selectedPrinter.isEmpty)
|| workflow.print.selectedPrinter.isEmpty)
.accessibilityIdentifier("btnPrintAll")
Spacer()
Button("Advance to Stage 3") { workflow.advanceToStage3() }
@@ -330,11 +340,20 @@ struct Stage2View: View {
.clipShape(RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium))
.accessibilityElement(children: .contain)
.accessibilityIdentifier("rawPrintPanel")
.task(id: workflow.printtargResult?.pages.count) {
// Auto-enumerate once a manifest exists and whenever it
// changes (e.g. resume from .ti2).
if workflow.printers.isEmpty, workflow.printtargResult != nil {
workflow.refreshPrinters()
.onAppear { schedulePrinterRefresh() }
.onChange(of: workflow.printtargResult?.pages.count) { _ in
schedulePrinterRefresh()
}
}
/// Auto-enumerates printers once a manifest exists and whenever it
/// changes (e.g. resume from .ti2). The explicit task handle keeps
/// a superseded run from racing the next one.
private func schedulePrinterRefresh() {
printGenerationTask?.cancel()
printGenerationTask = Task { @MainActor in
if workflow.print.printers.isEmpty, workflow.printtargResult != nil {
workflow.print.refreshPrinters()
}
}
}
@@ -343,7 +362,16 @@ struct Stage2View: View {
/// One gallery cell: PNG preview + per-page Print button.
private struct GalleryPageView: View {
let page: GalleryPage
let workflow: TargetWorkflowViewModel
@ObservedObject var workflow: TargetWorkflowViewModel
/// Observed directly: `print` is a nested ObservableObject and its
/// `isPrinting`/`selectedPrinter` changes drive this cell's button.
@ObservedObject private var printSession: PrintSessionViewModel
init(page: GalleryPage, workflow: TargetWorkflowViewModel) {
self.page = page
self.workflow = workflow
self._printSession = ObservedObject(wrappedValue: workflow.print)
}
var body: some View {
VStack(spacing: 6) {
@@ -364,9 +392,9 @@ private struct GalleryPageView: View {
Text("\(page.page.patches) patches · " +
"\(Int(page.page.widthMm))×\(Int(page.page.heightMm)) mm")
.font(.caption2).foregroundStyle(.secondary)
Button("Print") { workflow.printPage(page) }
.disabled(workflow.isPrinting
|| workflow.selectedPrinter.isEmpty)
Button("Print") { workflow.print.printPage(page, pageSize: workflow.pageSize) }
.disabled(workflow.print.isPrinting
|| workflow.print.selectedPrinter.isEmpty)
.accessibilityIdentifier("btnPrintPage-\(page.index)")
}
.padding(8)
+22 -18
View File
@@ -4,7 +4,15 @@ import ICCeryCore
/// Stage 3 measurement, live swatches, and multi-pass averaging.
struct Stage3View: View {
@Bindable var model: MeasurementWorkflowViewModel
@ObservedObject var model: MeasurementWorkflowViewModel
/// `model.basename`/`model.workingDirectory` delegate to `wizard`;
/// observe it directly so header updates propagate.
@ObservedObject private var wizard: WizardViewModel
init(model: MeasurementWorkflowViewModel) {
self.model = model
self._wizard = ObservedObject(wrappedValue: model.wizard)
}
var body: some View {
VStack(spacing: 0) {
@@ -164,28 +172,22 @@ struct Stage3View: View {
.foregroundStyle(Theme.accent)
}
if let lastError = model.lastError {
Text(lastError)
if let notice = model.chartreadNotice {
Text(notice.text)
.font(.caption)
.foregroundStyle(.red)
.foregroundStyle(notice.kind.tint)
.accessibilityIdentifier("chartreadLastError")
.accessibilityValue(lastError)
.accessibilityValue(notice.text)
}
controlButtons
if !model.chartreadLog.isEmpty {
DisclosureGroup("Log") {
VStack(alignment: .leading) {
ForEach(model.chartreadLog, id: \.self) { line in
Text(line)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
}
}
}
.foregroundStyle(Theme.text)
.accessibilityIdentifier("chartreadLogContainer")
ProcessLogView(
lines: model.chartreadLog,
containerId: "chartreadLogContainer",
logId: "chartreadLog"
)
}
}
.padding(16)
@@ -371,9 +373,11 @@ struct Stage3View: View {
}
if let notice = model.finishNotice {
Text(notice)
Text(notice.text)
.font(.caption)
.foregroundStyle(model.finishNoticeIsError ? .red : .green)
.foregroundStyle(notice.kind == .error ? .red : .green)
.accessibilityIdentifier("chartreadFinishNotice")
.accessibilityValue(notice.kind.accessibilityValue)
}
}
.padding(16)
+27 -28
View File
@@ -3,7 +3,15 @@ import ICCeryCore
/// Stage 4 build an ICC/ICM profile from the canonical `.ti3`.
struct Stage4View: View {
@Bindable var model: ProfileWorkflowViewModel
@ObservedObject var model: ProfileWorkflowViewModel
/// Header reads `model.wizard.basename`; observe the nested
/// ObservableObject directly.
@ObservedObject private var wizard: WizardViewModel
init(model: ProfileWorkflowViewModel) {
self.model = model
self._wizard = ObservedObject(wrappedValue: model.wizard)
}
var body: some View {
VStack(spacing: 0) {
@@ -130,16 +138,20 @@ struct Stage4View: View {
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("colprofCopyright")
Toggle("Apply calibration curve", isOn: $model.applyCalibration)
.accessibilityIdentifier("colprofApplyCalibration")
// Nested VStack keeps the parent at the Swift 5.7 ViewBuilder
// 10-child limit (Xcode 14.2 / macOS 12 CI runner, #111).
VStack(alignment: .leading, spacing: 12) {
Toggle("Apply calibration curve", isOn: $model.applyCalibration)
.accessibilityIdentifier("colprofApplyCalibration")
if model.applyCalibration {
HStack {
TextField("Calibration .cal file", text: $model.calibrationFile)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("colprofCalibrationFile")
Button("Browse…") { model.browseForCalibrationFile() }
.accessibilityIdentifier("btnBrowseCalibrationFile")
if model.applyCalibration {
HStack {
TextField("Calibration .cal file", text: $model.calibrationFile)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("colprofCalibrationFile")
Button("Browse…") { model.browseForCalibrationFile() }
.accessibilityIdentifier("btnBrowseCalibrationFile")
}
}
}
}
@@ -166,27 +178,14 @@ struct Stage4View: View {
}
Spacer()
if let lastError = model.lastError {
Text(lastError)
.font(.caption)
.foregroundStyle(.red)
.accessibilityIdentifier("colprofLastError")
}
}
if !model.colprofLog.isEmpty {
DisclosureGroup("Log") {
VStack(alignment: .leading) {
ForEach(model.colprofLog, id: \.self) { line in
Text(line)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
}
}
}
.foregroundStyle(Theme.text)
.accessibilityIdentifier("colprofLogContainer")
ProcessLogView(
lines: model.colprofLog,
containerId: "colprofLogContainer",
logId: "colprofLog"
)
}
}
.padding(16)
+9 -1
View File
@@ -3,7 +3,15 @@ import ICCeryCore
/// Stage 5 verify the generated profile, track drift, and install.
struct Stage5View: View {
@Bindable var model: ProfileWorkflowViewModel
@ObservedObject var model: ProfileWorkflowViewModel
/// Header/buttons read `model.wizard.*`; observe the nested
/// ObservableObject directly.
@ObservedObject private var wizard: WizardViewModel
init(model: ProfileWorkflowViewModel) {
self.model = model
self._wizard = ObservedObject(wrappedValue: model.wizard)
}
var body: some View {
VStack(spacing: 0) {
-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)
}
}
+155 -336
View File
@@ -1,5 +1,5 @@
import Combine
import Foundation
import Observation
import ICCeryCore
/// Stage 1/2 form state, runner orchestration, resume flow, and preset
@@ -10,8 +10,7 @@ import ICCeryCore
/// All process work runs through `ArgyllRunner` off `@MainActor`; only
/// coalesced log batches and completion hop back.
@MainActor
@Observable
final class TargetWorkflowViewModel {
final class TargetWorkflowViewModel: ObservableObject {
let wizard: WizardViewModel
let environment: AppEnvironment
@@ -19,117 +18,109 @@ final class TargetWorkflowViewModel {
// MARK: - Stage 1 form (targen)
var colourSpace: ColourSpace = .rgb {
@Published var colourSpace: ColourSpace = .rgb {
didSet {
guard colourSpace != oldValue else { return }
// CMYK black patches default to 0, RGB to 4 (docs/08).
blackPatches = colourSpace == .cmyk ? 0 : 4
}
}
var patchPreset: PatchCountPreset = .standard800
@Published var patchPreset: PatchCountPreset = .standard800
/// `#patchCountCustom` used when `patchPreset == .custom`.
var customPatchCount = 2500
var whitePatches = 4
var blackPatches = 4
@Published var customPatchCount = 2500
@Published var whitePatches = 4
@Published var blackPatches = 4
// Advanced each optional flag is enabled + value, so an untouched
// control emits nothing (#advanced fields are opt-in).
var greyStepsEnabled = false
var greySteps = 5
var singleChannelEnabled = false
var singleChannelSteps = 5
var neutralStepsEnabled = false
var neutralSteps = 3
var neutralConcEnabled = false
var neutralConcentration = 0.50
var preconditioningProfile: String?
var highQuality = false
var adaptationEnabled = false
var adaptation = 0.10
var algorithm: FullSpreadAlgorithm = .ofps
var inkLimitEnabled = false
var totalInkLimit = 320
var darkEmphasisEnabled = false
var darkEmphasis = 1.0
var devicePowerEnabled = false
var devicePower = 1.0
@Published var greyStepsEnabled = false
@Published var greySteps = 5
@Published var singleChannelEnabled = false
@Published var singleChannelSteps = 5
@Published var neutralStepsEnabled = false
@Published var neutralSteps = 3
@Published var neutralConcEnabled = false
@Published var neutralConcentration = 0.50
@Published var preconditioningProfile: String?
@Published var highQuality = false
@Published var adaptationEnabled = false
@Published var adaptation = 0.10
@Published var algorithm: FullSpreadAlgorithm = .ofps
@Published var inkLimitEnabled = false
@Published var totalInkLimit = 320
@Published var darkEmphasisEnabled = false
@Published var darkEmphasis = 1.0
@Published var devicePowerEnabled = false
@Published var devicePower = 1.0
/// `#targetBasename` no placeholder is ever invented (#60).
var targetBasename = ""
@Published var targetBasename = ""
/// `#selectedPathDisplay` / resolved cwd.
var targetDirectory: URL?
@Published var targetDirectory: URL?
// MARK: - Stage 2 form (printtarg)
var instrument: PrintInstrument = .i1
var pageSize: PageSize = .a4
var customPageW = 210.0
var customPageH = 297.0
var bitDepth: TiffBitDepth = .eight
@Published var instrument: PrintInstrument = .i1
@Published var pageSize: PageSize = .a4
@Published var customPageW = 210.0
@Published var customPageH = 297.0
@Published var bitDepth: TiffBitDepth = .eight
/// `#tiffDpi` two-way bound; presets can change it (150-DPI draft
/// regression must be visible here).
var tiffDpi = 300
var layoutOrder: LayoutOrder = .deterministic
var customSeed = 1
var labelIsCustom = false
var customLabel = ""
var metaPrinter = ""
var metaInkSet = ""
var metaDriverPaper = ""
var metaActualPaper = ""
@Published var tiffDpi = 300
@Published var layoutOrder: LayoutOrder = .deterministic
@Published var customSeed = 1
@Published var labelIsCustom = false
@Published var customLabel = ""
@Published var metaPrinter = ""
@Published var metaInkSet = ""
@Published var metaDriverPaper = ""
@Published var metaActualPaper = ""
// MARK: - Run state
var targenRunning = false
var targenLog: [String] = []
var printtargRunning = false
var printtargLog: [String] = []
var printtargResult: PrinttargResult?
@Published var targenRunning = false
@Published var targenLog: [String] = []
@Published var printtargRunning = false
@Published var printtargLog: [String] = []
@Published var printtargResult: PrinttargResult?
/// Sticky until the target changes: `.ti2` resume landed us on
/// Stage 3 (`#stage3LoadedTargetBanner` data).
var resumedFromTi2 = false
// MARK: - Print panel (issue 17)
/// CUPS destinations from `lpstat` (#printerSelect).
var printers: [Printer] = []
/// Selected queue name.
var selectedPrinter = ""
/// Capabilities of the selected queue (#printerTraySelect /
/// #printerMediaTypeSelect / PageSize source).
var printerCaps = PrinterCapabilities()
var selectedTray: Int?
var selectedMediaType: String?
/// "portrait" | "landscape" (#btnOrientPortrait/#btnOrientLandscape).
var printOrientation = "portrait"
/// Per-queue captured `key=value` strings from Preferences replayed
/// on `lp` (session-only, docs/11 §capturedCupsOptions).
var capturedCupsOptions: [String: String] = [:]
/// In-panel notice (#printNotification) cancel info, not error.
var printNotice: String?
var printNoticeIsError = false
var isPrinting = false
/// Strong reference to the active print task so the unstructured
/// `Task` is not dropped before it resumes.
private var printTask: Task<Void, Never>?
@Published var resumedFromTi2 = false
// MARK: - Presets
var presets: [ProfilingPreset] = []
var selectedPresetID = "none"
var showingSavePreset = false
var showingManagePresets = false
var savePresetName = ""
var savePresetDesc = ""
@Published var presets: [ProfilingPreset] = []
@Published var selectedPresetID = "none"
@Published var showingSavePreset = false
@Published var showingManagePresets = false
@Published var savePresetName = ""
@Published var savePresetDesc = ""
// MARK: - Media library (issue #146)
@Published var showingSaveMedia = false
@Published var showingManageMedia = false
// MARK: - Spot read (issue #148)
/// `RootView` sheet binding for the spot-read console.
@Published var showingSpotRead = false
/// Stage 3 measurement workflow, owned at the app level so it persists
/// across stage switches and can observe settings changes.
var measurement: MeasurementWorkflowViewModel
@Published var measurement: MeasurementWorkflowViewModel
/// Stage 4/5 profile workflow, owned at the app level so it persists
/// across stage switches and can observe preset values.
var profile: ProfileWorkflowViewModel
@Published var profile: ProfileWorkflowViewModel
/// Stage 0 calibration workflow.
var calibration: CalibrationViewModel!
@Published var calibration: CalibrationViewModel!
/// Stage 2 unmanaged print session.
@Published var print: PrintSessionViewModel!
/// Media recipe library needs a complete `self`.
@Published var media: MediaLibraryViewModel!
/// Spot-read console, created last needs `wizard` / `measurement`.
@Published var spotRead: SpotReadViewModel!
init(environment: AppEnvironment = .live()) {
self.environment = environment
@@ -142,12 +133,21 @@ final class TargetWorkflowViewModel {
wizard: wizard,
environment: environment
)
self.print = PrintSessionViewModel(wizard: wizard, environment: environment)
self.calibration = nil
self.calibration = CalibrationViewModel(
workflow: self,
profile: self.profile,
environment: environment
)
self.media = MediaLibraryViewModel(
workflow: self,
environment: environment
)
self.spotRead = SpotReadViewModel(
workflow: self,
environment: environment
)
reloadPresets()
}
@@ -227,16 +227,16 @@ final class TargetWorkflowViewModel {
func generateTarget() {
guard canGenerate, !targenRunning else { return }
let config = buildTargenConfig()
targenRunning = true
targenLog = []
resumedFromTi2 = false
let runner = environment.runner
Task { @MainActor in
do {
let url = try await runner.runTargen(config: config) { [weak self] batch in
Task { @MainActor [weak self] in
self?.targenLog.append(contentsOf: batch)
}
let url = try await ProcessRunSupport.runLogged(
setRunning: { self.targenRunning = $0 },
resetLog: { self.targenLog = [] },
onLog: { self.targenLog.append(contentsOf: $0) }
) { onLog in
try await runner.runTargen(config: config, onLogBatch: onLog)
}
wizard.setTarget(
basename: config.basename,
@@ -248,7 +248,6 @@ final class TargetWorkflowViewModel {
wizard.showNotice(
"targen failed: \(error.localizedDescription)", kind: .error)
}
targenRunning = false
}
}
@@ -261,7 +260,12 @@ final class TargetWorkflowViewModel {
? UITestHooks.datasetImportURL
: fileDialogs.selectDatasetFile()
guard let url else { return }
importMeasurementDataset(from: url)
}
/// Test seam (issue #80): unit tests pass missing or malformed URLs
/// directly instead of mutating the global environment.
func importMeasurementDataset(from url: URL) {
do {
let dataset = try CGATSParser.parse(url: url)
guard let directory = targetDirectory ?? wizard.effectiveWorkingDirectory else {
@@ -284,8 +288,6 @@ final class TargetWorkflowViewModel {
} else {
wizard.showNotice("Imported dataset is not ready for profiling.", kind: .warning)
}
} catch let error as CGATSParseError {
wizard.showNotice("Import failed: \(error.localizedDescription)", kind: .error)
} catch {
wizard.showNotice("Import failed: \(error.localizedDescription)", kind: .error)
}
@@ -360,28 +362,25 @@ final class TargetWorkflowViewModel {
func createLayout() {
guard wizard.isUnlocked(.layOutPrint), !printtargRunning else { return }
let config = buildPrinttargConfig()
printtargRunning = true
printtargLog = []
printtargResult = nil
let runner = environment.runner
Task { @MainActor in
do {
let result = try await runner.runPrinttarg(config: config) { [weak self] batch in
Task { @MainActor [weak self] in
self?.printtargLog.append(contentsOf: batch)
}
let result = try await ProcessRunSupport.runLogged(
setRunning: { self.printtargRunning = $0 },
resetLog: { self.printtargLog = [] },
onLog: { self.printtargLog.append(contentsOf: $0) }
) { onLog in
try await runner.runPrinttarg(config: config, onLogBatch: onLog)
}
printtargResult = result
wizard.refreshGating()
wizard.showNotice(
"Layout created — \(result.manifest.pages.count) page(s) ready.")
} catch {
// Stay on Stage 2: non-zero exit, malformed manifest, or
// missing .ti2 must never advance the wizard (#156).
wizard.showNotice(
"printtarg failed: \(error.localizedDescription)", kind: .error)
}
printtargRunning = false
}
}
@@ -391,160 +390,6 @@ final class TargetWorkflowViewModel {
wizard.go(to: .measure)
}
// MARK: - Print panel actions (issue 17)
/// `#btnRefreshPrinters` re-enumerate CUPS destinations and load
/// capabilities for the selection. Auto-runs when the panel first
/// appears with a manifest.
func refreshPrinters() {
let cups = environment.cupsService
Task { @MainActor in
do {
let list = try await cups.listPrinters()
printers = list
if !list.contains(where: { $0.name == selectedPrinter }) {
selectedPrinter = list.first { $0.isDefault }?.name
?? list.first?.name ?? ""
}
await reloadSelectedCapabilities()
} catch {
printNotice = "Could not list printers: \(error.localizedDescription)"
printNoticeIsError = true
}
}
}
/// Capabilities for `selectedPrinter` trays / media / sizes feed
/// the selects.
func reloadSelectedCapabilities() async {
guard !selectedPrinter.isEmpty else {
printerCaps = PrinterCapabilities()
return
}
do {
printerCaps = try await environment.cupsService
.capabilities(for: selectedPrinter)
// Default selections only when the captured options didn't
// already pin them (Preferences round-trip wins).
if selectedMediaType == nil {
selectedMediaType = printerCaps.mediaTypes.first?.id
}
if selectedTray == nil {
selectedTray = printerCaps.trays.first?.id
}
} catch {
printerCaps = PrinterCapabilities()
}
}
/// `#btnPrinterProperties` bound NSPrintPanel ("Use Settings").
/// Cancel info notice, never an error, cache untouched. On OK the
/// captured options are stored per-queue; a panel-side queue switch
/// updates `printerSelect` when the returned CUPS id is in the list.
func openPrinterPreferences() {
guard !selectedPrinter.isEmpty else { return }
let queue = selectedPrinter
let displayName = printers.first { $0.name == queue }?.displayName
let cups = environment.cupsService
Task { @MainActor in
do {
guard let result = try await PrintPanelService()
.showProperties(
queue: queue, displayName: displayName,
cupsService: cups)
else {
printNotice = "Printer properties dialog cancelled."
printNoticeIsError = false
return
}
if let selected = result.selectedPrinter,
printers.contains(where: { $0.name == selected }),
selected != queue {
selectedPrinter = selected
await reloadSelectedCapabilities()
}
if let captured = result.options.cupsOptions {
capturedCupsOptions[selectedPrinter] = captured
}
if let media = result.options.mediaType {
selectedMediaType = media
}
printNotice = "Settings captured for \(selectedPrinter)."
printNoticeIsError = false
} catch {
printNotice = error.localizedDescription
printNoticeIsError = true
}
}
}
/// `#btnPrintAll` spool every gallery TIFF, sequentially. Stops on
/// the first failure so the user sees which page failed.
func printAllPages() {
guard let result = printtargResult, !isPrinting else { return }
isPrinting = true
let task = Task { @MainActor [weak self] in
guard let self else { return }
defer { self.printTask = nil }
var printed = 0
for page in result.pages {
do {
try await spool(page, index: page.index)
printed += 1
} catch {
printNotice = "Print failed on \(page.page.filename): "
+ error.localizedDescription
printNoticeIsError = true
isPrinting = false
return
}
}
printNotice = "Sent \(printed) page(s) to \(selectedPrinter)."
printNoticeIsError = false
isPrinting = false
}
printTask = task
}
/// `#btnPrintPage-N` one TIFF.
func printPage(_ page: GalleryPage) {
guard !isPrinting else { return }
isPrinting = true
let task = Task { @MainActor [weak self] in
guard let self else { return }
defer { self.printTask = nil }
do {
try await spool(page, index: page.index)
printNotice = "Sent \(page.page.filename) to \(selectedPrinter)."
printNoticeIsError = false
} catch {
printNotice = "Print failed: \(error.localizedDescription)"
printNoticeIsError = true
}
isPrinting = false
}
printTask = task
}
private func spool(_ page: GalleryPage, index: Int) async throws {
guard !selectedPrinter.isEmpty else {
throw CupsError.noPrinterSelected
}
let options = PrintOptions(
orientation: printOrientation,
paperSize: pageSize == .custom ? nil : pageSize.rawValue,
mediaType: selectedMediaType,
ppdUncorrectedPassthrough: true,
cupsOptions: capturedCupsOptions[selectedPrinter])
try await environment.cupsService.printTarget(
queue: selectedPrinter,
tiffPath: page.fileURL.path,
options: options,
page: index)
// For Stage 5 history (#95): record which queue printed.
wizard.printerName = selectedPrinter
}
// MARK: - Presets
func reloadPresets() {
@@ -554,55 +399,61 @@ final class TargetWorkflowViewModel {
/// Applies every Stage 1/2 field of the preset to the live form
/// (bidirectional the draft preset's dpi=150 must be visible).
func applyPreset(_ preset: ProfilingPreset) {
colourSpace = preset.colourSpace == "cmyk" ? .cmyk : .rgb
patchPreset = PatchCountPreset(rawValue: "\(preset.patchCount)") ?? .custom
customPatchCount = preset.patchCount
whitePatches = preset.whitePatches
blackPatches = preset.blackPatches
greySteps = preset.greySteps ?? 5; greyStepsEnabled = preset.greySteps != nil
singleChannelSteps = preset.singleChannelSteps ?? 5
singleChannelEnabled = preset.singleChannelSteps != nil
neutralSteps = preset.neutralSteps ?? 3
neutralStepsEnabled = preset.neutralSteps != nil
neutralConcentration = preset.neutralConcentration ?? 0.50
neutralConcEnabled = preset.neutralConcentration != nil
preconditioningProfile = preset.preconditioningProfile
highQuality = preset.ofpsHighQuality == true
adaptation = preset.ofpsAdaptation ?? 0.10
adaptationEnabled = preset.ofpsAdaptation != nil
algorithm = preset.fullSpreadAlgorithm
.flatMap { FullSpreadAlgorithm(presetValue: $0) } ?? .ofps
totalInkLimit = preset.totalInkLimit ?? 320
inkLimitEnabled = preset.totalInkLimit != nil
darkEmphasis = preset.darkEmphasis ?? 1.0
darkEmphasisEnabled = preset.darkEmphasis != nil
devicePower = preset.devicePower ?? 1.0
devicePowerEnabled = preset.devicePower != nil
instrument = PrintInstrument(rawValue: preset.instrument) ?? .i1
if let size = PageSize(rawValue: preset.pageSize) {
pageSize = size
} else if let (w, h) = Self.parseCustomPage(preset.pageSize) {
pageSize = .custom; customPageW = w; customPageH = h
} else {
pageSize = .a4
}
bitDepth = preset.bitDepth == 16 ? .sixteen : .eight
tiffDpi = preset.dpi
if preset.noRandomize == true {
layoutOrder = .raster
} else if (preset.randomSeed ?? 1) == 1 {
layoutOrder = .deterministic
} else {
layoutOrder = .customSeed
}
customSeed = preset.randomSeed ?? 1
let targen = TargenConfig(preset: preset, basename: targetBasename, workingDirectory: targetDirectory)
applyTargenForm(targen)
// Stage 4 state (incl. calibration) is applied before Stage 2 so
// the layout config receives the preset's calibration path, not
// stale live state (#82).
profile.applyPreset(preset)
let printtarg = PrinttargConfig(
preset: preset,
basename: wizard.basename,
workingDirectory: wizard.effectiveWorkingDirectory,
calibrationFile: profile.applyCalibration && !profile.calibrationFile.isEmpty
? profile.calibrationFile : nil
)
applyPrinttargForm(printtarg)
selectedPresetID = preset.id
}
private func applyTargenForm(_ config: TargenConfig) {
colourSpace = config.colourSpace
patchPreset = PatchCountPreset(rawValue: "\(config.patchCount)") ?? .custom
customPatchCount = config.patchCount
whitePatches = config.whitePatches
blackPatches = config.blackPatches
greySteps = config.greySteps ?? 5
greyStepsEnabled = config.greySteps != nil
singleChannelSteps = config.singleChannelSteps ?? 5
singleChannelEnabled = config.singleChannelSteps != nil
neutralSteps = config.neutralSteps ?? 3
neutralStepsEnabled = config.neutralSteps != nil
neutralConcentration = config.neutralConcentration ?? 0.50
neutralConcEnabled = config.neutralConcentration != nil
preconditioningProfile = config.preconditioningProfile
highQuality = config.ofpsHighQuality == true
adaptation = config.ofpsAdaptation ?? 0.10
adaptationEnabled = config.ofpsAdaptation != nil
algorithm = config.fullSpreadAlgorithm ?? .ofps
totalInkLimit = config.totalInkLimit ?? 320
inkLimitEnabled = config.totalInkLimit != nil
darkEmphasis = config.darkEmphasis ?? 1.0
darkEmphasisEnabled = config.darkEmphasis != nil
devicePower = config.devicePower ?? 1.0
devicePowerEnabled = config.devicePower != nil
}
private func applyPrinttargForm(_ config: PrinttargConfig) {
instrument = config.instrument
pageSize = config.pageSize
customPageW = config.customPageWidth
customPageH = config.customPageHeight
bitDepth = config.bitDepth
tiffDpi = config.dpi
layoutOrder = config.layoutOrder
customSeed = config.customSeed
}
/// Snapshot of the live Stage 1/2 form as a custom preset.
func saveCurrentAsPreset() {
let name = savePresetName.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -614,39 +465,11 @@ final class TargetWorkflowViewModel {
id: "custom-\(UUID().uuidString.lowercased())",
name: name,
description: savePresetDesc.trimmingCharacters(in: .whitespacesAndNewlines),
colourSpace: colourSpace == .cmyk ? "cmyk" : "rgb",
patchCount: effectivePatchCount,
whitePatches: whitePatches,
blackPatches: blackPatches,
greySteps: greyStepsEnabled ? greySteps : nil,
singleChannelSteps: singleChannelEnabled ? singleChannelSteps : nil,
neutralSteps: neutralStepsEnabled ? neutralSteps : nil,
neutralConcentration: neutralConcEnabled ? neutralConcentration : nil,
preconditioningProfile: preconditioningProfile,
ofpsHighQuality: highQuality ? true : nil,
ofpsAdaptation: adaptationEnabled ? adaptation : nil,
fullSpreadAlgorithm: algorithm.presetValue,
totalInkLimit: inkLimitEnabled ? totalInkLimit : nil,
darkEmphasis: darkEmphasisEnabled ? darkEmphasis : nil,
devicePower: devicePowerEnabled ? devicePower : nil,
instrument: instrument.rawValue,
pageSize: pageSize == .custom
? "\(Int(customPageW))x\(Int(customPageH))"
: pageSize.rawValue,
bitDepth: bitDepth.rawValue,
dpi: tiffDpi,
randomSeed: layoutOrder == .deterministic ? 1 : customSeed,
noRandomize: layoutOrder == .raster,
targen: buildTargenConfig(),
printtarg: buildPrinttargConfig(),
colprof: profile.buildColprofConfig(),
calibrationFile: profile.calibrationFile.isEmpty ? nil : profile.calibrationFile,
applyCalibration: profile.applyCalibration ? true : nil,
colprofAlgorithm: profile.algorithm,
colprofQuality: profile.quality,
colprofIntent: profile.intent.isEmpty ? nil : profile.intent,
colprofFwa: profile.fwaValue,
colprofIlluminant: profile.illuminant.isEmpty ? nil : profile.illuminant,
colprofObserver: profile.observer.isEmpty ? nil : profile.observer,
colprofInputViewingCond: profile.inputViewingCond.isEmpty ? nil : profile.inputViewingCond,
colprofOutputViewingCond: profile.outputViewingCond.isEmpty ? nil : profile.outputViewingCond
applyCalibration: profile.applyCalibration ? true : nil
)
do {
try environment.presetStore.saveCustom(preset)
@@ -710,10 +533,6 @@ final class TargetWorkflowViewModel {
}
static func parseCustomPage(_ raw: String) -> (Double, Double)? {
let parts = raw.lowercased().split(separator: "x")
guard parts.count == 2,
let w = Double(parts[0]), let h = Double(parts[1]),
w >= 50, h >= 50 else { return nil }
return (w, h)
PageSize.parseCustom(raw)
}
}
+22 -19
View File
@@ -1,5 +1,5 @@
import Combine
import Foundation
import Observation
import ICCeryCore
/// Wizard state machine + artefact gating (issue #4, docs/06).
@@ -10,47 +10,46 @@ import ICCeryCore
/// `wizard_state.json`; unlocks come from `ArtefactProbe.verify`
/// navigation is disk, not buttons.
@MainActor
@Observable
final class WizardViewModel {
final class WizardViewModel: ObservableObject {
// MARK: - wizardState fields (persisted)
var stage: WizardStage {
@Published var stage: WizardStage {
didSet { if stage != oldValue { persist() } }
}
/// `wizardState.basename` empty until a real artefact names it (#60).
var basename: String {
@Published var basename: String {
didSet { if basename != oldValue { refreshGating(); persist() } }
}
/// `wizardState.cwd` resolved via `resolveSafeCwd` (#59).
var workingDirectory: URL? {
@Published var workingDirectory: URL? {
didSet { if workingDirectory != oldValue { refreshGating(); persist() } }
}
var printerName: String? {
@Published var printerName: String? {
didSet { if printerName != oldValue { persist() } }
}
var sessionMode: SessionMode {
@Published var sessionMode: SessionMode {
didSet { if sessionMode != oldValue { persist() } }
}
/// `profileBasename` may differ after a `.ti3` import (#94).
var profileBasename: String? {
@Published var profileBasename: String? {
didSet { if profileBasename != oldValue { persist() } }
}
/// Pre-`CAL_` basename, persisted so relaunch/Force Quit can restore it (#29).
var calibrationOriginalBasename: String {
@Published var calibrationOriginalBasename: String {
didSet { if calibrationOriginalBasename != oldValue { persist() } }
}
// MARK: - Ephemeral
/// Banner notice currently displayed (`#wizardNotification`).
var notice: Notice?
@Published var notice: Notice?
/// Current artefact probe result; recomputed on `refreshGating()`.
private(set) var artefacts = StageArtefacts()
@Published private(set) var artefacts = StageArtefacts()
/// Whether the 3D gamut viewer sheet is open (issue #28).
var showingGamutViewer = false
@Published var showingGamutViewer = false
/// Optional `.gam` URL to show alongside the sRGB reference.
var gamutProfileURL: URL?
@Published var gamutProfileURL: URL?
private let stateStore: WizardStateStore
private var noticeDismissTask: Task<Void, Never>?
@@ -67,8 +66,12 @@ final class WizardViewModel {
self.calibrationOriginalBasename = s.calibrationOriginalBasename
// A Force Quit mid-calibration leaves a CAL_ basename behind; restore
// the original before the UI can do anything with it (#29).
if basename.hasPrefix("CAL_"), !calibrationOriginalBasename.isEmpty {
basename = calibrationOriginalBasename
if CalibrationIdentity.isCalibration(basename), !calibrationOriginalBasename.isEmpty {
let identity = CalibrationIdentity.parse(
liveBasename: basename,
persistedOriginal: calibrationOriginalBasename
)
basename = identity.originalBasename
calibrationOriginalBasename = ""
sessionMode = .profile
stage = .generate
@@ -128,7 +131,7 @@ final class WizardViewModel {
/// refused and the original basename is restored (#29).
func go(to target: WizardStage) {
guard target != .calibrate else { enterCalibration(); return }
if basename.hasPrefix("CAL_") {
if CalibrationIdentity.isCalibration(basename) {
guard !calibrationOriginalBasename.isEmpty else {
showNotice(
"Cannot leave calibration — the original target name is missing.",
@@ -152,7 +155,7 @@ final class WizardViewModel {
}
func enterCalibration() {
if !basename.isEmpty, !basename.hasPrefix("CAL_"), calibrationOriginalBasename.isEmpty {
if !basename.isEmpty, !CalibrationIdentity.isCalibration(basename), calibrationOriginalBasename.isEmpty {
calibrationOriginalBasename = basename
}
sessionMode = .calibration
@@ -197,7 +200,7 @@ final class WizardViewModel {
self.notice = notice
if let delay = notice.autoHideAfter {
noticeDismissTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(delay))
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
guard !Task.isCancelled else { return }
if self?.notice?.id == notice.id {
self?.notice = nil
+13 -15
View File
@@ -1,27 +1,25 @@
import Testing
import XCTest
import Foundation
@testable import ICCeryCore
@Suite("AppPaths")
struct AppPathsTests {
@Test func appDataDirUsesBundleID() {
#expect(AppPaths.appDataDir.path.contains("Library/Application Support/com.gronod.iccery2"))
final class AppPathsTests: XCTestCase {
func testAppDataDirUsesBundleID() {
XCTAssertTrue(AppPaths.appDataDir.path.contains("Library/Application Support/com.gronod.iccery2"))
}
@Test func logFileIsUnderLibraryLogs() {
#expect(AppPaths.logFile.lastPathComponent == "iccery.log")
#expect(AppPaths.logFile.path.contains("Library/Logs/com.gronod.iccery2"))
func testLogFileIsUnderLibraryLogs() {
XCTAssertEqual(AppPaths.logFile.lastPathComponent, "iccery.log")
XCTAssertTrue(AppPaths.logFile.path.contains("Library/Logs/com.gronod.iccery2"))
}
@Test func bundledArgyllDirIsInsideResources() {
#expect(AppPaths.bundledArgyllDir.lastPathComponent == "Argyll")
func testBundledArgyllDirIsInsideResources() {
XCTAssertEqual(AppPaths.bundledArgyllDir.lastPathComponent, "Argyll")
}
}
@Suite("WizardStage")
struct WizardStageTests {
@Test func stepperOrderIsOneThroughFive() {
#expect(WizardStage.stepperStages.map(\.stepperIndex) == [1, 2, 3, 4, 5])
#expect(WizardStage.calibrate.stepperIndex == nil)
final class WizardStageTests: XCTestCase {
func testStepperOrderIsOneThroughFive() {
XCTAssertEqual(WizardStage.stepperStages.map(\.stepperIndex), [1, 2, 3, 4, 5])
XCTAssertNil(WizardStage.calibrate.stepperIndex)
}
}
@@ -1,22 +1,19 @@
import Foundation
import Testing
import XCTest
@testable import ICCeryCore
@Suite("ApplycalArgs")
struct ApplycalArgsTests {
final class ApplycalArgsTests: XCTestCase {
@Test("Apply argv")
func applyArgv() throws {
func testApplyArgv() throws {
let config = ApplycalConfig(
calibrationPath: "/tmp/cal.cal",
inputProfileURL: URL(fileURLWithPath: "/tmp/profile.icc")
)
let args = try ApplycalArgs.build(config: config)
#expect(args == ["-v", "-a", "/tmp/cal.cal", "/tmp/profile.icc"])
XCTAssertEqual(args, ["-v", "-a", "/tmp/cal.cal", "/tmp/profile.icc"])
}
@Test("Unapply is emitted when the caller explicitly sets it")
func unapplyEmittedWhenConfigSet() throws {
func testUnapplyEmittedWhenConfigSet() throws {
let config = ApplycalConfig(
calibrationPath: "/tmp/cal.cal",
inputProfileURL: URL(fileURLWithPath: "/tmp/profile.icc"),
@@ -25,6 +22,6 @@ struct ApplycalArgsTests {
let args = try ApplycalArgs.build(config: config)
// Builder emits -u only when the caller explicitly sets unapply.
// The UI layer never passes unapply: true in v2.0.
#expect(args == ["-v", "-u", "/tmp/cal.cal", "/tmp/profile.icc"])
XCTAssertEqual(args, ["-v", "-u", "/tmp/cal.cal", "/tmp/profile.icc"])
}
}
@@ -0,0 +1,39 @@
import Foundation
import XCTest
@testable import ICCeryCore
/// ``ApproximateLab`` sanity tests (issue #147).
///
/// These are loose sanity checks on a fixed-matrix approximation not
/// ColorSync goldens.
final class ApproximateLabTests: XCTestCase {
func testWhiteMapsToHighLStar() {
let lab = ApproximateLab.srgb8ToLab(r: 255, g: 255, b: 255)
XCTAssertGreaterThan(lab.l, 95)
XCTAssertEqual(lab.a, 0, accuracy: 2)
XCTAssertEqual(lab.b, 0, accuracy: 2)
}
func testBlackMapsToZeroLStar() {
let lab = ApproximateLab.srgb8ToLab(r: 0, g: 0, b: 0)
XCTAssertEqual(lab.l, 0, accuracy: 1)
}
func testPureRedIsChromatic() {
let lab = ApproximateLab.srgb8ToLab(r: 255, g: 0, b: 0)
// sRGB red Lab D50 (54, 81, 70) loose bounds only.
XCTAssertGreaterThan(lab.l, 40)
XCTAssertLessThan(lab.l, 65)
XCTAssertGreaterThan(lab.a, 60)
XCTAssertGreaterThan(lab.b, 40)
}
func testMidGreyIsNeutral() {
let lab = ApproximateLab.srgb8ToLab(r: 128, g: 128, b: 128)
XCTAssertGreaterThan(lab.l, 45)
XCTAssertLessThan(lab.l, 65)
XCTAssertEqual(lab.a, 0, accuracy: 1)
XCTAssertEqual(lab.b, 0, accuracy: 1)
}
}
@@ -0,0 +1,80 @@
import XCTest
import Foundation
@testable import ICCeryCore
final class ArgsBuilderTests: XCTestCase {
// MARK: - option
func testOptionNil() {
XCTAssertEqual(ArgsBuilder.option("-f", nil), [])
}
func testOptionPresent() {
XCTAssertEqual(ArgsBuilder.option("-f", "abc"), ["-f", "abc"])
XCTAssertEqual(ArgsBuilder.option("-f", ""), ["-f", ""])
XCTAssertEqual(ArgsBuilder.option("-f", " padded "), ["-f", " padded "])
}
// MARK: - optionIfNonEmpty
func testOptionIfNonEmptyNilEmpty() {
XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", nil), [])
XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", ""), [])
}
func testOptionIfNonEmptyWhitespace() {
XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", " "), [])
XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", " \t\n "), [])
}
func testOptionIfNonEmptyTrims() {
XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", " label "), ["-d", "label"])
XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", "\tcal.cal\n"), ["-d", "cal.cal"])
}
// MARK: - optionUnlessApprox
func testOptionUnlessApproxNil() {
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", nil, skip: 0.50), [])
}
func testOptionUnlessApproxExactSkip() {
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", 0.50, skip: 0.50), [])
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-V", 1.0, skip: 1.0), [])
}
func testOptionUnlessApproxWithinEpsilon() {
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", 0.5005, skip: 0.50), [])
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-V", 0.9995, skip: 1.0), [])
}
func testOptionUnlessApproxOutsideEpsilon() {
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", 0.75, skip: 0.50), ["-N", "0.75"])
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-V", 1.50, skip: 1.0), ["-V", "1.50"])
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", 0.498, skip: 0.50), ["-N", "0.50"])
}
func testOptionUnlessApproxPOSIX() {
// 1234.5 must never produce a grouping separator or comma decimal.
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-p", 1234.5, skip: 1.0), ["-p", "1234.50"])
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-p", 2.0, skip: 1.0), ["-p", "2.00"])
}
func testOptionUnlessApproxCustom() {
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-x", 1.005, skip: 1.0, epsilon: 0.01), [])
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-x", 1.5, skip: 1.0, format: "%.1f"), ["-x", "1.5"])
}
// MARK: - flag
func testFlagTrue() {
XCTAssertEqual(ArgsBuilder.flag("-G", when: true), ["-G"])
XCTAssertEqual(ArgsBuilder.flag("-r", when: true), ["-r"])
}
func testFlagFalse() {
XCTAssertEqual(ArgsBuilder.flag("-G", when: false), [])
XCTAssertEqual(ArgsBuilder.flag("-r", when: false), [])
}
}
@@ -1,17 +1,16 @@
import Foundation
import Testing
import XCTest
@testable import ICCeryCore
@Suite("ArgyllRunner Calibration")
struct ArgyllRunnerCalibrationTests {
final class ArgyllRunnerCalibrationTests: XCTestCase {
private func makeRunner() -> ArgyllRunner {
private func makeRunner(processManager: ProcessManager = ProcessManager()) -> ArgyllRunner {
let binDir = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("ICCeryUITests/Fixtures/bin")
return ArgyllRunner(
processManager: .shared,
processManager: processManager,
binaryResolver: BinaryResolver(overrideDir: binDir)
)
}
@@ -23,8 +22,7 @@ struct ArgyllRunnerCalibrationTests {
return root
}
@Test("Calibration targen produces CAL_*.ti1")
func calibrationTargenProducesTi1() async throws {
func testCalibrationTargenProducesTi1() async throws {
let testRoot = try makeTestDir()
let runner = makeRunner()
let config = CalibrationTargenConfig(
@@ -36,13 +34,41 @@ struct ArgyllRunnerCalibrationTests {
let url = try await runner.runCalibrationTargen(config: config)
#expect(url.lastPathComponent == "CAL_demo.ti1")
#expect(FileManager.default.fileExists(atPath: url.path))
XCTAssertEqual(url.lastPathComponent, "CAL_demo.ti1")
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
try? FileManager.default.removeItem(at: testRoot)
}
@Test("printcal captured run creates .cal")
func printcalProducesCal() async throws {
func testCalibrationTargenProcessId() async throws {
let testRoot = try makeTestDir()
let pm = ProcessManager()
let runner = makeRunner(processManager: pm)
let events = pm.events()
// Subscribed before spawn; the exit event is emitted before
// runCalibrationTargen returns, so this always terminates.
let sawExit = Task {
for await event in events {
guard event.id == "targen_CAL_foo" else { continue }
if case .exit = event { return true }
}
return false
}
let config = CalibrationTargenConfig(
colourSpace: .rgb,
steps: 21,
basename: "foo",
workingDirectory: testRoot
)
let url = try await runner.runCalibrationTargen(config: config)
XCTAssertEqual(url.lastPathComponent, "CAL_foo.ti1")
let sawExitEvent = await sawExit.value
XCTAssertTrue(sawExitEvent)
try? FileManager.default.removeItem(at: testRoot)
}
func testPrintcalProducesCal() async throws {
let testRoot = try makeTestDir()
let runner = makeRunner()
let output = testRoot.appendingPathComponent("CAL_demo.cal")
@@ -54,15 +80,32 @@ struct ArgyllRunnerCalibrationTests {
let url = try await runner.runPrintcal(config: config)
#expect(url.lastPathComponent == "CAL_demo.cal")
#expect(FileManager.default.fileExists(atPath: url.path))
XCTAssertEqual(url.lastPathComponent, "CAL_demo.cal")
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
try? FileManager.default.removeItem(at: testRoot)
}
@Test("printcal failure throws printcalFailed")
func printcalFailureThrows() async throws {
func testPrintcalFailureThrows() async throws {
let testRoot = try makeTestDir()
let runner = makeRunner()
defer { try? FileManager.default.removeItem(at: testRoot) }
// Per-test mock printcal that always fails no global
// environment mutation, no shared fixture changes.
let binDir = try makeTestDir()
defer { try? FileManager.default.removeItem(at: binDir) }
let mockURL = binDir.appendingPathComponent("printcal")
try """
#!/bin/sh
echo "printcal mock failure" >&2
exit 1
""".write(to: mockURL, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes(
[.posixPermissions: 0o755], ofItemAtPath: mockURL.path)
let runner = ArgyllRunner(
processManager: ProcessManager(),
binaryResolver: BinaryResolver(bundledRoot: binDir, overrideDir: binDir)
)
let output = testRoot.appendingPathComponent("CAL_demo.cal")
let config = PrintcalConfig(
ti3Basename: "CAL_demo",
@@ -70,12 +113,11 @@ struct ArgyllRunnerCalibrationTests {
outputURL: output
)
setenv("ICCERY_MOCK_PRINTCAL_EXIT", "1", 1)
defer { unsetenv("ICCERY_MOCK_PRINTCAL_EXIT") }
await #expect(throws: (any Error).self) {
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
_ = try await runner.runPrintcal(config: config)
} errorHandler: { error in
XCTAssertEqual(error, .toolFailed(
tool: "printcal", code: 1, logs: ["printcal mock failure\n"]))
}
try? FileManager.default.removeItem(at: testRoot)
}
}
@@ -1,5 +1,5 @@
import Foundation
import Testing
import XCTest
@testable import ICCeryCore
final class LogHolder: @unchecked Sendable {
@@ -19,11 +19,9 @@ final class LogHolder: @unchecked Sendable {
}
}
@Suite("ArgyllRunner colprof")
struct ArgyllRunnerColprofTests {
final class ArgyllRunnerColprofTests: XCTestCase {
@Test("Mock colprof produces .icc")
func colprofProducesIcc() async throws {
func testColprofProducesIcc() async throws {
let binDir = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
@@ -33,7 +31,7 @@ struct ArgyllRunnerColprofTests {
try FileManager.default.createDirectory(at: testRoot, withIntermediateDirectories: true)
let runner = ArgyllRunner(
processManager: .shared,
processManager: ProcessManager(),
binaryResolver: BinaryResolver(overrideDir: binDir)
)
@@ -43,10 +41,39 @@ struct ArgyllRunnerColprofTests {
holder.append(batch)
}
#expect(url.lastPathComponent == "testrun.icc")
#expect(FileManager.default.fileExists(atPath: url.path))
#expect(holder.lines.contains { $0.contains("Gamut mapping") })
XCTAssertEqual(url.lastPathComponent, "testrun.icc")
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
XCTAssertTrue(holder.lines.contains { $0.contains("Gamut mapping") })
try? FileManager.default.removeItem(at: testRoot)
}
func testColprofFailureThrowsToolFailed() async throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("colprof-fail-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
let mockURL = dir.appendingPathComponent("colprof")
try """
#!/bin/sh
echo "colprof broke" >&2
exit 4
""".write(to: mockURL, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes(
[.posixPermissions: 0o755], ofItemAtPath: mockURL.path)
let runner = ArgyllRunner(
processManager: ProcessManager(),
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir)
)
let config = ColprofConfig(basename: "failrun", workingDirectory: dir)
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
try await runner.runColprof(config: config)
} errorHandler: { error in
XCTAssertEqual(error, .toolFailed(
tool: "colprof", code: 4, logs: ["colprof broke"]))
}
}
}
@@ -0,0 +1,158 @@
import Foundation
import XCTest
@testable import ICCeryCore
/// Focused contracts for the shared `runStreamingTool` loop (#79).
///
/// Every test uses a per-test temporary directory, unique basenames,
/// and a fresh `ProcessManager` no shared UI fixture scripts and no
/// process-environment mutation.
final class ArgyllRunnerStreamingLoopTests: XCTestCase {
private func makeTempDir() throws -> URL {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("runner-loop-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}
private func writeMock(_ name: String, _ body: String, in dir: URL) throws {
let url = dir.appendingPathComponent(name)
try body.write(to: url, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes(
[.posixPermissions: 0o755], ofItemAtPath: url.path)
}
private func makeRunner(binDir: URL) -> ArgyllRunner {
ArgyllRunner(
processManager: ProcessManager(),
binaryResolver: BinaryResolver(bundledRoot: binDir, overrideDir: binDir))
}
func testNonZeroExitThrowsToolFailed() async throws {
let dir = try makeTempDir()
defer { try? FileManager.default.removeItem(at: dir) }
try writeMock("targen", """
#!/bin/sh
echo "Generating patches..."
echo "targen: too few patches" >&2
exit 3
""", in: dir)
let runner = makeRunner(binDir: dir)
let config = TargenConfig(
colourSpace: .rgb, patchCount: 800, whitePatches: 4,
blackPatches: 4, basename: "fail", workingDirectory: dir)
do {
_ = try await runner.runTargen(config: config)
XCTFail("Expected toolFailed")
} catch let error as ArgyllRunnerError {
guard case .toolFailed(let tool, let code, let logs) = error else {
XCTFail("Expected toolFailed, got \(error)")
return
}
XCTAssertEqual(tool, "targen")
XCTAssertEqual(code, 3)
XCTAssertTrue(logs.contains("Generating patches..."))
XCTAssertTrue(logs.contains("targen: too few patches"))
}
}
func testZeroExitMissingArtefact() async throws {
let dir = try makeTempDir()
defer { try? FileManager.default.removeItem(at: dir) }
try writeMock("targen", """
#!/bin/sh
echo "done but wrote nothing"
exit 0
""", in: dir)
let runner = makeRunner(binDir: dir)
let expectedPath = dir.appendingPathComponent("gone.ti1").path
let config = TargenConfig(
colourSpace: .rgb, patchCount: 800, whitePatches: 4,
blackPatches: 4, basename: "gone", workingDirectory: dir)
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
try await runner.runTargen(config: config)
} errorHandler: { error in
XCTAssertEqual(error, .missingArtefact(expectedPath))
}
}
func testImmediateExitDeliversLine() async throws {
let dir = try makeTempDir()
defer { try? FileManager.default.removeItem(at: dir) }
try writeMock("targen", """
#!/bin/sh
last=""
for arg in "$@"; do last="$arg"; done
echo "only line"
touch "$last.ti1"
exit 0
""", in: dir)
let runner = makeRunner(binDir: dir)
let config = TargenConfig(
colourSpace: .rgb, patchCount: 800, whitePatches: 4,
blackPatches: 4, basename: "quick", workingDirectory: dir)
let holder = LogHolder()
let url = try await runner.runTargen(config: config) { batch in
holder.append(batch)
}
XCTAssertEqual(url.lastPathComponent, "quick.ti1")
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
XCTAssertTrue(holder.lines.contains("only line"))
}
func testColprofPartialLineFlush() async throws {
let dir = try makeTempDir()
defer { try? FileManager.default.removeItem(at: dir) }
// The fragment is printed without a newline, then the mock sleeps
// past the 500 ms partial-line flush interval before writing the
// artefact and exiting so the tail is delivered mid-run.
try writeMock("colprof", """
#!/bin/sh
last=""
for arg in "$@"; do last="$arg"; done
printf 'Doing gamut mapping'
sleep 2
touch "$last.icc"
exit 0
""", in: dir)
let runner = makeRunner(binDir: dir)
let config = ColprofConfig(basename: "frag", workingDirectory: dir)
let holder = LogHolder()
let url = try await runner.runColprof(config: config) { batch in
holder.append(batch)
}
XCTAssertEqual(url.lastPathComponent, "frag.icc")
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
XCTAssertTrue(holder.lines.contains("Doing gamut mapping"))
}
func testToolDescriptions() {
let cases: [(tool: String, expected: String)] = [
(tool: "chartread", expected: "Chartread failed: boom"),
(tool: "average", expected: "Averaging failed: boom"),
(tool: "colprof", expected: "Profile creation failed: boom"),
(tool: "printcal", expected: "Calibration curve computation failed: boom"),
(tool: "applycal", expected: "Apply calibration failed: boom"),
(tool: "iccgamut", expected: "Gamut extraction failed: boom"),
(tool: "profcheck", expected: "Profile verification failed: boom"),
]
for (tool, expected) in cases {
let error = ArgyllRunnerError.toolFailed(tool: tool, code: 1, logs: ["boom"])
XCTAssertEqual(error.errorDescription, expected)
}
}
func testGenericFallbacks() {
let unknown = ArgyllRunnerError.toolFailed(tool: "targen", code: 7, logs: ["boom"])
XCTAssertEqual(unknown.errorDescription, "Process exited with code 7")
let emptyLogs = ArgyllRunnerError.toolFailed(tool: "colprof", code: 2, logs: [])
XCTAssertEqual(emptyLogs.errorDescription,
"Profile creation failed: exited with code 2")
}
}
+115 -27
View File
@@ -1,4 +1,4 @@
import Testing
import XCTest
import Foundation
import ImageIO
import UniformTypeIdentifiers
@@ -10,9 +10,8 @@ private func tempURL(_ name: String) -> URL {
.appendingPathComponent(name)
}
@Suite("Ti2Header")
struct Ti2HeaderTests {
@Test func parsesKeywordsAndSibling() throws {
final class Ti2HeaderTests: XCTestCase {
func testParsesKeywordsAndSibling() throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-ti2-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
@@ -31,18 +30,18 @@ struct Ti2HeaderTests {
)
let h = Ti2Header.parse(dir.appendingPathComponent("job.ti2"))
#expect(h.instrument == "i1iO")
#expect(h.patchCount == 800)
#expect(h.pageCount == 3)
#expect(h.hasSiblingTi1)
XCTAssertEqual(h.instrument, "i1iO")
XCTAssertEqual(h.patchCount, 800)
XCTAssertEqual(h.pageCount, 3)
XCTAssertTrue(h.hasSiblingTi1)
}
@Test func missingFileYieldsEmptyHeader() {
func testMissingFileYieldsEmptyHeader() {
let h = Ti2Header.parse(URL(fileURLWithPath: "/nonexistent/x.ti2"))
#expect(h.instrument == nil && h.patchCount == nil && !h.hasSiblingTi1)
XCTAssertTrue(h.instrument == nil && h.patchCount == nil && !h.hasSiblingTi1)
}
@Test func numberOfFieldsIsNotPatchCount() throws {
func testNumberOfFieldsIsNotPatchCount() throws {
let url = tempURL("t.ti2")
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
@@ -50,12 +49,11 @@ struct Ti2HeaderTests {
try "NUMBER_OF_FIELDS 9\nNUMBER_OF_SETS 52\nBEGIN_DATA\n".write(
to: url, atomically: true, encoding: .utf8
)
#expect(Ti2Header.parse(url).patchCount == 52)
XCTAssertEqual(Ti2Header.parse(url).patchCount, 52)
}
}
@Suite("TiffPreview")
struct TiffPreviewTests {
final class TiffPreviewTests: XCTestCase {
/// Builds a real 2000×1000 TIFF in a temp dir via ImageIO.
private func makeTiff(width: Int = 2000, height: Int = 1000) throws -> URL {
let url = tempURL("big.tif")
@@ -81,44 +79,134 @@ struct TiffPreviewTests {
return url
}
@Test func producesCappedPNG() throws {
func testProducesCappedPNG() throws {
let tiff = try makeTiff()
let png = TiffPreview.previewPNG(tiff: tiff)
#expect(png != nil)
XCTAssertNotNil(png)
// PNG magic
#expect(png!.prefix(8) == Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]))
XCTAssertEqual(png!.prefix(8), Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]))
// Verify the cap by decoding the thumbnail header.
let src = CGImageSourceCreateWithData(png! as CFData, nil)!
let img = CGImageSourceCreateImageAtIndex(src, 0, nil)!
#expect(max(img.width, img.height) <= TiffPreview.maxEdge)
#expect(img.width == 1200)
XCTAssertTrue(max(img.width, img.height) <= TiffPreview.maxEdge)
XCTAssertEqual(img.width, 1200)
}
@Test func nonTiffReturnsNil() throws {
func testNonTiffReturnsNil() throws {
let url = tempURL("not-tiff.txt")
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
)
try "hello".write(to: url, atomically: true, encoding: .utf8)
#expect(TiffPreview.previewPNG(tiff: url) == nil)
XCTAssertNil(TiffPreview.previewPNG(tiff: url))
}
}
@Suite("ArtefactFiles")
struct ArtefactFilesTests {
@Test func base64RoundTrip() throws {
final class ArtefactFilesTests: XCTestCase {
func testBase64RoundTrip() throws {
let url = tempURL("a.txt")
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
)
try "hello".write(to: url, atomically: true, encoding: .utf8)
let b64 = try ArtefactFiles.readBase64(url)
#expect(Data(base64Encoded: b64) == Data("hello".utf8))
XCTAssertEqual(Data(base64Encoded: b64), Data("hello".utf8))
}
@Test func defaultWorkingDirExists() {
#expect(FileManager.default.fileExists(
func testDefaultWorkingDirExists() {
XCTAssertTrue(FileManager.default.fileExists(
atPath: ArtefactFiles.defaultWorkingDirectory().path
))
}
}
final class ArtefactProbeProfileTests: XCTestCase {
private func makeDir() throws -> URL {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("probe-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}
// MARK: Basename probe matrix (#69)
func testOnlyIcc() throws {
let dir = try makeDir()
let icc = dir.appendingPathComponent("job.icc")
try Data("icc".utf8).write(to: icc)
XCTAssertEqual(ArtefactProbe.resolveProfile(basename: "job", cwd: dir)?.path, icc.path)
}
func testOnlyIcm() throws {
let dir = try makeDir()
let icm = dir.appendingPathComponent("job.icm")
try Data("icm".utf8).write(to: icm)
XCTAssertEqual(ArtefactProbe.resolveProfile(basename: "job", cwd: dir)?.path, icm.path)
}
func testIcmWins() throws {
let dir = try makeDir()
try Data("icc".utf8).write(to: dir.appendingPathComponent("job.icc"))
let icm = dir.appendingPathComponent("job.icm")
try Data("icm".utf8).write(to: icm)
let url = ArtefactProbe.resolveProfile(basename: "job", cwd: dir)
XCTAssertEqual(url?.path, icm.path)
}
func testNeitherExists() throws {
let dir = try makeDir()
XCTAssertNil(ArtefactProbe.resolveProfile(basename: "job", cwd: dir))
}
// MARK: Explicit URL matrix (#69 / #83)
func testExplicitIccWins() throws {
let dir = try makeDir()
let icc = dir.appendingPathComponent("job.icc")
try Data("icc".utf8).write(to: icc)
try Data("icm".utf8).write(to: dir.appendingPathComponent("job.icm"))
XCTAssertEqual(ArtefactProbe.resolveProfile(icc).path, icc.path)
}
func testExplicitIcmWins() throws {
let dir = try makeDir()
try Data("icc".utf8).write(to: dir.appendingPathComponent("job.icc"))
let icm = dir.appendingPathComponent("job.icm")
try Data("icm".utf8).write(to: icm)
XCTAssertEqual(ArtefactProbe.resolveProfile(icm).path, icm.path)
}
func testFlipExtension() throws {
let dir = try makeDir()
let icc = dir.appendingPathComponent("job.icc")
let icm = dir.appendingPathComponent("job.icm")
try Data("icm".utf8).write(to: icm)
let resolved = ArtefactProbe.resolveProfile(icc)
XCTAssertEqual(resolved.path, icm.path)
}
func testFlipToIcc() throws {
let dir = try makeDir()
let icc = dir.appendingPathComponent("job.icc")
let icm = dir.appendingPathComponent("job.icm")
try Data("icc".utf8).write(to: icc)
XCTAssertEqual(ArtefactProbe.resolveProfile(icm).path, icc.path)
}
func testMissingBoth() throws {
let dir = try makeDir()
let icc = dir.appendingPathComponent("job.icc")
XCTAssertEqual(ArtefactProbe.resolveProfile(icc).path, icc.path)
}
func testUnrelatedExtension() throws {
let dir = try makeDir()
let mpp = dir.appendingPathComponent("job.mpp")
let icc = dir.appendingPathComponent("job.icc")
try Data("icc".utf8).write(to: icc)
// Even though a sibling .icc exists, a missing .mpp stays .mpp.
XCTAssertEqual(ArtefactProbe.resolveProfile(mpp).path, mpp.path)
let txt = dir.appendingPathComponent("job.txt")
XCTAssertEqual(ArtefactProbe.resolveProfile(txt).path, txt.path)
}
}
+16 -17
View File
@@ -1,9 +1,8 @@
import Testing
import XCTest
import Foundation
@testable import ICCeryCore
@Suite("BinaryResolver")
struct BinaryResolverTests {
final class BinaryResolverTests: XCTestCase {
private func makeTree(_ body: (URL) throws -> Void) throws -> URL {
let root = FileManager.default.temporaryDirectory
@@ -22,16 +21,16 @@ struct BinaryResolverTests {
}
}
@Test func overrideDirWinsWhenFileExists() throws {
func testOverrideDirWinsWhenFileExists() throws {
let override = try makeTree { root in
try touch(root.appendingPathComponent("targen"))
}
let bundled = try makeTree { _ in }
let r = BinaryResolver(bundledRoot: bundled, overrideDir: override)
#expect(r.resolve("targen") == override.appendingPathComponent("targen"))
XCTAssertEqual(r.resolve("targen"), override.appendingPathComponent("targen"))
}
@Test func overrideFallsThroughWhenMissing() throws {
func testOverrideFallsThroughWhenMissing() throws {
let override = try makeTree { _ in }
let bundled = try makeTree { root in
let dir = root.appendingPathComponent("macos-universal")
@@ -39,10 +38,10 @@ struct BinaryResolverTests {
try touch(dir.appendingPathComponent("instlist"))
}
let r = BinaryResolver(bundledRoot: bundled, overrideDir: override)
#expect(r.resolve("targen").path.contains("macos-universal/targen"))
XCTAssertTrue(r.resolve("targen").path.contains("macos-universal/targen"))
}
@Test func universalPreferredWhenMarkerPresent() throws {
func testUniversalPreferredWhenMarkerPresent() throws {
let bundled = try makeTree { root in
for dir in ["macos-universal", "macos-x86_64"] {
let d = root.appendingPathComponent(dir)
@@ -51,10 +50,10 @@ struct BinaryResolverTests {
}
}
let r = BinaryResolver(bundledRoot: bundled)
#expect(r.platformDir() == "macos-universal")
XCTAssertEqual(r.platformDir(), "macos-universal")
}
@Test func fallsBackToArchDir() throws {
func testFallsBackToArchDir() throws {
let bundled = try makeTree { root in
let d = root.appendingPathComponent("macos-x86_64")
try FileManager.default.createDirectory(at: d, withIntermediateDirectories: true)
@@ -64,20 +63,20 @@ struct BinaryResolverTests {
bundledRoot: bundled,
archDirs: ["macos-universal", "macos-x86_64"]
)
#expect(r.platformDir() == "macos-x86_64")
XCTAssertEqual(r.platformDir(), "macos-x86_64")
}
@Test func missingEverythingReturnsConstructedPath() throws {
func testMissingEverythingReturnsConstructedPath() throws {
let bundled = try makeTree { _ in }
let r = BinaryResolver(bundledRoot: bundled)
// v1 semantic: path is returned; spawn surfaces the error.
#expect(r.resolve("targen").path.hasSuffix("macos-universal/targen"))
#expect(!r.exists(r.resolve("targen")))
XCTAssertTrue(r.resolve("targen").path.hasSuffix("macos-universal/targen"))
XCTAssertFalse(r.exists(r.resolve("targen")))
}
@Test func mockAndGamutPaths() throws {
func testMockAndGamutPaths() throws {
let r = BinaryResolver(bundledRoot: URL(fileURLWithPath: "/x"))
#expect(r.mock("chartread").path == "/x/mocks/chartread.mock")
#expect(r.referenceGamut("sRGB.gam").path == "/x/reference_gamuts/sRGB.gam")
XCTAssertEqual(r.mock("chartread").path, "/x/mocks/chartread.mock")
XCTAssertEqual(r.referenceGamut("sRGB.gam").path, "/x/reference_gamuts/sRGB.gam")
}
}
+37 -50
View File
@@ -1,9 +1,8 @@
import Foundation
import Testing
import XCTest
@testable import ICCeryCore
@Suite("CGATS Parser & Writer")
struct CGATSParserTests {
final class CGATSParserTests: XCTestCase {
private static let canonicalCTI3 = """
CTI3
@@ -21,44 +20,40 @@ struct CGATSParserTests {
END_DATA
"""
@Test("Parses CTI3 with canonical field names")
func parseCTI3() throws {
func testParseCTI3() throws {
let dataset = try CGATSParser.parse(Self.canonicalCTI3)
#expect(dataset.format == .cti3)
#expect(dataset.samples.count == 2)
#expect(dataset.colorRep == "RGB")
#expect(dataset.deviceClass == "DISPLAY")
#expect(dataset.samples[0].id == "1")
#expect(dataset.samples[0].loc == "A1")
#expect(dataset.samples[1].values["RGB_G"] == "50.0000")
XCTAssertEqual(dataset.format, .cti3)
XCTAssertEqual(dataset.samples.count, 2)
XCTAssertEqual(dataset.colorRep, "RGB")
XCTAssertEqual(dataset.deviceClass, "DISPLAY")
XCTAssertEqual(dataset.samples[0].id, "1")
XCTAssertEqual(dataset.samples[0].loc, "A1")
XCTAssertEqual(dataset.samples[1].values["RGB_G"], "50.0000")
}
@Test("Round-trips parse, write, reparse")
func roundTrip() throws {
func testRoundTrip() throws {
let first = try CGATSParser.parse(Self.canonicalCTI3)
let text = try CGATSWriter.write(first)
let second = try CGATSParser.parse(text)
#expect(second.format == first.format)
#expect(second.samples.count == first.samples.count)
#expect(second.colorRep == first.colorRep)
#expect(second.deviceClass == first.deviceClass)
XCTAssertEqual(second.format, first.format)
XCTAssertEqual(second.samples.count, first.samples.count)
XCTAssertEqual(second.colorRep, first.colorRep)
XCTAssertEqual(second.deviceClass, first.deviceClass)
}
@Test("Parses CSV with comma delimiters")
func parseCSV() throws {
func testParseCSV() throws {
let csv = """
SAMPLE_ID,SAMPLE_LOC,RGB_R,RGB_G,RGB_B,XYZ_X,XYZ_Y,XYZ_Z,LAB_L,LAB_A,LAB_B
1,A1,50,0,0,20,10,5,50,60,30
2,A2,0,50,0,10,30,5,60,-50,40
"""
let dataset = try CGATSParser.parse(csv, sourceURL: URL(fileURLWithPath: "/tmp/sample.csv"))
#expect(dataset.format == .csv)
#expect(dataset.samples.count == 2)
#expect(dataset.samples[0].values["RGB_R"] == "50.0000")
XCTAssertEqual(dataset.format, .csv)
XCTAssertEqual(dataset.samples.count, 2)
XCTAssertEqual(dataset.samples[0].values["RGB_R"], "50.0000")
}
@Test("Converts 0-255 device values to 0-100")
func converts255To100() throws {
func testConverts255To100() throws {
let rgb = """
CTI3
COLOR_REP RGB
@@ -72,12 +67,11 @@ struct CGATSParserTests {
END_DATA
"""
let dataset = try CGATSParser.parse(rgb)
#expect(dataset.samples[0].values["RGB_R"] == "100.0000")
#expect(dataset.samples[0].values["RGB_G"] == "50.1961")
XCTAssertEqual(dataset.samples[0].values["RGB_R"], "100.0000")
XCTAssertEqual(dataset.samples[0].values["RGB_G"], "50.1961")
}
@Test("Synthesizes COLOR_REP and DEVICE_CLASS when missing")
func synthesizesMetadata() throws {
func testSynthesizesMetadata() throws {
let cmyk = """
CTI3
NUMBER_OF_FIELDS 6
@@ -90,19 +84,15 @@ struct CGATSParserTests {
END_DATA
"""
let dataset = try CGATSParser.parse(cmyk)
#expect(dataset.colorRep == "CMYK")
#expect(dataset.deviceClass == "PRINTER")
XCTAssertEqual(dataset.colorRep, "CMYK")
XCTAssertEqual(dataset.deviceClass, "PRINTER")
}
@Test("Rejects empty file")
func rejectsEmpty() {
#expect(throws: (any Error).self) {
_ = try CGATSParser.parse("")
}
func testRejectsEmpty() {
XCTAssertThrowsError(try CGATSParser.parse(""))
}
@Test("Rejects malformed arity")
func rejectsArity() {
func testRejectsArity() {
let bad = """
CTI3
NUMBER_OF_FIELDS 2
@@ -114,21 +104,18 @@ struct CGATSParserTests {
1
END_DATA
"""
#expect(throws: (any Error).self) {
_ = try CGATSParser.parse(bad)
}
XCTAssertThrowsError(try CGATSParser.parse(bad))
}
@Test("Writer emits valid .ti3 with tabs and required keywords")
func writerFormat() throws {
func testWriterFormat() throws {
let dataset = try CGATSParser.parse(Self.canonicalCTI3)
let text = try CGATSWriter.write(dataset)
#expect(text.contains("CTI3"))
#expect(text.contains("BEGIN_DATA_FORMAT"))
#expect(text.contains("BEGIN_DATA"))
#expect(text.contains("END_DATA"))
#expect(text.contains("COLOR_REP"))
#expect(text.contains("DEVICE_CLASS"))
#expect(text.contains("\t"))
XCTAssertTrue(text.contains("CTI3"))
XCTAssertTrue(text.contains("BEGIN_DATA_FORMAT"))
XCTAssertTrue(text.contains("BEGIN_DATA"))
XCTAssertTrue(text.contains("END_DATA"))
XCTAssertTrue(text.contains("COLOR_REP"))
XCTAssertTrue(text.contains("DEVICE_CLASS"))
XCTAssertTrue(text.contains("\t"))
}
}
@@ -0,0 +1,67 @@
import Foundation
import XCTest
@testable import ICCeryCore
/// Issue #83 canonical `CAL_` / original-stem pairing.
final class CalibrationIdentityTests: XCTestCase {
func testLivePlain() {
let id = CalibrationIdentity.parse(liveBasename: "foo", persistedOriginal: "")
XCTAssertEqual(id.originalBasename, "foo")
XCTAssertEqual(id.calibrationBasename, "CAL_foo")
}
func testLivePlainIgnoresPersisted() {
let id = CalibrationIdentity.parse(liveBasename: "foo", persistedOriginal: "bar")
XCTAssertEqual(id.originalBasename, "foo")
XCTAssertEqual(id.calibrationBasename, "CAL_foo")
}
func testLiveCalPersisted() {
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "foo")
XCTAssertEqual(id.originalBasename, "foo")
XCTAssertEqual(id.calibrationBasename, "CAL_foo")
}
func testLiveCalNoPersist() {
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "")
XCTAssertEqual(id.originalBasename, "foo")
XCTAssertEqual(id.calibrationBasename, "CAL_foo")
}
func testPersistedWins() {
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "bar")
XCTAssertEqual(id.originalBasename, "bar")
XCTAssertEqual(id.calibrationBasename, "CAL_bar")
}
func testEmptyLiveWithPersisted() {
let id = CalibrationIdentity.parse(liveBasename: "", persistedOriginal: "foo")
XCTAssertTrue(id.originalBasename.isEmpty)
XCTAssertTrue(id.calibrationBasename.isEmpty)
}
func testEmptyLive() {
let id = CalibrationIdentity.parse(liveBasename: "", persistedOriginal: "")
XCTAssertTrue(id.originalBasename.isEmpty)
XCTAssertTrue(id.calibrationBasename.isEmpty)
}
func testAlreadyPrefixed() {
XCTAssertEqual(CalibrationIdentity.prefix("CAL_foo"), "CAL_foo")
XCTAssertEqual(CalibrationIdentity.prefix("foo"), "CAL_foo")
let id = CalibrationIdentity.parse(liveBasename: "CAL_CAL_foo", persistedOriginal: "")
XCTAssertEqual(id.originalBasename, "CAL_foo")
XCTAssertEqual(id.calibrationBasename, "CAL_foo")
}
func testPrefixEmpty() {
XCTAssertTrue(CalibrationIdentity.prefix("").isEmpty)
XCTAssertEqual(CalibrationIdentity.strip("foo"), "foo")
XCTAssertEqual(CalibrationIdentity.strip("CAL_foo"), "foo")
}
func testProcessIdMatches() {
let cal = CalibrationIdentity.prefix("foo")
XCTAssertEqual(ProcessID.targen(cal), "targen_CAL_foo")
}
}
@@ -1,9 +1,8 @@
import Foundation
import Testing
import XCTest
@testable import ICCeryCore
@Suite("CalibrationStore")
struct CalibrationStoreTests {
final class CalibrationStoreTests: XCTestCase {
private static let sampleCal = """
CTI3
@@ -23,8 +22,7 @@ struct CalibrationStoreTests {
END_DATA
"""
@Test("Loads metadata and curves from .cal")
func parseCal() async throws {
func testParseCal() async throws {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("test_\(UUID().uuidString).cal")
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
@@ -33,17 +31,16 @@ struct CalibrationStoreTests {
try await store.load(url: url)
let data = await store.data
#expect(data?.colorRep == "RGB")
#expect(data?.descriptor == "Test printer")
#expect(data?.maxTac == 300)
#expect(data?.curves.count == 3)
XCTAssertEqual(data?.colorRep, "RGB")
XCTAssertEqual(data?.descriptor, "Test printer")
XCTAssertEqual(data?.maxTac, 300)
XCTAssertEqual(data?.curves.count, 3)
let r = data?.curves.first { $0.channel == "R" }
#expect(r?.output == [0, 64, 255])
XCTAssertEqual(r?.output, [0, 64, 255])
}
@Test("Staleness is true for a very old calibration")
func staleCalibration() async throws {
func testStaleCalibration() async throws {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("stale_\(UUID().uuidString).cal")
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
@@ -51,11 +48,10 @@ struct CalibrationStoreTests {
let store = CalibrationStore(staleDays: 0)
try await store.load(url: url)
let stale = await store.isStale(comparedTo: "Other")
#expect(stale == true)
XCTAssertEqual(stale, true)
}
@Test("Printer mismatch is flagged as stale")
func printerMismatch() async throws {
func testPrinterMismatch() async throws {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("mismatch_\(UUID().uuidString).cal")
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
@@ -64,6 +60,6 @@ struct CalibrationStoreTests {
try await store.load(url: url)
await store.setPrinterName("Printer A")
let stale = await store.isStale(comparedTo: "Printer B")
#expect(stale == true)
XCTAssertEqual(stale, true)
}
}
@@ -1,12 +1,10 @@
import Foundation
import Testing
import XCTest
@testable import ICCeryCore
@Suite("CalibrationTargenArgs")
struct CalibrationTargenArgsTests {
final class CalibrationTargenArgsTests: XCTestCase {
@Test("RGB baseline")
func rgbBaseline() throws {
func testRgbBaseline() throws {
let config = CalibrationTargenConfig(
colourSpace: .rgb,
steps: 21,
@@ -15,11 +13,10 @@ struct CalibrationTargenArgsTests {
workingDirectory: URL(fileURLWithPath: "/tmp")
)
let args = try CalibrationTargenArgs.build(config: config)
#expect(args == ["-v", "-d", "2", "-s", "21", "-g", "21", "-e", "4", "-f", "0", "CAL_demo"])
XCTAssertEqual(args, ["-v", "-d", "2", "-s", "21", "-g", "21", "-e", "4", "-f", "0", "CAL_demo"])
}
@Test("CMYK baseline with ink limit and neutral emphasis")
func cmykWithOptions() throws {
func testCmykWithOptions() throws {
let config = CalibrationTargenConfig(
colourSpace: .cmyk,
steps: 25,
@@ -30,33 +27,26 @@ struct CalibrationTargenArgsTests {
workingDirectory: URL(fileURLWithPath: "/tmp")
)
let args = try CalibrationTargenArgs.build(config: config)
#expect(args == ["-v", "-d", "4", "-s", "25", "-g", "25", "-e", "4", "-f", "0", "-n", "25", "-l", "320", "CAL_printer"])
XCTAssertEqual(args, ["-v", "-d", "4", "-s", "25", "-g", "25", "-e", "4", "-f", "0", "-n", "25", "-l", "320", "CAL_printer"])
}
@Test("Rejects out-of-range steps")
func rejectsBadSteps() {
func testRejectsBadSteps() {
let config = CalibrationTargenConfig(steps: 5, basename: "demo")
#expect(throws: (any Error).self) {
_ = try CalibrationTargenArgs.build(config: config)
}
XCTAssertThrowsError(try CalibrationTargenArgs.build(config: config))
}
@Test("Rejects bad CMYK ink limit")
func rejectsBadInkLimit() {
func testRejectsBadInkLimit() {
let config = CalibrationTargenConfig(
colourSpace: .cmyk,
inkLimit: 500,
basename: "demo"
)
#expect(throws: (any Error).self) {
_ = try CalibrationTargenArgs.build(config: config)
}
XCTAssertThrowsError(try CalibrationTargenArgs.build(config: config))
}
@Test("Does not double-prefix an existing CAL_ basename")
func noDoublePrefix() throws {
func testNoDoublePrefix() throws {
let config = CalibrationTargenConfig(basename: "CAL_test")
let args = try CalibrationTargenArgs.build(config: config)
#expect(args.last == "CAL_test")
XCTAssertEqual(args.last, "CAL_test")
}
}
+23 -32
View File
@@ -1,72 +1,63 @@
import Foundation
import Testing
import XCTest
@testable import ICCeryCore
@Suite("ColprofArgs")
struct ColprofArgsTests {
final class ColprofArgsTests: XCTestCase {
@Test("Default algorithm and quality")
func defaults() throws {
func testDefaults() throws {
let config = ColprofConfig(basename: "target")
let args = try ColprofArgs.build(config: config)
#expect(args == ["-v", "-a", "l", "-q", "m", "target"])
XCTAssertEqual(args, ["-v", "-a", "l", "-q", "m", "target"])
}
@Test("FWA bare -f when empty string")
func fwaBareFlag() throws {
func testFwaBareFlag() throws {
let config = ColprofConfig(fwa: "", basename: "target")
let args = try ColprofArgs.build(config: config)
#expect(args == ["-v", "-a", "l", "-q", "m", "-f", "target"])
XCTAssertEqual(args, ["-v", "-a", "l", "-q", "m", "-f", "target"])
}
@Test("FWA D50 and D65 emit -f value")
func fwaD50() throws {
func testFwaD50() throws {
let config = ColprofConfig(fwa: "D50", basename: "target")
let args = try ColprofArgs.build(config: config)
#expect(args.contains("-f"))
#expect(args.contains("D50"))
#expect(args.last == "target")
XCTAssertTrue(args.contains("-f"))
XCTAssertTrue(args.contains("D50"))
XCTAssertEqual(args.last, "target")
}
@Test("FWA none is omitted")
func fwaNoneOmitted() throws {
func testFwaNoneOmitted() throws {
let config = ColprofConfig(fwa: "none", basename: "target")
let args = try ColprofArgs.build(config: config)
#expect(!args.contains("-f"))
XCTAssertFalse(args.contains("-f"))
}
@Test("Viewing conditions skip none")
func viewingCondNoneSkipped() throws {
func testViewingCondNoneSkipped() throws {
let config = ColprofConfig(
inputViewingCond: "none",
outputViewingCond: "mt",
basename: "target"
)
let args = try ColprofArgs.build(config: config)
#expect(!args.contains("-c"))
#expect(args.contains("-d"))
#expect(args.contains("mt"))
XCTAssertFalse(args.contains("-c"))
XCTAssertTrue(args.contains("-d"))
XCTAssertTrue(args.contains("mt"))
}
@Test("Description falls back to basename when empty")
func descriptionFallback() throws {
func testDescriptionFallback() throws {
let config = ColprofConfig(description: "", basename: "target")
let args = try ColprofArgs.build(config: config)
#expect(!args.contains("-D"))
XCTAssertFalse(args.contains("-D"))
}
@Test("Copyright only when non-empty")
func copyright() throws {
func testCopyright() throws {
let config = ColprofConfig(copyright: "Gronod 2026", basename: "target")
let args = try ColprofArgs.build(config: config)
#expect(args.contains("-C"))
#expect(args.contains("Gronod 2026"))
XCTAssertTrue(args.contains("-C"))
XCTAssertTrue(args.contains("Gronod 2026"))
}
@Test("No -u passed")
func noProgressJsonFlag() throws {
func testNoProgressJsonFlag() throws {
let config = ColprofConfig(basename: "target")
let args = try ColprofArgs.build(config: config)
#expect(!args.contains("-u"))
XCTAssertFalse(args.contains("-u"))
}
}
@@ -1,24 +1,20 @@
import Foundation
import Testing
import XCTest
@testable import ICCeryCore
@Suite("ColprofProgress")
struct ColprofProgressTests {
final class ColprofProgressTests: XCTestCase {
@Test("Classifies gamut mapping")
func gamutMapping() {
#expect(ColprofProgressClassifier.classify(line: "Gamut mapping calculation in progress") == .gamutMapping)
func testGamutMapping() {
XCTAssertEqual(ColprofProgressClassifier.classify(line: "Gamut mapping calculation in progress"), .gamutMapping)
}
@Test("Classifies fitting or clut")
func fitting() {
#expect(ColprofProgressClassifier.classify(line: "Fitting cLUT grid points") == .fittingClut)
#expect(ColprofProgressClassifier.classify(line: "clut table") == .fittingClut)
func testFitting() {
XCTAssertEqual(ColprofProgressClassifier.classify(line: "Fitting cLUT grid points"), .fittingClut)
XCTAssertEqual(ColprofProgressClassifier.classify(line: "clut table"), .fittingClut)
}
@Test("Classifies writing")
func writing() {
#expect(ColprofProgressClassifier.classify(line: "Writing ICC profile header") == .writingIcc)
#expect(ColprofProgressClassifier.classify(line: "icc profile written") == .writingIcc)
func testWriting() {
XCTAssertEqual(ColprofProgressClassifier.classify(line: "Writing ICC profile header"), .writingIcc)
XCTAssertEqual(ColprofProgressClassifier.classify(line: "icc profile written"), .writingIcc)
}
}
@@ -1,4 +1,4 @@
import Testing
import XCTest
import Foundation
@testable import ICCeryCore
@testable import ICCery
@@ -6,48 +6,42 @@ import AppKit
import ApplicationServices
/// Issue 14 PMPrintSettingsToOptions capture filter (docs/11 layer ).
@Suite("CupsOptionsFilter")
struct CupsOptionsFilterTests {
final class CupsOptionsFilterTests: XCTestCase {
@Test("Drops com.apple.*, collate, copies, job-sheets, AP_* keys")
func dropsReserved() {
func testDropsReserved() {
let raw = "AP_ColorMatchingMode=AP_ApplicationColorMatching "
+ "AP.ColorMatchingMode=AP_ApplicationColorMatching "
+ "com.apple.print.JobTicket.PMTotalSidesImaged=0 "
+ "collate=true copies=1 job-sheets=none,none "
+ "pserrorhandler-requested=standard "
+ "MediaType=PhotographicGlossy"
#expect(CupsOptionsFilter.filter(raw) == "MediaType=PhotographicGlossy")
XCTAssertEqual(CupsOptionsFilter.filter(raw), "MediaType=PhotographicGlossy")
}
@Test("Keeps relevant driver keys, order preserved")
func keepsRelevant() {
func testKeepsRelevant() {
let raw = "InputSlot=Rear PageSize=A4 CNIJIntent2=4 "
+ "Resolution=600x600dpi Duplex=None"
#expect(CupsOptionsFilter.filter(raw) == raw)
XCTAssertEqual(CupsOptionsFilter.filter(raw), raw)
}
@Test("Permissive: unknown non-com.* keys survive")
func keepsUnknown() {
func testKeepsUnknown() {
let raw = "VendorFooBar=baz MediaType=Plain"
#expect(CupsOptionsFilter.filter(raw) == raw)
XCTAssertEqual(CupsOptionsFilter.filter(raw), raw)
}
@Test("Drops empty keys and values")
func dropsEmpty() {
func testDropsEmpty() {
let raw = "=noval MediaType= InputSlot=Rear"
// "MediaType=" has an empty value dropped; "=noval" empty key.
#expect(CupsOptionsFilter.filter(raw) == "InputSlot=Rear")
XCTAssertEqual(CupsOptionsFilter.filter(raw), "InputSlot=Rear")
}
@Test("extractMediaType prefers MediaType then EPIJ_Medi")
func extractMedia() {
#expect(CupsParsers.extractMediaType(
fromOptionsString: "MediaType=Photo EPIJ_Medi=1") == "Photo")
#expect(CupsParsers.extractMediaType(
fromOptionsString: "EPIJ_Medi=7") == "7")
#expect(CupsParsers.extractMediaType(
fromOptionsString: "PageSize=A4") == nil)
func testExtractMedia() {
XCTAssertEqual(CupsParsers.extractMediaType(
fromOptionsString: "MediaType=Photo EPIJ_Medi=1"), "Photo")
XCTAssertEqual(CupsParsers.extractMediaType(
fromOptionsString: "EPIJ_Medi=7"), "7")
XCTAssertNil(CupsParsers.extractMediaType(
fromOptionsString: "PageSize=A4"))
}
}
@@ -55,9 +49,8 @@ struct CupsOptionsFilterTests {
/// `@convention(c)` closures can't capture, so recording goes through
/// a file-scope recorder keyed by global state; no private symbols are
/// touched.
@Suite("ColorSyncSuppressor")
@MainActor
struct ColorSyncSuppressorTests {
final class ColorSyncSuppressorTests: XCTestCase {
/// Fake PMPrintSession the injected resolver never dereferences it.
private var fakeSession: PMPrintSession {
@@ -78,10 +71,14 @@ struct ColorSyncSuppressorTests {
s.modeResolver = { name in
if Self.missing.contains(name) { return nil }
Self.currentSymbol = name
// `Self` inside a @convention(c) closure is a dynamic-Self
// capture spell the (final) class name instead.
return { _, modeArg in
Self.recorded.append((Self.currentSymbol, modeArg as String))
if let ok = Self.succeeding,
Self.currentSymbol == ok.0, (modeArg as String) == ok.1 {
ColorSyncSuppressorTests.recorded.append(
(ColorSyncSuppressorTests.currentSymbol, modeArg as String))
if let ok = ColorSyncSuppressorTests.succeeding,
ColorSyncSuppressorTests.currentSymbol == ok.0,
(modeArg as String) == ok.1 {
return 0
}
return 1
@@ -90,55 +87,50 @@ struct ColorSyncSuppressorTests {
return s
}
@Test("Attempt order: Lock → Mode → NoLock, AP_ prefix first")
func attemptOrder() {
func testAttemptOrder() {
Self.recorded = []
Self.succeeding = nil
Self.missing = ["PMSessionSetColorMatchingModeLock"]
let s = makeSuppressor()
#expect(s.applySPIMode(to: fakeSession) == false)
XCTAssertEqual(s.applySPIMode(to: fakeSession), false)
// Lock is unresolvable skipped; the rest plays out in order.
#expect(Self.recorded.map { "\($0.0)|\($0.1)" }
== ColorMatchingAttempts.attempts
XCTAssertEqual(Self.recorded.map { "\($0.0)|\($0.1)" }, ColorMatchingAttempts.attempts
.filter { $0.symbol != "PMSessionSetColorMatchingModeLock" }
.map { "\($0.symbol)|\($0.mode)" })
}
@Test("First zero wins — later symbols/modes not called")
func firstZeroWins() {
func testFirstZeroWins() {
Self.recorded = []
Self.succeeding = ("PMSessionSetColorMatchingModeLock",
"AP_ApplicationColorMatching")
Self.missing = []
let s = makeSuppressor()
#expect(s.applySPIMode(to: fakeSession))
#expect(Self.recorded.map { "\($0.0)|\($0.1)" } == [
XCTAssertTrue(s.applySPIMode(to: fakeSession))
XCTAssertEqual(Self.recorded.map { "\($0.0)|\($0.1)" }, [
"PMSessionSetColorMatchingModeLock|AP_ApplicationColorMatching",
])
}
@Test("Mode fallback: AP_ rejected → ApplicationColorMatching tried")
func modeFallback() {
func testModeFallback() {
Self.recorded = []
Self.succeeding = ("PMSessionSetColorMatchingModeLock",
"ApplicationColorMatching")
Self.missing = []
let s = makeSuppressor()
#expect(s.applySPIMode(to: fakeSession))
#expect(Self.recorded[0].0 == "PMSessionSetColorMatchingModeLock")
#expect(Self.recorded[0].1 == "AP_ApplicationColorMatching")
#expect(Self.recorded[1].0 == "PMSessionSetColorMatchingModeLock")
#expect(Self.recorded[1].1 == "ApplicationColorMatching")
#expect(Self.recorded.count == 2)
XCTAssertTrue(s.applySPIMode(to: fakeSession))
XCTAssertEqual(Self.recorded[0].0, "PMSessionSetColorMatchingModeLock")
XCTAssertEqual(Self.recorded[0].1, "AP_ApplicationColorMatching")
XCTAssertEqual(Self.recorded[1].0, "PMSessionSetColorMatchingModeLock")
XCTAssertEqual(Self.recorded[1].1, "ApplicationColorMatching")
XCTAssertEqual(Self.recorded.count, 2)
}
@Test("All symbols missing → false, no calls")
func allMissing() {
func testAllMissing() {
Self.recorded = []
Self.succeeding = nil
Self.missing = Set(ColorMatchingAttempts.symbols)
let s = makeSuppressor()
#expect(s.applySPIMode(to: fakeSession) == false)
#expect(Self.recorded.isEmpty)
XCTAssertEqual(s.applySPIMode(to: fakeSession), false)
XCTAssertTrue(Self.recorded.isEmpty)
}
}
+51 -62
View File
@@ -1,11 +1,10 @@
import Testing
import XCTest
import Foundation
@testable import ICCeryCore
/// Issue 12 CUPS enumeration parsers on recorded fixtures
/// (docs/1011). No live `lpstat`/`lpoptions` is spawned here.
@Suite("CupsParsers")
struct CupsParsersTests {
final class CupsParsersTests: XCTestCase {
// Recorded on an Epson XP-55 + Canon Pro9500 host.
private let lpstatE = """
@@ -34,112 +33,102 @@ struct CupsParsersTests {
cupsPrintQuality/cupsPrintQuality: Draft *Normal High
"""
@Test("lpstat -e: one destination per line; empty = success")
func destinations() {
#expect(CupsParsers.lpstatDestinations(lpstatE) == [
func testDestinations() {
XCTAssertEqual(CupsParsers.lpstatDestinations(lpstatE), [
"Canon_Pro9500_II_series_XPS",
"Epson_XP_55_LPD",
"EPSON_XP_55_Series",
])
#expect(CupsParsers.lpstatDestinations("") == [])
XCTAssertEqual(CupsParsers.lpstatDestinations(""), [])
}
@Test("lpstat -p: idle / now-printing / disabled statuses")
func statuses() {
func testStatuses() {
let s = CupsParsers.lpstatStatuses(lpstatP)
#expect(s["Canon_Pro9500_II_series_XPS"] == .idle)
#expect(s["Epson_XP_55_LPD"] == .printing)
#expect(s["EPSON_XP_55_Series"] == .stopped)
XCTAssertEqual(s["Canon_Pro9500_II_series_XPS"], .idle)
XCTAssertEqual(s["Epson_XP_55_LPD"], .printing)
XCTAssertEqual(s["EPSON_XP_55_Series"], .stopped)
}
@Test("lpstat -d: default destination or none")
func defaultDestination() {
#expect(CupsParsers.lpstatDefault(
"system default destination: Canon_Pro9500_II_series_XPS\n")
== "Canon_Pro9500_II_series_XPS")
#expect(CupsParsers.lpstatDefault("no system default destination\n") == nil)
func testDefaultDestination() {
XCTAssertEqual(CupsParsers.lpstatDefault(
"system default destination: Canon_Pro9500_II_series_XPS\n"), "Canon_Pro9500_II_series_XPS")
XCTAssertNil(CupsParsers.lpstatDefault("no system default destination\n"))
}
@Test("lpoptions -p: quoted printer-info, bare flags ignored")
func displayName() {
#expect(CupsParsers.lpoptionsDisplayName(lpoptionsP) == "EPSON XP-55 Series")
#expect(CupsParsers.lpoptionsDisplayName("printer-type=42\n") == nil)
func testDisplayName() {
XCTAssertEqual(CupsParsers.lpoptionsDisplayName(lpoptionsP), "EPSON XP-55 Series")
XCTAssertNil(CupsParsers.lpoptionsDisplayName("printer-type=42\n"))
}
@Test("lpoptions -l: key/label split, * marks the default")
func optionListings() {
func testOptionListings() {
let listings = CupsParsers.lpoptionsList(lpoptionsL)
#expect(listings.count == 6)
XCTAssertEqual(listings.count, 6)
let page = listings[0]
#expect(page.key == "PageSize")
#expect(page.label == "Media Size")
#expect(page.defaultChoice == "A4")
#expect(page.choices.contains("Custom.WIDTHxHEIGHT"))
#expect(!page.choices.contains("*A4"))
XCTAssertEqual(page.key, "PageSize")
XCTAssertEqual(page.label, "Media Size")
XCTAssertEqual(page.defaultChoice, "A4")
XCTAssertTrue(page.choices.contains("Custom.WIDTHxHEIGHT"))
XCTAssertFalse(page.choices.contains("*A4"))
let slot = listings[1]
#expect(slot.key == "InputSlot")
#expect(slot.choices == ["Auto", "Main", "Photo", "Rear"])
#expect(slot.defaultChoice == "Main")
XCTAssertEqual(slot.key, "InputSlot")
XCTAssertEqual(slot.choices, ["Auto", "Main", "Photo", "Rear"])
XCTAssertEqual(slot.defaultChoice, "Main")
}
@Test("capabilities: trays/sizes index 1-based, media uses detected key")
func capabilities() {
func testCapabilities() {
let service = CupsService()
let listings = CupsParsers.lpoptionsList(lpoptionsL)
let caps = service.capabilities(from: listings, ppd: nil)
#expect(caps.trays == [
XCTAssertEqual(caps.trays, [
PrinterTray(id: 1, name: "Auto"),
PrinterTray(id: 2, name: "Main"),
PrinterTray(id: 3, name: "Photo"),
PrinterTray(id: 4, name: "Rear"),
])
#expect(caps.paperSizes.first == PrinterPaperSize(id: 1, name: "3.5x5"))
#expect(caps.paperSizes.count == 10)
#expect(caps.mediaTypes.map(\.id) == [
XCTAssertEqual(caps.paperSizes.first, PrinterPaperSize(id: 1, name: "3.5x5"))
XCTAssertEqual(caps.paperSizes.count, 10)
XCTAssertEqual(caps.mediaTypes.map(\.id), [
"Stationery", "PhotographicHighGloss", "Photographic",
"PhotographicMatte", "Envelope",
])
#expect(caps.supportsOrientation)
XCTAssertTrue(caps.supportsOrientation)
}
@Test("PPD enrichment maps id → human label")
func ppdLabels() {
func testPpdLabels() {
let ppd = """
*CNIJMediaType 42/Photo Paper Plus Semi-gloss: "<</MediaType(42)>>"
*CNIJMediaType 0/Plain Paper: ""
*en_US.CNIJMediaType 13/Envelope: ""
"""
let labels = CupsParsers.ppdChoiceLabels(ppd, key: "CNIJMediaType")
#expect(labels["42"] == "Photo Paper Plus Semi-gloss")
#expect(labels["0"] == "Plain Paper")
#expect(labels["13"] == "Envelope")
XCTAssertEqual(labels["42"], "Photo Paper Plus Semi-gloss")
XCTAssertEqual(labels["0"], "Plain Paper")
XCTAssertEqual(labels["13"], "Envelope")
}
@Test("detectMediaTypeKey prefers vendor keys in order")
func mediaTypeKey() {
#expect(CupsParsers.detectMediaTypeKey(
optionKeys: ["MediaType", "CNIJMediaType"]) == "CNIJMediaType")
#expect(CupsParsers.detectMediaTypeKey(
optionKeys: ["PageSize", "MediaType"]) == "MediaType")
#expect(CupsParsers.detectMediaTypeKey(optionKeys: ["PageSize"]) == nil)
func testMediaTypeKey() {
XCTAssertEqual(CupsParsers.detectMediaTypeKey(
optionKeys: ["MediaType", "CNIJMediaType"]), "CNIJMediaType")
XCTAssertEqual(CupsParsers.detectMediaTypeKey(
optionKeys: ["PageSize", "MediaType"]), "MediaType")
XCTAssertNil(CupsParsers.detectMediaTypeKey(optionKeys: ["PageSize"]))
}
@Test("Driver bypass: Canon Intent2 > Intent; Epson CCor > CMat")
func driverBypass() {
func testDriverBypass() {
func pair(_ keys: Set<String>) -> String? {
CupsParsers.detectDriverColorBypass(optionKeys: keys)
.map { "\($0.key)=\($0.value)" }
}
#expect(pair(["CNIJIntent2", "CNIJIntent"]) == "CNIJIntent2=4")
#expect(pair(["CNIJIntent"]) == "CNIJIntent=4")
#expect(pair(["EPIJ_CCor", "EPIJ_CMat"]) == "EPIJ_CCor=0")
#expect(pair(["EPIJ_CMat"]) == "EPIJ_CMat=3")
#expect(pair(["StpColorCorrection"]) == "StpColorCorrection=Uncorrected")
#expect(pair(["ColorCorrection"]) == "ColorCorrection=Uncorrected")
#expect(pair(["EpsonColorMode"]) == "EpsonColorMode=Off")
#expect(pair(["PageSize"]) == nil)
XCTAssertEqual(pair(["CNIJIntent2", "CNIJIntent"]), "CNIJIntent2=4")
XCTAssertEqual(pair(["CNIJIntent"]), "CNIJIntent=4")
XCTAssertEqual(pair(["EPIJ_CCor", "EPIJ_CMat"]), "EPIJ_CCor=0")
XCTAssertEqual(pair(["EPIJ_CMat"]), "EPIJ_CMat=3")
XCTAssertEqual(pair(["StpColorCorrection"]), "StpColorCorrection=Uncorrected")
XCTAssertEqual(pair(["ColorCorrection"]), "ColorCorrection=Uncorrected")
XCTAssertEqual(pair(["EpsonColorMode"]), "EpsonColorMode=Off")
XCTAssertNil(pair(["PageSize"]))
}
}
+20 -30
View File
@@ -1,65 +1,57 @@
import Foundation
import Testing
import XCTest
@testable import ICCeryCore
@Suite("DriftAlert")
struct DriftAlertTests {
final class DriftAlertTests: XCTestCase {
@Test("No alert with fewer than two poor results")
func notEnough() {
func testNotEnough() {
let records = [
record(avg: 4.0, at: 1000)
]
#expect(DriftAlert.compute(from: records) == nil)
XCTAssertNil(DriftAlert.compute(from: records))
}
@Test("Alert on two poor results one hour apart")
func oneHourApart() {
func testOneHourApart() {
let records = [
record(avg: 4.0, at: 1000),
record(avg: 5.0, at: 4600)
]
#expect(DriftAlert.compute(from: records) != nil)
XCTAssertNotNil(DriftAlert.compute(from: records))
}
@Test("No alert if same day and under one hour")
func sameDayUnderHour() {
func testSameDayUnderHour() {
let records = [
record(avg: 4.0, at: 1000),
record(avg: 5.0, at: 2000)
]
#expect(DriftAlert.compute(from: records) == nil)
XCTAssertNil(DriftAlert.compute(from: records))
}
@Test("Alert on distinct days")
func distinctDays() {
func testDistinctDays() {
let day1 = record(avg: 4.0, at: 0)
let day2 = record(avg: 5.0, at: 86400 + 1000)
#expect(DriftAlert.compute(from: [day1, day2]) != nil)
XCTAssertNotNil(DriftAlert.compute(from: [day1, day2]))
}
@Test("Non-poor records do not trigger")
func nonPoor() {
func testNonPoor() {
let records = [
record(avg: 1.0, at: 0),
record(avg: 1.5, at: 86400)
]
#expect(DriftAlert.compute(from: records) == nil)
XCTAssertNil(DriftAlert.compute(from: records))
}
@Test("Non-poor records break the consecutive poor run")
func nonPoorBreaksRun() {
func testNonPoorBreaksRun() {
let records = [
record(avg: 4.0, at: 0), // poor
record(avg: 4.5, at: 86400), // poor, far apart
record(avg: 1.0, at: 90000), // good breaks the run
record(avg: 4.0, at: 92000) // poor, recent but close to previous poor
]
#expect(DriftAlert.compute(from: records) == nil)
XCTAssertNil(DriftAlert.compute(from: records))
}
@Test("Only the final consecutive poor run is considered")
func onlySuffixRun() {
func testOnlySuffixRun() {
let records = [
record(avg: 4.0, at: 0), // poor
record(avg: 4.5, at: 18000), // poor, > 1h from first
@@ -67,26 +59,24 @@ struct DriftAlertTests {
record(avg: 4.0, at: 25000), // poor
record(avg: 4.5, at: 26000) // poor, < 1h and same day
]
#expect(DriftAlert.compute(from: records) == nil)
XCTAssertNil(DriftAlert.compute(from: records))
}
@Test("Final consecutive poor run alerts when far apart")
func suffixRunAlerts() {
func testSuffixRunAlerts() {
let records = [
record(avg: 1.0, at: 0), // good
record(avg: 4.0, at: 1000), // poor
record(avg: 4.5, at: 4600) // poor, 1h after previous
]
#expect(DriftAlert.compute(from: records) != nil)
XCTAssertNotNil(DriftAlert.compute(from: records))
}
@Test("A single final poor record after good records does not alert")
func singleFinalPoor() {
func testSingleFinalPoor() {
let records = [
record(avg: 1.0, at: 0),
record(avg: 4.0, at: 86400)
]
#expect(DriftAlert.compute(from: records) == nil)
XCTAssertNil(DriftAlert.compute(from: records))
}
private func record(avg: Double, at offset: TimeInterval) -> VerificationRecord {
+35 -38
View File
@@ -1,4 +1,4 @@
import Testing
import XCTest
import Foundation
@testable import ICCeryCore
@@ -13,91 +13,88 @@ private func touch(_ url: URL, _ contents: String = "x") throws {
try contents.write(to: url, atomically: true, encoding: .utf8)
}
@Suite("PathSecurity")
struct PathSecurityTests {
@Test func rejectsTraversalAndSeparators() {
final class PathSecurityTests: XCTestCase {
func testRejectsTraversalAndSeparators() {
for bad in ["a/b", "a\\b", "..", "a/../b", "", "..x"] {
#expect(!PathSecurity.isValidBasename(bad))
#expect(throws: PathSecurity.Error.self) {
try PathSecurity.sanitizeBasename(bad)
XCTAssertFalse(PathSecurity.isValidBasename(bad))
XCTAssertThrowsError(try PathSecurity.sanitizeBasename(bad)) { error in
XCTAssertTrue(error is PathSecurity.Error)
}
}
}
@Test func acceptsNormalNames() {
func testAcceptsNormalNames() {
for good in ["target", "My Target 01", "écheneau-ümläut", "a.b"] {
#expect(PathSecurity.isValidBasename(good))
XCTAssertTrue(PathSecurity.isValidBasename(good))
}
}
@Test func resolveSafeCwdPrefersExplicit() throws {
func testResolveSafeCwdPrefersExplicit() throws {
let dir = try tempDir()
#expect(PathSecurity.resolveSafeCwd(dir) == dir)
XCTAssertEqual(PathSecurity.resolveSafeCwd(dir), dir)
}
@Test func resolveSafeCwdNeverReturnsNil() {
func testResolveSafeCwdNeverReturnsNil() {
let missing = URL(fileURLWithPath: "/nonexistent-\(UUID().uuidString)")
let resolved = PathSecurity.resolveSafeCwd(missing)
#expect(FileManager.default.fileExists(atPath: resolved.path))
XCTAssertTrue(FileManager.default.fileExists(atPath: resolved.path))
}
}
@Suite("AtomicFileWriter")
struct AtomicFileWriterTests {
@Test func writesAndLeavesNoTmp() throws {
final class AtomicFileWriterTests: XCTestCase {
func testWritesAndLeavesNoTmp() throws {
let dir = try tempDir()
let url = dir.appendingPathComponent("state.json")
try AtomicFileWriter.write(Data("{\"a\":1}".utf8), to: url)
#expect(try String(contentsOf: url, encoding: .utf8) == "{\"a\":1}")
#expect(!FileManager.default.fileExists(atPath: url.appendingPathExtension("tmp").path))
XCTAssertEqual(try String(contentsOf: url, encoding: .utf8), "{\"a\":1}")
XCTAssertFalse(FileManager.default.fileExists(atPath: url.appendingPathExtension("tmp").path))
}
@Test func overwritesExistingAtomically() throws {
func testOverwritesExistingAtomically() throws {
let dir = try tempDir()
let url = dir.appendingPathComponent("f.txt")
try AtomicFileWriter.write("one", to: url)
try AtomicFileWriter.write("two-longer", to: url)
#expect(try String(contentsOf: url, encoding: .utf8) == "two-longer")
XCTAssertEqual(try String(contentsOf: url, encoding: .utf8), "two-longer")
}
@Test func createsParentDirs() throws {
func testCreatesParentDirs() throws {
let dir = try tempDir()
let url = dir.appendingPathComponent("a/b/c/deep.json")
try AtomicFileWriter.write("{}", to: url)
#expect(FileManager.default.fileExists(atPath: url.path))
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
}
}
@Suite("ArtefactProbe")
struct ArtefactProbeTests {
@Test func verifyProgression() throws {
final class ArtefactProbeTests: XCTestCase {
func testVerifyProgression() throws {
let dir = try tempDir()
var v = ArtefactProbe.verify(basename: "t", cwd: dir)
#expect(v == StageArtefacts())
XCTAssertEqual(v, StageArtefacts())
try touch(dir.appendingPathComponent("t.ti1"))
v = ArtefactProbe.verify(basename: "t", cwd: dir)
#expect(v.stage1Complete && !v.stage2Complete && !v.stage3Complete)
XCTAssertTrue(v.stage1Complete && !v.stage2Complete && !v.stage3Complete)
try touch(dir.appendingPathComponent("t.ti2"))
try touch(dir.appendingPathComponent("t.ti3"))
v = ArtefactProbe.verify(basename: "t", cwd: dir)
#expect(v.stage2Complete && v.stage3Complete && !v.stage4Complete)
XCTAssertTrue(v.stage2Complete && v.stage3Complete && !v.stage4Complete)
try touch(dir.appendingPathComponent("t.icc"))
v = ArtefactProbe.verify(basename: "t", cwd: dir)
#expect(v.stage4Complete && v.profilePath?.pathExtension == "icc")
XCTAssertTrue(v.stage4Complete && v.profilePath?.pathExtension == "icc")
}
@Test func icmWinsOverIcc() throws {
func testIcmWinsOverIcc() throws {
let dir = try tempDir()
try touch(dir.appendingPathComponent("p.icc"))
try touch(dir.appendingPathComponent("p.icm"))
let profile = ArtefactProbe.resolveProfile(basename: "p", cwd: dir)
#expect(profile?.pathExtension == "icm")
XCTAssertEqual(profile?.pathExtension, "icm")
}
@Test func enumeratesPassesPagesAndCAL() throws {
func testEnumeratesPassesPagesAndCAL() throws {
let dir = try tempDir()
for name in [
"t.ti1", "t.ti2", "t.tif", "t.2.tif", "t_03.tif",
@@ -115,15 +112,15 @@ struct ArtefactProbeTests {
"t.ti3", "t_pass1.ti3", "t_pass2.ti3",
"t.icc", "t.gam", "CAL_t.ti1", "CAL_t.cal",
] {
#expect(names.contains(expected), "missing \(expected)")
XCTAssertTrue(names.contains(expected), "missing \(expected)")
}
#expect(!names.contains("other.ti1"))
#expect(!names.contains("t.txt"))
#expect(!names.contains("CAL_other.ti1"))
XCTAssertFalse(names.contains("other.ti1"))
XCTAssertFalse(names.contains("t.txt"))
XCTAssertFalse(names.contains("CAL_other.ti1"))
}
@Test func emptyDirReturnsEmpty() throws {
func testEmptyDirReturnsEmpty() throws {
let dir = try tempDir()
#expect(ArtefactProbe.existingArtefacts(basename: "x", cwd: dir).isEmpty)
XCTAssertTrue(ArtefactProbe.existingArtefacts(basename: "x", cwd: dir).isEmpty)
}
}
@@ -0,0 +1,85 @@
import Foundation
import XCTest
@testable import ICCeryCore
/// ``GamutGeometry`` containment and volume goldens on the bundled
/// `sRGB.gam` reference mesh (issue #147). No GPU involved.
final class GamutContainmentTests: XCTestCase {
/// Returns the bundled real `sRGB.gam` in `Resources/Argyll/reference_gamuts`.
private var bundledSRGBGamURL: URL {
let bundle = Bundle.main
let resource = bundle.resourceURL ?? bundle.bundleURL
return resource.appendingPathComponent("Argyll/reference_gamuts/sRGB.gam")
}
private func loadSRGB() throws -> GamutMesh {
try GamutMeshParser.parse(url: bundledSRGBGamURL)
}
func testNeutralMidGreyIsInsideSRGB() throws {
let mesh = try loadSRGB()
XCTAssertEqual(
GamutGeometry.containment(of: LabColor(l: 50, a: 0, b: 0), in: mesh),
.inside)
}
func testSaturatedColourIsOutsideSRGB() throws {
let mesh = try loadSRGB()
XCTAssertEqual(
GamutGeometry.containment(of: LabColor(l: 50, a: 80, b: 80), in: mesh),
.outside)
}
func testVolumeIsFiniteAndPositiveOnBundledSRGB() throws {
let mesh = try loadSRGB()
let volume = GamutGeometry.volume(of: mesh)
XCTAssertTrue(volume.isFinite)
XCTAssertGreaterThan(volume, 0)
}
func testVertexOnlyMeshReportsUnknown() {
let mesh = GamutMesh(
vertices: [
GamutVertex(lab: LabColor(l: 50, a: 0, b: 0), rgb: DisplayRGB(r: 0.5, g: 0.5, b: 0.5)),
],
faces: [])
XCTAssertEqual(
GamutGeometry.containment(of: LabColor(l: 50, a: 0, b: 0), in: mesh),
.unknown)
XCTAssertEqual(GamutGeometry.volume(of: mesh), 0)
}
func testKnownCubeFixture() throws {
// Unit cube centred at Lab (50, 0, 0): a*,b* ±10, L* 40...60.
// Two triangles per face, outward winding.
let lab = { (l: Double, a: Double, b: Double) in
GamutVertex(lab: LabColor(l: l, a: a, b: b), rgb: DisplayRGB(r: 0, g: 0, b: 0))
}
// Corners in (a, L, b) space.
let c = [
lab(40, -10, -10), lab(40, 10, -10), lab(40, 10, 10), lab(40, -10, 10), // bottom
lab(60, -10, -10), lab(60, 10, -10), lab(60, 10, 10), lab(60, -10, 10), // top
]
let quad = { (a: UInt32, b: UInt32, c: UInt32, d: UInt32) in
[GamutTriangle(a: a, b: b, c: c), GamutTriangle(a: a, b: c, c: d)]
}
var faces: [GamutTriangle] = []
faces += quad(0, 3, 2, 1) // bottom (y=40)
faces += quad(4, 5, 6, 7) // top (y=60)
faces += quad(0, 1, 5, 4) // z=-10
faces += quad(3, 7, 6, 2) // z=+10
faces += quad(1, 2, 6, 5) // x=+10
faces += quad(0, 4, 7, 3) // x=-10
let mesh = GamutMesh(vertices: c, faces: faces)
XCTAssertEqual(
GamutGeometry.containment(of: LabColor(l: 50, a: 0, b: 0), in: mesh),
.inside)
XCTAssertEqual(
GamutGeometry.containment(of: LabColor(l: 50, a: 20, b: 0), in: mesh),
.outside)
// 20 × 20 × 20 Lab-cube.
XCTAssertEqual(GamutGeometry.volume(of: mesh), 8000, accuracy: 1)
}
}
@@ -1,15 +1,13 @@
import Testing
import XCTest
import SceneKit
import ICCeryCore
@testable import ICCery
/// ``GamutSceneGeometryBuilder`` edge-case tests.
@Suite("Gamut scene geometry builder")
@MainActor
struct GamutGeometryBuilderTests {
final class GamutGeometryBuilderTests: XCTestCase {
@Test("Drops out-of-bounds faces from the element without crashing")
func dropsOutOfBoundsFaces() {
func testDropsOutOfBoundsFaces() {
let white = GamutVertex(
lab: LabColor(l: 100, a: 0, b: 0),
rgb: DisplayRGB(r: 1, g: 1, b: 1)
@@ -28,6 +26,6 @@ struct GamutGeometryBuilderTests {
let (_, element) = GamutSceneGeometryBuilder.geometry(for: mesh)
#expect(element.primitiveCount == 1, "Only the in-bounds face should be in the index buffer")
XCTAssertEqual(element.primitiveCount, 1, "Only the in-bounds face should be in the index buffer")
}
}
@@ -1,10 +1,9 @@
import Foundation
import Testing
import XCTest
@testable import ICCeryCore
/// ``GamutMeshParser`` acceptance + edge-case tests.
@Suite("Gamut mesh parser")
struct GamutMeshParserTests {
final class GamutMeshParserTests: XCTestCase {
/// Returns the bundled real `sRGB.gam` in `Resources/Argyll/reference_gamuts`.
private var bundledSRGBGamURL: URL {
@@ -13,16 +12,14 @@ struct GamutMeshParserTests {
return resource.appendingPathComponent("Argyll/reference_gamuts/sRGB.gam")
}
@Test("Parses bundled sRGB.gam")
func parsesBundledSRGB() throws {
func testParsesBundledSRGB() throws {
let mesh = try GamutMeshParser.parse(url: bundledSRGBGamURL)
#expect(mesh.vertices.count == 448, "sRGB.gam has 448 vertices")
#expect(mesh.faces.count == 892, "sRGB.gam has 892 faces")
XCTAssertEqual(mesh.vertices.count, 448, "sRGB.gam has 448 vertices")
XCTAssertEqual(mesh.faces.count, 892, "sRGB.gam has 892 faces")
}
@Test("Discards VERTEX_NO and uses push-order indices")
func discardsVertexNo() throws {
func testDiscardsVertexNo() throws {
let text = """
GAMUT
NUMBER_OF_FIELDS 4
@@ -49,14 +46,13 @@ struct GamutMeshParserTests {
let mesh = try GamutMeshParser.parse(text: text)
#expect(mesh.vertices.count == 4)
#expect(mesh.faces.count == 2)
#expect(mesh.vertices[0].lab == LabColor(l: 10, a: 20, b: 30))
#expect(mesh.vertices[3].lab == LabColor(l: 40, a: 50, b: 60))
XCTAssertEqual(mesh.vertices.count, 4)
XCTAssertEqual(mesh.faces.count, 2)
XCTAssertEqual(mesh.vertices[0].lab, LabColor(l: 10, a: 20, b: 30))
XCTAssertEqual(mesh.vertices[3].lab, LabColor(l: 40, a: 50, b: 60))
}
@Test("Ignores comments and blank lines")
func ignoresComments() throws {
func testIgnoresComments() throws {
let text = """
# Header comment
NUMBER_OF_FIELDS 4
@@ -81,12 +77,11 @@ struct GamutMeshParserTests {
"""
let mesh = try GamutMeshParser.parse(text: text)
#expect(mesh.vertices.count == 2)
#expect(mesh.faces.count == 1)
XCTAssertEqual(mesh.vertices.count, 2)
XCTAssertEqual(mesh.faces.count, 1)
}
@Test("Remaps coordinates to x=a*, y=L*, z=b*")
func remapsCoordinates() throws {
func testRemapsCoordinates() throws {
let text = """
NUMBER_OF_FIELDS 4
BEGIN_DATA_FORMAT
@@ -99,11 +94,10 @@ struct GamutMeshParserTests {
"""
let mesh = try GamutMeshParser.parse(text: text)
#expect(mesh.vertices.first?.position == SIMD3<Float>(-20, 50, 80))
XCTAssertEqual(mesh.vertices.first?.position, SIMD3<Float>(-20, 50, 80))
}
@Test("Computes per-vertex sRGB colour")
func computesVertexColor() throws {
func testComputesVertexColor() throws {
let text = """
NUMBER_OF_FIELDS 4
BEGIN_DATA_FORMAT
@@ -116,14 +110,13 @@ struct GamutMeshParserTests {
"""
let mesh = try GamutMeshParser.parse(text: text)
let white = try #require(mesh.vertices.first).rgb
#expect(white.r > 0.95)
#expect(white.g > 0.95)
#expect(white.b > 0.95)
let white = try XCTUnwrap(mesh.vertices.first).rgb
XCTAssertTrue(white.r > 0.95)
XCTAssertTrue(white.g > 0.95)
XCTAssertTrue(white.b > 0.95)
}
@Test("Drops out-of-bounds face indices")
func dropsOutOfBoundsFaces() throws {
func testDropsOutOfBoundsFaces() throws {
let text = """
NUMBER_OF_FIELDS 4
BEGIN_DATA_FORMAT
@@ -146,21 +139,23 @@ struct GamutMeshParserTests {
"""
let mesh = try GamutMeshParser.parse(text: text)
#expect(mesh.faces.count == 1)
XCTAssertEqual(mesh.faces.count, 1)
}
@Test("Throws on empty file")
func throwsOnEmptyFile() {
#expect(throws: GamutMeshParseError.noDataBlock) {
_ = try GamutMeshParser.parse(text: "")
func testThrowsOnEmptyFile() {
XCTAssertThrowsError(try GamutMeshParser.parse(text: "")) { error in
guard case GamutMeshParseError.noDataBlock = error else {
return XCTFail("Expected GamutMeshParseError.noDataBlock, got \(error)")
}
}
}
@Test("Throws when file is missing")
func throwsWhenMissing() {
func testThrowsWhenMissing() {
let url = URL(fileURLWithPath: "/nonexistent/path/to/mesh.gam")
#expect(throws: GamutMeshParseError.missingFile) {
_ = try GamutMeshParser.parse(url: url)
XCTAssertThrowsError(try GamutMeshParser.parse(url: url)) { error in
guard case GamutMeshParseError.missingFile = error else {
return XCTFail("Expected GamutMeshParseError.missingFile, got \(error)")
}
}
}
}
@@ -0,0 +1,127 @@
import Foundation
import XCTest
@testable import ICCeryCore
@testable import ICCery
/// Issue #147 `GamutViewModel` compare-slot behaviour: failed or
/// missing compare meshes are info, never fatal (#24); sRGB always stays.
@MainActor
final class GamutViewModelTests: XCTestCase {
/// Bundled root that has `reference_gamuts/sRGB.gam` but **no** tool
/// binaries, so `iccgamut` spawns deterministically fail.
private func bundledRootWithoutTools() throws -> URL {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-gamut-vm-\(UUID().uuidString)")
let gamutDir = root.appendingPathComponent("reference_gamuts")
try FileManager.default.createDirectory(
at: gamutDir, withIntermediateDirectories: true)
let bundle = Bundle.main.resourceURL ?? Bundle.main.bundleURL
try FileManager.default.copyItem(
at: bundle.appendingPathComponent("Argyll/reference_gamuts/sRGB.gam"),
to: gamutDir.appendingPathComponent("sRGB.gam"))
return root
}
private func makeViewModel(root: URL? = nil) throws -> GamutViewModel {
let env = try TestAppEnvironment.make(bundledArgyllRoot: root)
return GamutViewModel(environment: env.environment)
}
func testInitialLoadHasSRGBAndFacesStatus() async throws {
let vm = try makeViewModel()
await vm.awaitInitialLoad()
XCTAssertNotNil(vm.layer(id: GamutViewModel.srgbLayerID))
XCTAssertTrue(vm.status.contains("faces"), "status: \(vm.status)")
}
func testMissingCompareGamLeavesSRGBAndSetsNotice() async throws {
let vm = try makeViewModel()
await vm.awaitInitialLoad()
await vm.loadCompareGam(
url: URL(fileURLWithPath: "/nonexistent/compare.gam"))
XCTAssertNotNil(vm.layer(id: GamutViewModel.srgbLayerID))
XCTAssertNil(vm.layer(id: GamutViewModel.compareLayerID))
XCTAssertNotNil(vm.noticeText)
}
func testFailedIccgamutLeavesSRGBAndSetsNotice() async throws {
// bundledRootWithoutTools has no macos-universal/iccgamut.
let vm = try makeViewModel(root: bundledRootWithoutTools())
await vm.awaitInitialLoad()
XCTAssertNotNil(vm.layer(id: GamutViewModel.srgbLayerID))
let profile = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-gamut-icc-\(UUID().uuidString).icc")
try Data("MOCK_ICC".utf8).write(to: profile)
defer { try? FileManager.default.removeItem(at: profile) }
await vm.loadCompareProfile(url: profile)
XCTAssertNotNil(vm.layer(id: GamutViewModel.srgbLayerID))
XCTAssertNil(vm.layer(id: GamutViewModel.compareLayerID))
XCTAssertNotNil(vm.noticeText)
}
func testThirdProfileReplacesCompareSlot() async throws {
let vm = try makeViewModel()
await vm.awaitInitialLoad()
let bundle = Bundle.main.resourceURL ?? Bundle.main.bundleURL
let srgb = bundle.appendingPathComponent("Argyll/reference_gamuts/sRGB.gam")
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-gamut-cmp-\(UUID().uuidString)")
try FileManager.default.createDirectory(
at: dir, withIntermediateDirectories: true)
let first = dir.appendingPathComponent("first.gam")
let second = dir.appendingPathComponent("second.gam")
try FileManager.default.copyItem(at: srgb, to: first)
try FileManager.default.copyItem(at: srgb, to: second)
defer { try? FileManager.default.removeItem(at: dir) }
await vm.loadCompareGam(url: first)
XCTAssertNil(vm.noticeText)
XCTAssertEqual(vm.layer(id: GamutViewModel.compareLayerID)?.displayName, "first")
await vm.loadCompareGam(url: second)
XCTAssertEqual(vm.layer(id: GamutViewModel.compareLayerID)?.displayName, "second")
XCTAssertEqual(
vm.layers.filter { $0.role == .profileB }.count, 1,
"compare slot holds one profile")
XCTAssertNotNil(vm.noticeText)
XCTAssertNotNil(vm.layer(id: GamutViewModel.srgbLayerID))
}
func testRemoveCompareLeavesSRGB() async throws {
let vm = try makeViewModel()
await vm.awaitInitialLoad()
let bundle = Bundle.main.resourceURL ?? Bundle.main.bundleURL
await vm.loadCompareGam(
url: bundle.appendingPathComponent("Argyll/reference_gamuts/sRGB.gam"))
XCTAssertNotNil(vm.layer(id: GamutViewModel.compareLayerID))
vm.removeCompare()
XCTAssertNil(vm.layer(id: GamutViewModel.compareLayerID))
XCTAssertNotNil(vm.layer(id: GamutViewModel.srgbLayerID))
}
func testInspectLabRunsContainmentPerLayer() async throws {
let vm = try makeViewModel()
await vm.awaitInitialLoad()
vm.labEntryL = "50"
vm.labEntryA = "0"
vm.labEntryB = "0"
XCTAssertTrue(vm.canInspectLab)
vm.inspectEnteredLab()
let srgb = vm.inspectResults.first { $0.id == GamutViewModel.srgbLayerID }
XCTAssertEqual(srgb?.containment, .inside)
XCTAssertTrue(vm.inspectIsApproximate)
XCTAssertNotNil(vm.inspectSwatch)
}
}
@@ -1,16 +1,14 @@
import Foundation
import Testing
import XCTest
@testable import ICCeryCore
@Suite("IccgamutArgs")
struct IccgamutArgsTests {
final class IccgamutArgsTests: XCTestCase {
@Test("Density is 10 and not a directory")
func densityNotDirectory() throws {
func testDensityNotDirectory() throws {
let config = IccgamutConfig(
profileURL: URL(fileURLWithPath: "/tmp/MyProfile.icc")
)
let args = try IccgamutArgs.build(config: config)
#expect(args == ["-v", "-d", "10", "/tmp/MyProfile.icc"])
XCTAssertEqual(args, ["-v", "-d", "10", "/tmp/MyProfile.icc"])
}
}
@@ -0,0 +1,77 @@
import Foundation
import XCTest
@testable import ICCeryCore
final class JSONFileStoreTests: XCTestCase {
private func tempURL() -> URL {
FileManager.default.temporaryDirectory
.appendingPathComponent("json-store-\(UUID().uuidString).json")
}
func testMissingFileDefaults() throws {
let store = JSONFileStore<AppSettings>(
fileURL: tempURL(),
corrupt: .throwCorrupt,
defaultValue: { .default }
)
XCTAssertEqual(try store.load(), .default)
}
func testCorruptDefaults() throws {
let url = tempURL()
try "{ not json".write(to: url, atomically: true, encoding: .utf8)
let store = JSONFileStore<AppSettings>(
fileURL: url,
corrupt: .replaceWithDefault,
defaultValue: { .default }
)
XCTAssertEqual(try store.load(), .default)
let kept = try String(contentsOf: url, encoding: .utf8)
XCTAssertEqual(kept, "{ not json")
}
func testCorruptThrows() throws {
let url = tempURL()
try "not json".write(to: url, atomically: true, encoding: .utf8)
let store = JSONFileStore<[Int]>(
fileURL: url,
corrupt: .throwCorrupt,
defaultValue: { [] }
)
XCTAssertThrowsError(try store.load()) { error in XCTAssertTrue(error is DecodingError) }
let kept = try String(contentsOf: url, encoding: .utf8)
XCTAssertEqual(kept, "not json")
}
func testPrettySorted() throws {
let url = tempURL()
let store = JSONFileStore<AppSettings>(
fileURL: url,
corrupt: .replaceWithDefault,
defaultValue: { .default }
)
try store.save(.default)
let text = try String(contentsOf: url, encoding: .utf8)
XCTAssertTrue(text.contains("\n"))
XCTAssertTrue(text.contains("\"delta_e_good_max\""))
// Lexical key sorting: ascending order of top-level keys.
let keys = [
"ask_before_overwrite_profile",
"calibration_stale_days",
"custom_presets",
"default_install_location",
"delta_e_good_max",
"delta_e_warning_max",
"enable_i1pro2_leds",
"open_color_panel_after_install",
]
var lastIndex = text.startIndex
for key in keys {
guard let range = text.range(of: "\"\(key)\"", range: lastIndex..<text.endIndex) else {
XCTFail("missing or out-of-order key \(key)")
return
}
lastIndex = range.upperBound
}
}
}
+44 -57
View File
@@ -1,12 +1,11 @@
import Testing
import XCTest
import Foundation
@testable import ICCeryCore
/// Issue 15 `lp` argv goldens (docs/11 `build_lp_args`).
/// `-d`/`options`/`-t` handling is in `CupsService`; these tests cover
/// flag order, captured-option precedence, and sanitisation.
@Suite("LpArgs")
struct LpArgsTests {
final class LpArgsTests: XCTestCase {
private let tiff = "/tmp/work/target_001.tif"
private let queue = "EPSON_XP_55_Series"
@@ -20,112 +19,100 @@ struct LpArgsTests {
options: options, optionKeys: optionKeys)
}
@Test("Header: -d queue -t title, both AP_* first, TIFF last")
func header() throws {
func testHeader() throws {
let argv = try build()
#expect(Array(argv[0...1]) == ["-d", queue])
#expect(Array(argv[2...3]) == ["-t", "ICCery Target - target_001.tif"])
#expect(Array(argv[4...5])
== ["-o", "AP_ColorMatchingMode=AP_ApplicationColorMatching"])
#expect(Array(argv[6...7])
== ["-o", "AP.ColorMatchingMode=AP_ApplicationColorMatching"])
#expect(argv.last == tiff)
#expect(!argv.contains { $0 == "raw" || $0 == "-o raw" })
XCTAssertEqual(Array(argv[0...1]), ["-d", queue])
XCTAssertEqual(Array(argv[2...3]), ["-t", "ICCery Target - target_001.tif"])
XCTAssertEqual(Array(argv[4...5]), ["-o", "AP_ColorMatchingMode=AP_ApplicationColorMatching"])
XCTAssertEqual(Array(argv[6...7]), ["-o", "AP.ColorMatchingMode=AP_ApplicationColorMatching"])
XCTAssertEqual(argv.last, tiff)
XCTAssertFalse(argv.contains { $0 == "raw" || $0 == "-o raw" })
}
@Test("Never emits -o raw; captured raw= is dropped")
func neverRaw() throws {
func testNeverRaw() throws {
let argv = try build(options: PrintOptions(
cupsOptions: "raw=true MediaType=Photo"))
for (i, arg) in argv.enumerated() where arg == "-o" {
#expect(argv[i + 1] != "raw")
#expect(argv[i + 1] != "raw=true")
XCTAssertNotEqual(argv[i + 1], "raw")
XCTAssertNotEqual(argv[i + 1], "raw=true")
}
#expect(!argv.contains { $0.hasPrefix("raw=") })
#expect(argv.contains("MediaType=Photo"))
XCTAssertFalse(argv.contains { $0.hasPrefix("raw=") })
XCTAssertTrue(argv.contains("MediaType=Photo"))
}
@Test("Captured options replayed after AP_* headers")
func capturedReplay() throws {
func testCapturedReplay() throws {
let argv = try build(options: PrintOptions(
cupsOptions: "InputSlot=Rear MediaType=Photo"))
let rear = argv.firstIndex(of: "InputSlot=Rear")!
let apFirst = argv.firstIndex(of:
"AP_ColorMatchingMode=AP_ApplicationColorMatching")!
#expect(rear > apFirst)
XCTAssertTrue(rear > apFirst)
}
@Test("Captured wins: media key present → derived media skipped")
func capturedWinsMedia() throws {
func testCapturedWinsMedia() throws {
let argv = try build(
options: PrintOptions(
mediaType: "Plain",
cupsOptions: "MediaType=Glossy"),
optionKeys: ["MediaType"])
#expect(argv.contains("MediaType=Glossy"))
#expect(!argv.contains("MediaType=Plain"))
XCTAssertTrue(argv.contains("MediaType=Glossy"))
XCTAssertFalse(argv.contains("MediaType=Plain"))
}
@Test("Media emitted via detected key when not captured")
func mediaDerived() throws {
func testMediaDerived() throws {
let argv = try build(
options: PrintOptions(mediaType: "SemiGloss"),
optionKeys: ["CNIJMediaType", "MediaType"])
// CNIJMediaType wins over MediaType in detection order.
#expect(argv.contains("CNIJMediaType=SemiGloss"))
#expect(!argv.contains("MediaType=SemiGloss"))
XCTAssertTrue(argv.contains("CNIJMediaType=SemiGloss"))
XCTAssertFalse(argv.contains("MediaType=SemiGloss"))
}
@Test("Driver bypass emitted when absent, skipped when captured")
func bypassRules() throws {
func testBypassRules() throws {
let withBypass = try build(
optionKeys: ["EPIJ_CMat"])
#expect(withBypass.contains("EPIJ_CMat=3"))
XCTAssertTrue(withBypass.contains("EPIJ_CMat=3"))
let captured = try build(
options: PrintOptions(cupsOptions: "EPIJ_CMat=1"),
optionKeys: ["EPIJ_CMat"])
// Captured value kept, detection not re-applied.
#expect(captured.filter { $0.hasPrefix("EPIJ_CMat") }
== ["EPIJ_CMat=1"])
XCTAssertEqual(captured.filter { $0.hasPrefix("EPIJ_CMat") }, ["EPIJ_CMat=1"])
}
@Test("Orientation: portrait=3 landscape=4; captured wins")
func orientation() throws {
#expect(try build(options: PrintOptions(orientation: "portrait"))
func testOrientation() throws {
XCTAssertTrue(try build(options: PrintOptions(orientation: "portrait"))
.contains("orientation-requested=3"))
#expect(try build(options: PrintOptions(orientation: "landscape"))
XCTAssertTrue(try build(options: PrintOptions(orientation: "landscape"))
.contains("orientation-requested=4"))
let capturedOrients = try build(options: PrintOptions(
orientation: "landscape",
cupsOptions: "orientation-requested=5"))
#expect(!capturedOrients.contains("orientation-requested=4"))
#expect(capturedOrients.contains("orientation-requested=5"))
XCTAssertFalse(capturedOrients.contains("orientation-requested=4"))
XCTAssertTrue(capturedOrients.contains("orientation-requested=5"))
}
@Test("PageSize emitted unless captured")
func pageSize() throws {
#expect(try build(options: PrintOptions(paperSize: "A4"))
func testPageSize() throws {
XCTAssertTrue(try build(options: PrintOptions(paperSize: "A4"))
.contains("PageSize=A4"))
let capturedSize = try build(options: PrintOptions(
paperSize: "A4", cupsOptions: "PageSize=Letter"))
#expect(!capturedSize.contains("PageSize=A4"))
#expect(capturedSize.contains("PageSize=Letter"))
XCTAssertFalse(capturedSize.contains("PageSize=A4"))
XCTAssertTrue(capturedSize.contains("PageSize=Letter"))
}
@Test("Sanitise rejects `;`, newline, and shell metachars")
func sanitise() throws {
#expect(throws: LpArgsError.self) {
_ = try build(options: PrintOptions(
cupsOptions: "InputSlot=Rear;rm -rf /"))
func testSanitise() throws {
XCTAssertThrowsError(try build(options: PrintOptions(
cupsOptions: "InputSlot=Rear;rm -rf /"))) { error in
XCTAssertTrue(error is LpArgsError)
}
#expect(throws: LpArgsError.self) {
_ = try build(options: PrintOptions(
cupsOptions: "InputSlot=Rear\nMediaType=Photo"))
XCTAssertThrowsError(try build(options: PrintOptions(
cupsOptions: "InputSlot=Rear\nMediaType=Photo"))) { error in
XCTAssertTrue(error is LpArgsError)
}
#expect(throws: LpArgsError.self) {
_ = try build(options: PrintOptions(
cupsOptions: "InputSlot=$(whoami)"))
XCTAssertThrowsError(try build(options: PrintOptions(
cupsOptions: "InputSlot=$(whoami)"))) { error in
XCTAssertTrue(error is LpArgsError)
}
}
}
+123 -113
View File
@@ -1,12 +1,10 @@
import Foundation
import Testing
import XCTest
@testable import ICCeryCore
@Suite("InstrumentParser")
struct InstrumentParserTests {
final class InstrumentParserTests: XCTestCase {
@Test("Parses pretty-printed instlist JSON")
func json() throws {
func testJson() throws {
let json = """
{
"event": "instruments",
@@ -18,134 +16,118 @@ struct InstrumentParserTests {
}
"""
let devices = try InstrumentParser.parse(json)
#expect(devices.count == 3)
#expect(devices[0].port == 1)
#expect(devices[0].name == "X-Rite i1Pro")
#expect(devices[2].port == 3)
XCTAssertEqual(devices.count, 3)
XCTAssertEqual(devices[0].port, 1)
XCTAssertEqual(devices[0].name, "X-Rite i1Pro")
XCTAssertEqual(devices[2].port, 3)
}
@Test("Falls back to regex for legacy instlist text")
func regexFallback() throws {
func testRegexFallback() throws {
let text = """
1: 'X-Rite i1Pro' on usb
2: 'ColorMunki Smile'
""" + "\n"
let devices = try InstrumentParser.parse(text)
#expect(devices.count == 2)
#expect(devices[0].port == 1)
#expect(devices[1].name == "ColorMunki Smile")
XCTAssertEqual(devices.count, 2)
XCTAssertEqual(devices[0].port, 1)
XCTAssertEqual(devices[1].name, "ColorMunki Smile")
}
@Test("Empty output returns no devices")
func empty() throws {
#expect(try InstrumentParser.parse("").isEmpty)
func testEmpty() throws {
XCTAssertTrue(try InstrumentParser.parse("").isEmpty)
}
}
@Suite("ChartreadArgs")
struct ChartreadArgsTests {
final class ChartreadArgsTests: XCTestCase {
@Test("Baseline argv and port 1 omits -c")
func baseline() throws {
func testBaseline() throws {
let config = ChartreadConfig(basename: "target", selectedPort: 1)
let args = try ChartreadArgs.build(config: config)
#expect(args == ["-v", "-u", "target"])
XCTAssertEqual(args, ["-v", "-u", "target"])
}
@Test("Port > 1 emits -c")
func portArgument() throws {
func testPortArgument() throws {
let config = ChartreadConfig(basename: "target", selectedPort: 3)
let args = try ChartreadArgs.build(config: config)
#expect(args == ["-v", "-u", "-c", "3", "target"])
XCTAssertEqual(args, ["-v", "-u", "-c", "3", "target"])
}
@Test("LEDs emit -Y l")
func leds() throws {
func testLeds() throws {
let config = ChartreadConfig(
basename: "target",
selectedPort: 2,
enableLEDs: true
)
let args = try ChartreadArgs.build(config: config)
#expect(args.contains("-Y"))
#expect(args.contains("l"))
XCTAssertTrue(args.contains("-Y"))
XCTAssertTrue(args.contains("l"))
}
@Test("Auto omits -c")
func autoPort() throws {
func testAutoPort() throws {
let config = ChartreadConfig(basename: "target")
let args = try ChartreadArgs.build(config: config)
#expect(!args.contains("-c"))
XCTAssertFalse(args.contains("-c"))
}
}
@Suite("ChartreadClassifier")
struct ChartreadClassifierTests {
final class ChartreadClassifierTests: XCTestCase {
@Test("Calibration prompt")
func calibration() {
func testCalibration() {
let r = ChartreadClassifier.classify(
line: "Place instrument on calibration tile and hit [Space] to calibrate.",
previousState: .idle
)
#expect(r.state == .calibrating)
XCTAssertEqual(r.state, .calibrating)
}
@Test("Strip awaiting")
func awaitingStrip() {
func testAwaitingStrip() {
let r = ChartreadClassifier.classify(
line: "Hit [Space] to read strip A",
previousState: .calibrating
)
#expect(r.state == .awaitingStrip)
XCTAssertEqual(r.state, .awaitingStrip)
}
@Test("Done prompt")
func done() {
func testDone() {
let r = ChartreadClassifier.classify(
line: "'d' if/when done",
previousState: .awaitingStrip
)
#expect(r.state == .allStripsRead)
XCTAssertEqual(r.state, .allStripsRead)
}
@Test("XY place sheet")
func placeSheet() {
func testPlaceSheet() {
let r = ChartreadClassifier.classify(
line: "Please place sheet 1 of 2 on the table",
previousState: .idle
)
#expect(r.state == .tablePlaceSheet)
#expect(r.sheetNumber == 1)
#expect(r.sheetTotal == 2)
XCTAssertEqual(r.state, .tablePlaceSheet)
XCTAssertEqual(r.sheetNumber, 1)
XCTAssertEqual(r.sheetTotal, 2)
}
@Test("XY locate patch")
func locatePatch() {
func testLocatePatch() {
let r = ChartreadClassifier.classify(
line: "locate patch A1 with the sight,",
previousState: .tablePlaceSheet
)
#expect(r.state == .tableAlign)
#expect(r.alignmentPatch == "A1")
XCTAssertEqual(r.state, .tableAlign)
XCTAssertEqual(r.alignmentPatch, "A1")
}
@Test("Remove sheet notice preserves state")
func removeNotice() {
func testRemoveNotice() {
let r = ChartreadClassifier.classify(
line: "Please remove last sheet from table",
previousState: .tablePlaceSheet
)
#expect(r.state == .tablePlaceSheet)
#expect(r.isRemoveSheetNotice == true)
XCTAssertEqual(r.state, .tablePlaceSheet)
XCTAssertEqual(r.isRemoveSheetNotice, true)
}
}
@Suite("ChartreadRow")
struct ChartreadRowTests {
final class ChartreadRowTests: XCTestCase {
@Test("Decodes row JSON")
func decode() throws {
func testDecode() throws {
let json = """
{"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2,
"patch_count": 1, "patches": [
@@ -155,59 +137,93 @@ struct ChartreadRowTests {
]}
"""
let row = try JSONDecoder().decode(ChartreadRow.self, from: Data(json.utf8))
#expect(row.rowId == "A")
#expect(row.patchCount == 1)
#expect(row.patches[0].measured.lab?.l == 51)
XCTAssertEqual(row.rowId, "A")
XCTAssertEqual(row.patchCount, 1)
XCTAssertEqual(row.patches[0].measured.lab?.l, 51)
}
func testDecodeXYZAndLab() throws {
let json = """
{"event": "row_complete", "row_id": "B", "row_index": 1, "total_rows": 2,
"patch_count": 1, "patches": [
{"id": "7", "loc": "B7", "is_pad": false, "device": [10, 20, 30, 40],
"measured": {"XYZ": [30.5, 32.1, 25.9], "Lab": [63.4, 2.5, -8.2]}}
]}
"""
let row = try JSONDecoder().decode(ChartreadRow.self, from: Data(json.utf8))
let measured = row.patches[0].measured
XCTAssertEqual(measured.xyz, CIEXYZ(x: 30.5, y: 32.1, z: 25.9))
XCTAssertEqual(measured.lab, CIELab(l: 63.4, a: 2.5, b: -8.2))
}
func testXyzWireEncoding() throws {
for color in [XYZColor(x: 1.5, y: 2.5, z: 3.5), CIEXYZ(x: 1.5, y: 2.5, z: 3.5)] {
let value = try JSONSerialization.jsonObject(
with: JSONEncoder().encode(color))
XCTAssertEqual(value as? [Double], [1.5, 2.5, 3.5])
}
}
func testLabWireEncoding() throws {
for color in [LabColor(l: 50, a: -1, b: 2), CIELab(l: 50, a: -1, b: 2)] {
let value = try JSONSerialization.jsonObject(
with: JSONEncoder().encode(color))
XCTAssertEqual(value as? [Double], [50, -1, 2])
}
}
func testPatchColorKeys() throws {
let color = PatchColor(
xyz: CIEXYZ(x: 10, y: 20, z: 30),
lab: CIELab(l: 55, a: 1, b: -2))
let object = try JSONSerialization.jsonObject(
with: JSONEncoder().encode(color)) as? [String: Any]
XCTAssertEqual(object?["XYZ"] as? [Double], [10, 20, 30])
XCTAssertEqual(object?["Lab"] as? [Double], [55, 1, -2])
XCTAssertNil(object?["spectral"])
}
}
@Suite("ColourMath")
struct ColourMathTests {
final class ColourMathTests: XCTestCase {
@Test("White XYZ to Lab")
func whiteLab() {
func testWhiteLab() {
let white = XYZColor(x: 96.4212, y: 100.0, z: 82.5188)
let lab = LabColorMath.xyzToLab(white)
#expect(abs(lab.l - 100) < 0.5)
#expect(abs(lab.a) < 0.5)
#expect(abs(lab.b) < 0.5)
XCTAssertTrue(abs(lab.l - 100) < 0.5)
XCTAssertTrue(abs(lab.a) < 0.5)
XCTAssertTrue(abs(lab.b) < 0.5)
}
@Test("Lab to sRGB roundtrip is clamped")
func labToSRGB() {
func testLabToSRGB() {
let red = LabColor(l: 55, a: 80, b: 70)
let rgb = LabColorMath.labToSRGB(red)
#expect(rgb.r > 0.8)
#expect(rgb.g < 0.2)
#expect(rgb.b < 0.2)
XCTAssertTrue(rgb.r > 0.8)
XCTAssertTrue(rgb.g < 0.2)
XCTAssertTrue(rgb.b < 0.2)
}
@Test("Pad white returns DisplayRGB")
func padWhite() {
func testPadWhite() {
let white = LabColor(l: 95, a: 0, b: 0)
let rgb = LabColorMath.labToSRGB(white)
#expect(rgb.r > 0.9)
#expect(rgb.g > 0.9)
#expect(rgb.b > 0.9)
XCTAssertTrue(rgb.r > 0.9)
XCTAssertTrue(rgb.g > 0.9)
XCTAssertTrue(rgb.b > 0.9)
}
@Test("Standard CIEDE2000 vector (Sharma)")
func ciede2000() {
func testCiede2000() {
let a = LabColor(l: 50, a: -1.3802, b: -84.2814)
let b = LabColor(l: 50, a: 0.0000, b: -82.7485)
#expect(abs(ColorDifference.deltaE00(a, b) - 1.00) < 0.001)
XCTAssertTrue(abs(ColorDifference.deltaE00(a, b) - 1.00) < 0.001)
}
@Test("Classification respects thresholds")
func classify() {
#expect(ColorDifference.classify(deltaE: 0.5, goodMax: 2.0, warningMax: 5.0) == .good)
#expect(ColorDifference.classify(deltaE: 3.0, goodMax: 2.0, warningMax: 5.0) == .warning)
#expect(ColorDifference.classify(deltaE: 6.0, goodMax: 2.0, warningMax: 5.0) == .bad)
func testClassify() {
XCTAssertEqual(ColorDifference.classify(deltaE: 0.5, goodMax: 2.0, warningMax: 5.0), .good)
XCTAssertEqual(ColorDifference.classify(deltaE: 3.0, goodMax: 2.0, warningMax: 5.0), .warning)
XCTAssertEqual(ColorDifference.classify(deltaE: 6.0, goodMax: 2.0, warningMax: 5.0), .bad)
}
}
@Suite("MeasurementArtefacts")
struct MeasurementArtefactTests {
final class MeasurementArtefactTests: XCTestCase {
private func makeCwd() throws -> URL {
let url = FileManager.default.temporaryDirectory
@@ -216,8 +232,7 @@ struct MeasurementArtefactTests {
return url
}
@Test("Discovers passes in order")
func discovery() throws {
func testDiscovery() throws {
let cwd = try makeCwd()
defer { try? FileManager.default.removeItem(at: cwd) }
@@ -226,11 +241,10 @@ struct MeasurementArtefactTests {
try "C".write(to: cwd.appendingPathComponent("target_pass10.ti3"), atomically: true, encoding: .utf8)
let passes = MeasurementArtefacts.passSnapshots(basename: "target", cwd: cwd)
#expect(passes.map(\.lastPathComponent) == ["target_pass1.ti3", "target_pass3.ti3", "target_pass10.ti3"])
XCTAssertEqual(passes.map(\.lastPathComponent), ["target_pass1.ti3", "target_pass3.ti3", "target_pass10.ti3"])
}
@Test("Snapshot and promote are atomic")
func snapshotPromote() throws {
func testSnapshotPromote() throws {
let cwd = try makeCwd()
defer { try? FileManager.default.removeItem(at: cwd) }
@@ -238,16 +252,15 @@ struct MeasurementArtefactTests {
try "canonical".write(to: canonical, atomically: true, encoding: .utf8)
let pass = try MeasurementArtefacts.snapshotPass(basename: "target", cwd: cwd)
#expect(pass.lastPathComponent == "target_pass1.ti3")
#expect(!FileManager.default.fileExists(atPath: canonical.path))
XCTAssertEqual(pass.lastPathComponent, "target_pass1.ti3")
XCTAssertFalse(FileManager.default.fileExists(atPath: canonical.path))
let promoted = try MeasurementArtefacts.promotePass(pass: pass, basename: "target", cwd: cwd)
#expect(promoted.lastPathComponent == "target.ti3")
#expect(FileManager.default.fileExists(atPath: promoted.path))
XCTAssertEqual(promoted.lastPathComponent, "target.ti3")
XCTAssertTrue(FileManager.default.fileExists(atPath: promoted.path))
}
@Test("Pass collisions handled")
func collision() throws {
func testCollision() throws {
let cwd = try makeCwd()
defer { try? FileManager.default.removeItem(at: cwd) }
@@ -257,28 +270,25 @@ struct MeasurementArtefactTests {
try "v2".write(to: canonical, atomically: true, encoding: .utf8)
let pass2 = try MeasurementArtefacts.snapshotPass(basename: "target", cwd: cwd)
#expect(pass2.lastPathComponent == "target_pass2.ti3")
XCTAssertEqual(pass2.lastPathComponent, "target_pass2.ti3")
}
}
@Suite("AverageArgs")
struct AverageArgsTests {
final class AverageArgsTests: XCTestCase {
@Test("Requires at least two pass files")
func passCount() {
func testPassCount() {
let cwd = URL(fileURLWithPath: "/tmp")
let config = AverageConfig(
workingDirectory: cwd,
basename: "target",
passFiles: [URL(fileURLWithPath: "target_pass1.ti3")]
)
#expect(throws: AverageArgError.self) {
_ = try AverageArgs.build(config: config)
XCTAssertThrowsError(try AverageArgs.build(config: config)) { error in
XCTAssertTrue(error is AverageArgError)
}
}
@Test("Output is last and inputs are relative")
func ordering() throws {
func testOrdering() throws {
let cwd = URL(fileURLWithPath: "/tmp")
let config = AverageConfig(
workingDirectory: cwd,
@@ -289,8 +299,8 @@ struct AverageArgsTests {
]
)
let args = try AverageArgs.build(config: config)
#expect(args.first == "-v")
#expect(args.last == "target.ti3")
#expect(args == ["-v", "target_pass1.ti3", "target_pass2.ti3", "target.ti3"])
XCTAssertEqual(args.first, "-v")
XCTAssertEqual(args.last, "target.ti3")
XCTAssertEqual(args, ["-v", "target_pass1.ti3", "target_pass2.ti3", "target.ti3"])
}
}
@@ -0,0 +1,96 @@
import Foundation
import XCTest
@testable import ICCeryCore
/// Issue #146 `MediaLibraryStore` persistence contract.
final class MediaLibraryStoreTests: XCTestCase {
private var url: URL!
override func setUp() {
url = FileManager.default.temporaryDirectory
.appendingPathComponent("media-lib-\(UUID().uuidString).json")
}
override func tearDown() {
try? FileManager.default.removeItem(at: url)
url = nil
}
private func recipe(id: String, name: String = "R") -> MediaRecipe {
MediaRecipe(
id: id, name: name, printerID: "q",
colourSpace: "rgb", presetID: "preset-std-rgb")
}
func testRoundTrip() async throws {
let store = MediaLibraryStore(url: url)
let r = recipe(id: "recipe-1", name: "Epson Rag")
try await store.upsert(r)
let loaded = try await store.load()
XCTAssertEqual(loaded, [r])
}
func testCorruptFileThrowsAndPreservesBytes() async throws {
try "not json".write(to: url, atomically: true, encoding: .utf8)
let before = try Data(contentsOf: url)
let store = MediaLibraryStore(url: url)
await assertAsyncThrows(expectedType: DecodingError.self) {
try await store.load()
}
// upsert must also propagate a corrupt file is never wiped.
await assertAsyncThrows(expectedType: DecodingError.self) {
try await store.upsert(recipe(id: "x"))
}
XCTAssertEqual(try Data(contentsOf: url), before)
}
func testDelete() async throws {
let store = MediaLibraryStore(url: url)
try await store.upsert(recipe(id: "a"))
try await store.upsert(recipe(id: "b"))
let removed = try await store.delete(id: "a")
XCTAssertTrue(removed)
let remaining = try await store.load().map(\.id)
XCTAssertEqual(remaining, ["b"])
let again = try await store.delete(id: "a")
XCTAssertFalse(again)
}
func testCapacityReached() async throws {
let store = MediaLibraryStore(url: url, capacity: 2)
try await store.upsert(recipe(id: "1"))
try await store.upsert(recipe(id: "2"))
await assertAsyncThrows(
expectedType: MediaLibraryStore.MediaLibraryError.self
) {
try await store.upsert(recipe(id: "3"))
} errorHandler: {
XCTAssertEqual($0, .capacityReached(2))
}
let stored = try await store.load()
XCTAssertEqual(stored.count, 2)
}
func testUpsertPreservesCreatedBumpsUpdated() async throws {
let store = MediaLibraryStore(url: url)
var r = recipe(id: "recipe-1")
r.created = Date(timeIntervalSince1970: 1_000_000)
r.updated = Date(timeIntervalSince1970: 1_000_000)
try await store.upsert(r)
var edit = r
edit.name = "Renamed"
edit.updated = Date(timeIntervalSince1970: 2_000_000)
try await store.upsert(edit)
let loaded = try await store.load()
XCTAssertEqual(loaded.count, 1)
XCTAssertEqual(loaded[0].name, "Renamed")
XCTAssertEqual(loaded[0].created, r.created)
XCTAssertGreaterThan(loaded[0].updated, r.updated)
}
}
@@ -0,0 +1,406 @@
import Foundation
import XCTest
@testable import ICCeryCore
@testable import ICCery
/// Issue #146 `MediaLibraryViewModel` apply / capture / staleness
/// under an isolated `TestAppEnvironment` with a mock CUPS `bin` dir.
@MainActor
final class MediaLibraryViewModelTests: XCTestCase {
// CTI3 fixture mirrored from CalibrationStoreTests.
private static let sampleCal = """
CTI3
DESCRIPTOR "Test printer"
COLOR_REP "RGB"
DEVICE_CLASS "OUTPUT"
MAX_TAC "300"
NUMBER_OF_FIELDS 5
NUMBER_OF_SETS 3
BEGIN_DATA_FORMAT
SAMPLE_ID INPUT_VALUE R G B
END_DATA_FORMAT
BEGIN_DATA
1 0 0 0 0
2 128 64 64 64
3 255 255 255 255
END_DATA
"""
/// Same fixture plus an old CREATED keyword so the age check fires.
private static let staleCal = """
CTI3
DESCRIPTOR "Test printer"
CREATED "2020-01-01T00:00:00Z"
COLOR_REP "RGB"
DEVICE_CLASS "OUTPUT"
NUMBER_OF_FIELDS 5
NUMBER_OF_SETS 3
BEGIN_DATA_FORMAT
SAMPLE_ID INPUT_VALUE R G B
END_DATA_FORMAT
BEGIN_DATA
1 0 0 0 0
2 128 64 64 64
3 255 255 255 255
END_DATA
"""
private var env: TestAppEnvironment!
private var workflow: TargetWorkflowViewModel!
private var media: MediaLibraryViewModel!
override func setUp() async throws {
env = try TestAppEnvironment.make()
try installMockCups()
workflow = TargetWorkflowViewModel(environment: env.environment)
media = workflow.media
await media.reloadAsync()
}
override func tearDown() async throws {
env?.cleanup()
env = nil
workflow = nil
media = nil
}
/// Mock `lpstat`/`lpoptions` inside the env's `cups-bin` (the
/// `CupsService.binaryDir` `TestAppEnvironment` points at). Queues:
/// `Mock_Queue` (default) and `Other_Queue`.
private func installMockCups() throws {
let bin = env.root.appendingPathComponent("cups-bin")
try FileManager.default.createDirectory(
at: bin, withIntermediateDirectories: true)
let lpstat = """
#!/bin/sh
case "$1" in
-e) printf 'Mock_Queue\\nOther_Queue\\n' ;;
-p) printf 'printer Mock_Queue is idle.\\nprinter Other_Queue is idle.\\n' ;;
-d) printf 'system default destination: Mock_Queue\\n' ;;
esac
exit 0
"""
let lpoptions = """
#!/bin/sh
list=0
queue=""
for arg in "$@"; do
case "$arg" in
-l) list=1 ;;
-p) ;;
*) queue="$arg" ;;
esac
done
if [ "$list" = "1" ]; then
printf 'PageSize/Media Size: *A4 Letter\\n'
printf 'MediaType/Media Type: *Stationery Glossy\\n'
exit 0
fi
printf "printer-info='Mock %s' printer-type=42\\n" "$queue"
exit 0
"""
for (name, body) in ["lpstat": lpstat, "lpoptions": lpoptions] {
let url = bin.appendingPathComponent(name)
try body.write(to: url, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes(
[.posixPermissions: 0o755], ofItemAtPath: url.path)
}
}
private func writeCal(
_ contents: String = MediaLibraryViewModelTests.sampleCal,
named name: String = "recipe.cal"
) throws -> String {
let url = env.root.appendingPathComponent(name)
try contents.write(to: url, atomically: true, encoding: .utf8)
return url.path
}
private func makeRecipe(
id: String = "recipe-t1",
printerID: String = "Mock_Queue",
colourSpace: String = "rgb",
presetID: String = "preset-std-rgb",
calibrationURL: String? = nil,
applyCalibration: Bool = false
) -> MediaRecipe {
MediaRecipe(
id: id,
name: "Test Recipe",
printerID: printerID,
printerDisplayName: "Mock Queue Display",
paperName: "Rag",
inkSet: "PK",
colourSpace: colourSpace,
presetID: presetID,
calibrationURL: calibrationURL,
applyCalibration: applyCalibration)
}
private func seed(_ recipe: MediaRecipe) async throws {
try await env.environment.mediaStore.upsert(recipe)
await media.reloadAsync()
}
// MARK: - Apply
func testApplyHappyPath() async throws {
let calPath = try writeCal()
let r = makeRecipe(
calibrationURL: calPath, applyCalibration: true)
try await seed(r)
let applied = await media.apply(r)
XCTAssertTrue(applied)
XCTAssertEqual(workflow.print.selectedPrinter, "Mock_Queue")
XCTAssertEqual(workflow.wizard.printerName, "Mock Queue Display")
XCTAssertTrue(workflow.profile.applyCalibration)
XCTAssertEqual(workflow.profile.calibrationFile, calPath)
XCTAssertEqual(media.selectedRecipeID, r.id)
XCTAssertEqual(workflow.selectedPresetID, "preset-std-rgb")
XCTAssertEqual(
workflow.buildPrinttargConfig().calibrationFile, calPath)
}
func testApplyMissingPresetRefuses() async throws {
let r = makeRecipe(presetID: "preset-nonexistent")
try await seed(r)
let applied = await media.apply(r)
XCTAssertFalse(applied)
XCTAssertEqual(media.selectedRecipeID, "none")
XCTAssertEqual(workflow.selectedPresetID, "none")
XCTAssertTrue(
workflow.wizard.notice?.text.contains("no longer exists") == true)
}
func testApplyColourSpaceMismatchRefuses() async throws {
let r = makeRecipe(colourSpace: "cmyk", presetID: "preset-std-rgb")
try await seed(r)
let applied = await media.apply(r)
XCTAssertFalse(applied)
XCTAssertEqual(media.selectedRecipeID, "none")
XCTAssertTrue(
workflow.wizard.notice?.text.contains("colour space") == true)
}
func testApplyMissingCalFileFails() async throws {
let missing = env.root.appendingPathComponent("gone.cal").path
let r = makeRecipe(
calibrationURL: missing, applyCalibration: true)
try await seed(r)
let applied = await media.apply(r)
XCTAssertFalse(applied)
XCTAssertFalse(workflow.profile.applyCalibration)
XCTAssertEqual(workflow.profile.calibrationFile, missing)
XCTAssertEqual(workflow.wizard.notice?.kind, .error)
XCTAssertEqual(media.selectedRecipeID, "none")
}
func testApplyCalPrefixedCalCannotArmK() async throws {
let calPath = try writeCal(named: "CAL_target.cal")
let r = makeRecipe(
calibrationURL: calPath, applyCalibration: true)
try await seed(r)
let applied = await media.apply(r)
// Success with warning the refusal is permanent, re-clicking
// cannot unstick it (decision 6).
XCTAssertTrue(applied)
XCTAssertFalse(workflow.profile.applyCalibration)
XCTAssertEqual(workflow.profile.calibrationFile, calPath)
XCTAssertNil(workflow.buildPrinttargConfig().calibrationFile)
XCTAssertEqual(media.selectedRecipeID, r.id)
XCTAssertEqual(workflow.wizard.notice?.kind, .warning)
}
func testApplyLiveCalBasenameBlocks() async throws {
let calPath = try writeCal()
let r = makeRecipe(
calibrationURL: calPath, applyCalibration: true)
try await seed(r)
workflow.wizard.basename = "CAL_live"
let applied = await media.apply(r)
XCTAssertTrue(applied)
XCTAssertFalse(workflow.profile.applyCalibration)
XCTAssertNil(workflow.buildPrinttargConfig().calibrationFile)
}
func testApplyMissingQueueLeavesQueueUntouched() async throws {
workflow.print.selectedPrinter = "Other_Queue"
let r = makeRecipe(printerID: "No_Such_Queue")
try await seed(r)
let applied = await media.apply(r)
XCTAssertFalse(applied)
XCTAssertEqual(workflow.print.selectedPrinter, "Other_Queue")
XCTAssertTrue(
workflow.wizard.notice?.text.contains("is not installed") == true)
XCTAssertEqual(media.selectedRecipeID, "none")
}
// MARK: - Capture
func testCaptureCopiesPrinterAndPreset() async throws {
workflow.print.printers = [
Printer(name: "Mock_Queue", displayName: "Mock Queue Display")
]
workflow.print.selectedPrinter = "Mock_Queue"
workflow.selectedPresetID = "preset-std-rgb"
media.saveMediaName = "My Recipe"
media.saveMediaPaper = "Rag"
media.saveMediaInk = "PK"
let saved = await media.captureFromSession()
XCTAssertTrue(saved)
let stored = await env.environment.mediaStore.all()
XCTAssertEqual(stored.count, 1)
XCTAssertEqual(stored[0].printerID, "Mock_Queue")
XCTAssertEqual(stored[0].printerDisplayName, "Mock Queue Display")
XCTAssertEqual(stored[0].presetID, "preset-std-rgb")
XCTAssertEqual(stored[0].colourSpace, "rgb")
XCTAssertEqual(media.selectedRecipeID, stored[0].id)
}
func testCaptureNoPresetAutoSnapshots() async throws {
workflow.print.printers = [
Printer(name: "Mock_Queue", displayName: "Mock Queue Display")
]
workflow.print.selectedPrinter = "Mock_Queue"
workflow.selectedPresetID = "none"
media.saveMediaName = "Snap"
media.saveMediaPaper = "Rag"
media.saveMediaInk = "MK"
let saved = await media.captureFromSession()
XCTAssertTrue(saved)
let stored = await env.environment.mediaStore.all()
XCTAssertEqual(stored.count, 1)
XCTAssertTrue(stored[0].presetID.hasPrefix("custom-"))
XCTAssertTrue(
env.environment.presetStore.customs()
.contains { $0.id == stored[0].presetID })
XCTAssertEqual(workflow.selectedPresetID, stored[0].presetID)
}
func testCaptureRequiresPrinter() async {
workflow.print.selectedPrinter = ""
media.saveMediaName = "n"
media.saveMediaPaper = "p"
media.saveMediaInk = "i"
let saved = await media.captureFromSession()
XCTAssertFalse(saved)
XCTAssertNotNil(media.saveMediaError)
}
func testCaptureForcesOffCalToggleForCalFile() async throws {
let calPath = try writeCal(named: "CAL_target.cal")
workflow.profile.calibrationFile = calPath
workflow.profile.applyCalibration = true
workflow.print.printers = [Printer(name: "Mock_Queue")]
workflow.print.selectedPrinter = "Mock_Queue"
workflow.selectedPresetID = "preset-std-rgb"
media.saveMediaName = "n"
media.saveMediaPaper = "p"
media.saveMediaInk = "i"
media.saveMediaApplyCal = true // forced off by calApplyable
XCTAssertFalse(media.calApplyable)
let saved = await media.captureFromSession()
XCTAssertTrue(saved)
let stored = await env.environment.mediaStore.all()
XCTAssertEqual(stored[0].calibrationURL, calPath) // verbatim
XCTAssertFalse(stored[0].applyCalibration)
}
// MARK: - Staleness
func testStaleCalFlagged() async throws {
let calPath = try writeCal(
MediaLibraryViewModelTests.staleCal, named: "old.cal")
let r = makeRecipe(calibrationURL: calPath)
try await seed(r)
workflow.print.printers = [Printer(name: "Mock_Queue")]
await media.refreshStalenessAsync()
XCTAssertTrue(
media.staleReasons[r.id]?.contains(.calibration) == true)
XCTAssertNotNil(media.calAgeDays[r.id])
}
func testAbsentQueueFlaggedOnlyWhenListNonEmpty() async throws {
let r = makeRecipe(printerID: "No_Such_Queue")
try await seed(r)
// Un-enumerated list is indeterminate no flag.
workflow.print.printers = []
await media.refreshStalenessAsync()
XCTAssertNil(media.staleReasons[r.id])
// Absent from a non-empty list .printer.
workflow.print.printers = [Printer(name: "Other_Queue")]
await media.refreshStalenessAsync()
XCTAssertTrue(
media.staleReasons[r.id]?.contains(.printer) == true)
// Present but unselected no flag.
workflow.print.printers = [
Printer(name: "Other_Queue"), Printer(name: "No_Such_Queue"),
]
workflow.print.selectedPrinter = "Other_Queue"
await media.refreshStalenessAsync()
XCTAssertNil(media.staleReasons[r.id])
}
// MARK: - Delete
func testDeleteResetsSelection() async throws {
let r = makeRecipe()
try await seed(r)
media.selectedRecipeID = r.id
media.delete(r)
try await Task.sleep(nanoseconds: 200_000_000)
XCTAssertEqual(media.selectedRecipeID, "none")
let stored = await env.environment.mediaStore.all()
XCTAssertTrue(stored.isEmpty)
}
// MARK: - Corrupt library
func testCorruptLibraryKeepsFileAndWarns() async throws {
// Fresh environment so the store's `loaded` flag is still false.
let env2 = try TestAppEnvironment.make()
defer { env2.cleanup() }
try "garbage".write(
to: env2.mediaLibraryURL, atomically: true, encoding: .utf8)
let workflow2 = TargetWorkflowViewModel(
environment: env2.environment)
await workflow2.media.reloadAsync()
XCTAssertTrue(workflow2.media.recipes.isEmpty)
XCTAssertEqual(workflow2.media.selectedRecipeID, "none")
XCTAssertEqual(workflow2.wizard.notice?.kind, .warning)
XCTAssertEqual(try Data(contentsOf: env2.mediaLibraryURL),
"garbage".data(using: .utf8))
}
}
@@ -0,0 +1,126 @@
import Foundation
import XCTest
@testable import ICCeryCore
/// Issue #146 `MediaRecipe` Codable + validation contract.
final class MediaRecipeTests: XCTestCase {
private func makeRecipe() -> MediaRecipe {
MediaRecipe(
id: "recipe-abc",
name: "Epson Rag",
notes: "notes",
printerID: "epson_p900",
printerDisplayName: "Epson SureColor P900",
paperName: "Rag Photographique",
driverMediaType: "PhotographicGlossy",
inkSet: "PK",
colourSpace: "rgb",
presetID: "preset-std-rgb",
calibrationURL: "/tmp/prof.cal",
applyCalibration: true,
created: Date(timeIntervalSince1970: 1_700_000_000),
updated: Date(timeIntervalSince1970: 1_700_000_100)
)
}
func testRoundTripSnakeCase() throws {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let data = try encoder.encode(makeRecipe())
let object = try JSONSerialization.jsonObject(with: data) as! [String: Any]
for key in [
"printer_id", "printer_display_name", "paper_name",
"driver_media_type", "ink_set", "colour_space", "preset_id",
"calibration_url", "apply_calibration", "created", "updated",
"id", "name", "notes",
] {
XCTAssertNotNil(object[key], "missing key \(key)")
}
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let decoded = try decoder.decode(MediaRecipe.self, from: data)
XCTAssertEqual(decoded, makeRecipe())
}
func testMissingRequiredKeyThrows() {
for key in ["id", "name", "printer_id", "colour_space", "preset_id"] {
var dict: [String: Any] = [
"id": "r1", "name": "n", "printer_id": "q",
"colour_space": "rgb", "preset_id": "p",
]
dict.removeValue(forKey: key)
let data = try! JSONSerialization.data(withJSONObject: dict)
XCTAssertThrowsError(
try JSONDecoder().decode(MediaRecipe.self, from: data),
"expected throw without \(key)")
}
}
func testUnknownKeysIgnored() throws {
let dict: [String: Any] = [
"id": "r1", "name": "n", "printer_id": "q",
"colour_space": "rgb", "preset_id": "p",
"future_field": "ignored",
]
let data = try JSONSerialization.data(withJSONObject: dict)
let recipe = try JSONDecoder().decode(MediaRecipe.self, from: data)
XCTAssertEqual(recipe.id, "r1")
XCTAssertEqual(recipe.notes, "")
XCTAssertFalse(recipe.applyCalibration)
}
func testValidatedGoldens() {
var r = makeRecipe()
r.name = " "
XCTAssertThrowsError(try r.validated()) {
XCTAssertEqual($0 as? MediaRecipe.ValidationError, .emptyName)
}
r = makeRecipe()
r.printerID = ""
XCTAssertThrowsError(try r.validated()) {
XCTAssertEqual($0 as? MediaRecipe.ValidationError, .emptyPrinterID)
}
r = makeRecipe()
r.presetID = ""
XCTAssertThrowsError(try r.validated()) {
XCTAssertEqual($0 as? MediaRecipe.ValidationError, .emptyPresetID)
}
r = makeRecipe()
r.colourSpace = "lab"
XCTAssertThrowsError(try r.validated()) {
XCTAssertEqual(
$0 as? MediaRecipe.ValidationError, .invalidColourSpace("lab"))
}
for bad in ["../evil.cal", "rel/path.cal", "/tmp/a\0b.cal"] {
r = makeRecipe()
r.calibrationURL = bad
XCTAssertThrowsError(try r.validated(), "expected throw for \(bad)") {
XCTAssertEqual(
$0 as? MediaRecipe.ValidationError,
.invalidCalibrationURL(bad))
}
}
}
func testColourSpaceNormalisedToLowercase() throws {
var r = makeRecipe()
r.colourSpace = "RGB"
let validated = try r.validated()
XCTAssertEqual(validated.colourSpace, "rgb")
}
func testCalPrefixedCalNameIsSchemaValid() throws {
var r = makeRecipe()
// CAL_ refusal is an apply-time policy, not a schema error.
r.calibrationURL = "/tmp/CAL_target.cal"
XCTAssertNoThrow(try r.validated())
}
}
+334 -93
View File
@@ -1,116 +1,98 @@
import Testing
import Foundation
import XCTest
@testable import ICCeryCore
@Suite("ProfilingPreset")
struct ProfilingPresetTests {
final class ProfilingPresetTests: XCTestCase {
@Test("snake_case keys round-trip through Codable")
func roundTrip() throws {
func testRoundTrip() throws {
var p = PresetCatalog.highQualityCMYK
p.colprofInputViewingCond = "D50_2"
let data = try JSONEncoder().encode(p)
let decoded = try JSONDecoder().decode(ProfilingPreset.self, from: data)
#expect(decoded == p)
XCTAssertEqual(decoded, p)
// Spot-check the wire format.
let obj = try JSONSerialization.jsonObject(with: data) as! [String: Any]
#expect(obj["colour_space"] as? String == "cmyk")
#expect(obj["patch_count"] as? Int == 1500)
#expect(obj["total_ink_limit"] as? Int == 320)
#expect(obj["bit_depth"] as? Int == 16)
#expect(obj["colprof_input_viewing_cond"] as? String == "D50_2")
XCTAssertEqual(obj["colour_space"] as? String, "cmyk")
XCTAssertEqual(obj["patch_count"] as? Int, 1500)
XCTAssertEqual(obj["total_ink_limit"] as? Int, 320)
XCTAssertEqual(obj["bit_depth"] as? Int, 16)
XCTAssertEqual(obj["colprof_input_viewing_cond"] as? String, "D50_2")
}
@Test("Unknown keys ignored; missing required field fails")
func schemaTolerance() throws {
func testSchemaTolerance() throws {
let json = """
{"id":"x","name":"N","colour_space":"rgb","patch_count":10,
"white_patches":1,"black_patches":1,"instrument":"i1",
"page_size":"A4","bit_depth":8,"dpi":300,"future_key":42}
""".data(using: .utf8)!
let ok = try JSONDecoder().decode(ProfilingPreset.self, from: json)
#expect(ok.id == "x")
XCTAssertEqual(ok.id, "x")
let missing = """
{"id":"x","name":"N","colour_space":"rgb"}
""".data(using: .utf8)!
#expect(throws: DecodingError.self) {
try JSONDecoder().decode(ProfilingPreset.self, from: missing)
}
XCTAssertThrowsError(try JSONDecoder().decode(ProfilingPreset.self, from: missing)) { error in XCTAssertTrue(error is DecodingError) }
}
@Test("Validation rejects bad colour space / dpi / bit depth")
func validation() {
#expect(throws: ProfilingPreset.ValidationError.self) {
try ProfilingPreset(id: "a", name: "n", colourSpace: "lab").validated()
}
#expect(throws: ProfilingPreset.ValidationError.self) {
try ProfilingPreset(id: "a", name: "n", dpi: 10).validated()
}
#expect(throws: ProfilingPreset.ValidationError.self) {
try ProfilingPreset(id: "a", name: "n", bitDepth: 12).validated()
}
#expect(throws: ProfilingPreset.ValidationError.self) {
try ProfilingPreset(id: "a", name: "n", patchCount: 0).validated()
}
func testValidation() {
XCTAssertThrowsError(try ProfilingPreset(id: "a", name: "n", colourSpace: "lab").validated()) { error in XCTAssertTrue(error is ProfilingPreset.ValidationError) }
XCTAssertThrowsError(try ProfilingPreset(id: "a", name: "n", dpi: 10).validated()) { error in XCTAssertTrue(error is ProfilingPreset.ValidationError) }
XCTAssertThrowsError(try ProfilingPreset(id: "a", name: "n", bitDepth: 12).validated()) { error in XCTAssertTrue(error is ProfilingPreset.ValidationError) }
XCTAssertThrowsError(try ProfilingPreset(id: "a", name: "n", patchCount: 0).validated()) { error in XCTAssertTrue(error is ProfilingPreset.ValidationError) }
}
}
@Suite("PresetCatalog")
struct PresetCatalogTests {
final class PresetCatalogTests: XCTestCase {
@Test("Four built-ins with the documented values")
func builtIns() {
#expect(PresetCatalog.builtIns.count == 4)
func testBuiltIns() {
XCTAssertEqual(PresetCatalog.builtIns.count, 4)
let byID = Dictionary(uniqueKeysWithValues: PresetCatalog.builtIns.map { ($0.id, $0) })
let std = byID["preset-std-rgb"]!
#expect(std.colourSpace == "rgb" && std.patchCount == 800
XCTAssertTrue(std.colourSpace == "rgb" && std.patchCount == 800
&& std.pageSize == "A4" && std.bitDepth == 8
&& std.dpi == 300 && std.colprofQuality == "m"
&& std.whitePatches == 4 && std.blackPatches == 4)
let hq = byID["preset-hq-cmyk"]!
#expect(hq.colourSpace == "cmyk" && hq.patchCount == 1500
XCTAssertTrue(hq.colourSpace == "cmyk" && hq.patchCount == 1500
&& hq.pageSize == "A3" && hq.bitDepth == 16
&& hq.dpi == 300 && hq.colprofQuality == "h"
&& hq.totalInkLimit == 320 && hq.blackPatches == 8)
let draft = byID["preset-draft-rgb"]!
#expect(draft.colourSpace == "rgb" && draft.patchCount == 400
XCTAssertTrue(draft.colourSpace == "rgb" && draft.patchCount == 400
&& draft.pageSize == "A4" && draft.bitDepth == 8
&& draft.dpi == 150 && draft.colprofQuality == "l")
let ultra = byID["preset-ultra-rgb"]!
#expect(ultra.colourSpace == "rgb" && ultra.patchCount == 2500
XCTAssertTrue(ultra.colourSpace == "rgb" && ultra.patchCount == 2500
&& ultra.pageSize == "A3" && ultra.bitDepth == 16
&& ultra.dpi == 300 && ultra.colprofQuality == "u"
&& ultra.ofpsHighQuality == true
&& ultra.whitePatches == 6 && ultra.blackPatches == 6)
for p in PresetCatalog.builtIns {
#expect(p.instrument == "i1")
#expect(p.colprofFwa == "D50")
#expect(p.randomSeed == 1)
#expect(p.noRandomize == false)
#expect(p.colprofAlgorithm == "l")
XCTAssertEqual(p.instrument, "i1")
XCTAssertEqual(p.colprofFwa, "D50")
XCTAssertEqual(p.randomSeed, 1)
XCTAssertEqual(p.noRandomize, false)
XCTAssertEqual(p.colprofAlgorithm, "l")
}
}
@Test("Custom presets overlay by id; built-ins are not deletable")
func overlay() {
func testOverlay() {
let custom = ProfilingPreset(
id: "preset-std-rgb", name: "Shadowed", patchCount: 42)
let all = PresetCatalog.all(custom: [custom])
#expect(all.count == 4)
#expect(all.first { $0.id == "preset-std-rgb" }?.patchCount == 42)
#expect(PresetCatalog.isBuiltIn("preset-std-rgb"))
#expect(!PresetCatalog.isBuiltIn("custom-1"))
XCTAssertEqual(all.count, 4)
XCTAssertEqual(all.first { $0.id == "preset-std-rgb" }?.patchCount, 42)
XCTAssertTrue(PresetCatalog.isBuiltIn("preset-std-rgb"))
XCTAssertFalse(PresetCatalog.isBuiltIn("custom-1"))
}
}
@Suite("PresetStore")
struct PresetStoreTests {
final class PresetStoreTests: XCTestCase {
private func tempSettingsURL() throws -> URL {
let dir = FileManager.default.temporaryDirectory
@@ -119,58 +101,52 @@ struct PresetStoreTests {
return dir.appendingPathComponent("settings.json")
}
@Test("CRUD + export/import round-trip")
func crud() throws {
func testCrud() throws {
let url = try tempSettingsURL()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
var p = ProfilingPreset(id: "custom-x", name: "Mine", patchCount: 999, dpi: 150)
try store.saveCustom(p)
#expect(store.customs().count == 1)
#expect(store.all().count == 5)
XCTAssertEqual(store.customs().count, 1)
XCTAssertEqual(store.all().count, 5)
p.name = "Renamed"
try store.saveCustom(p)
#expect(store.customs().count == 1)
#expect(store.customs()[0].name == "Renamed")
XCTAssertEqual(store.customs().count, 1)
XCTAssertEqual(store.customs()[0].name, "Renamed")
let data = try store.export(p)
let imported = try store.import(data)
#expect(imported.name == "Renamed")
#expect(imported.dpi == 150)
XCTAssertEqual(imported.name, "Renamed")
XCTAssertEqual(imported.dpi, 150)
#expect(try store.deleteCustom(id: "custom-x"))
#expect(store.customs().isEmpty)
#expect(try !store.deleteCustom(id: "preset-std-rgb"))
XCTAssertTrue(try store.deleteCustom(id: "custom-x"))
XCTAssertTrue(store.customs().isEmpty)
XCTAssertFalse(try store.deleteCustom(id: "preset-std-rgb"))
}
@Test("Import rewrites a built-in id to a fresh custom id")
func importBuiltinCollision() throws {
func testImportBuiltinCollision() throws {
let url = try tempSettingsURL()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
let data = try store.export(PresetCatalog.standardRGB)
let imported = try store.import(data)
#expect(imported.id.hasPrefix("custom-"))
#expect(!PresetCatalog.isBuiltIn(imported.id))
XCTAssertTrue(imported.id.hasPrefix("custom-"))
XCTAssertFalse(PresetCatalog.isBuiltIn(imported.id))
}
@Test("Built-ins are immutable through saveCustom")
func builtInImmutable() throws {
func testBuiltInImmutable() throws {
let url = try tempSettingsURL()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
var shadowed = PresetCatalog.standardRGB
shadowed.name = "Hacked"
#expect(throws: PresetStore.PresetStoreError.self) {
try store.saveCustom(shadowed)
}
XCTAssertThrowsError(try store.saveCustom(shadowed)) { error in XCTAssertTrue(error is PresetStore.PresetStoreError) }
}
}
@Suite("AppSettings preset migration")
struct PresetMigrationTests {
final class PresetMigrationTests: XCTestCase {
private func tempSettingsURL() throws -> URL {
let dir = FileManager.default.temporaryDirectory
@@ -179,8 +155,7 @@ struct PresetMigrationTests {
return dir.appendingPathComponent("settings.json")
}
@Test("Legacy M1 custom_presets migrate to typed schema")
func legacyMigration() throws {
func testLegacyMigration() throws {
let url = try tempSettingsURL()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let legacy = """
@@ -194,20 +169,19 @@ struct PresetMigrationTests {
try legacy.write(to: url)
let settings = SettingsStore(fileURL: url).load()
#expect(settings.customPresets.count == 1)
XCTAssertEqual(settings.customPresets.count, 1)
let p = settings.customPresets[0]
#expect(p.name == "Old One")
#expect(p.id.hasPrefix("custom-0-"))
#expect(p.colourSpace == "cmyk")
#expect(p.patchCount == 900)
#expect(p.dpi == 150)
#expect(p.bitDepth == 16)
#expect(p.instrument == "p3")
#expect(p.pageSize == "A3")
XCTAssertEqual(p.name, "Old One")
XCTAssertTrue(p.id.hasPrefix("custom-0-"))
XCTAssertEqual(p.colourSpace, "cmyk")
XCTAssertEqual(p.patchCount, 900)
XCTAssertEqual(p.dpi, 150)
XCTAssertEqual(p.bitDepth, 16)
XCTAssertEqual(p.instrument, "p3")
XCTAssertEqual(p.pageSize, "A3")
}
@Test("Typed presets load and re-save as the typed schema")
func typedRoundTrip() throws {
func testTypedRoundTrip() throws {
let url = try tempSettingsURL()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let store = SettingsStore(fileURL: url)
@@ -215,18 +189,285 @@ struct PresetMigrationTests {
s.customPresets = [ProfilingPreset(id: "c1", name: "C1", patchCount: 700)]
try store.save(s)
let loaded = store.load()
#expect(loaded.customPresets.first?.patchCount == 700)
XCTAssertEqual(loaded.customPresets.first?.patchCount, 700)
}
@Test("Draft preset dpi=150 survives Codable + settings round-trip")
func draftDPI() throws {
func testDraftDPI() throws {
let url = try tempSettingsURL()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
let data = try store.export(PresetCatalog.draftRGB)
let obj = try JSONSerialization.jsonObject(with: data) as! [String: Any]
#expect(obj["dpi"] as? Int == 150)
XCTAssertEqual(obj["dpi"] as? Int, 150)
let back = try store.import(data)
#expect(back.dpi == 150)
XCTAssertEqual(back.dpi, 150)
}
}
final class PresetMappingTests: XCTestCase {
func testDraftDpi() {
let cfg = PrinttargConfig(
preset: PresetCatalog.draftRGB,
basename: "t",
workingDirectory: nil,
calibrationFile: nil
)
XCTAssertEqual(cfg.dpi, 150)
XCTAssertEqual(cfg.layoutOrder, .deterministic)
}
func testOptionalNil() {
let preset = ProfilingPreset(id: "x", name: "n", patchCount: 800)
let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
XCTAssertNil(cfg.greySteps)
XCTAssertNil(cfg.singleChannelSteps)
XCTAssertNil(cfg.neutralSteps)
XCTAssertNil(cfg.totalInkLimit)
XCTAssertNil(cfg.darkEmphasis)
XCTAssertNil(cfg.devicePower)
}
func testRoundTripConfigs() {
var preset = PresetCatalog.highQualityCMYK
preset.pageSize = "210x297"
preset.colprofFwa = "D50"
preset.greySteps = nil
let targen = TargenConfig(preset: preset, basename: "job", workingDirectory: nil)
let printtarg = PrinttargConfig(
preset: preset, basename: "job", workingDirectory: nil, calibrationFile: nil
)
let colprof = ColprofConfig(preset: preset, basename: "job", workingDirectory: nil)
XCTAssertEqual(printtarg.pageSize, .custom)
XCTAssertEqual(printtarg.customPageWidth, 210)
XCTAssertEqual(colprof.fwa, "D50")
let back = ProfilingPreset(
id: preset.id,
name: preset.name,
description: preset.description,
targen: targen,
printtarg: printtarg,
colprof: colprof,
calibrationFile: preset.calibrationFile,
applyCalibration: preset.applyCalibration
)
XCTAssertEqual(back.dpi, preset.dpi)
XCTAssertEqual(back.colourSpace, "cmyk")
XCTAssertEqual(back.pageSize, "210x297")
XCTAssertEqual(back.colprofFwa, "D50")
XCTAssertNil(back.greySteps)
}
func testFullRoundTrip() {
let preset = ProfilingPreset(
id: "custom-full",
name: "Full",
description: "All fields",
colourSpace: "cmyk",
patchCount: 1500,
whitePatches: 6,
blackPatches: 8,
greySteps: 9,
singleChannelSteps: 7,
neutralSteps: 4,
neutralConcentration: 0.7,
preconditioningProfile: "/tmp/pre.icm",
ofpsHighQuality: true,
ofpsAdaptation: 0.2,
fullSpreadAlgorithm: "R",
totalInkLimit: 280,
darkEmphasis: 1.3,
devicePower: 1.2,
instrument: "p3",
pageSize: "250x300",
bitDepth: 16,
dpi: 360,
randomSeed: 42,
noRandomize: false,
calibrationFile: "/tmp/a.cal",
applyCalibration: true,
colprofAlgorithm: "x",
colprofQuality: "u",
colprofIntent: "p",
colprofFwa: "D65",
colprofIlluminant: "D65",
colprofObserver: "1931_2",
colprofInputViewingCond: "D50_2",
colprofOutputViewingCond: "D65_2"
)
let targen = TargenConfig(preset: preset, basename: "j", workingDirectory: nil)
XCTAssertEqual(targen.colourSpace, .cmyk)
XCTAssertEqual(targen.patchCount, 1500)
XCTAssertEqual(targen.whitePatches, 6)
XCTAssertEqual(targen.blackPatches, 8)
XCTAssertEqual(targen.greySteps, 9)
XCTAssertEqual(targen.singleChannelSteps, 7)
XCTAssertEqual(targen.neutralSteps, 4)
XCTAssertEqual(targen.neutralConcentration, 0.7)
XCTAssertEqual(targen.preconditioningProfile, "/tmp/pre.icm")
XCTAssertEqual(targen.ofpsHighQuality, true)
XCTAssertEqual(targen.ofpsAdaptation, 0.2)
XCTAssertEqual(targen.fullSpreadAlgorithm, .uniformRandom)
XCTAssertEqual(targen.totalInkLimit, 280)
XCTAssertEqual(targen.darkEmphasis, 1.3)
XCTAssertEqual(targen.devicePower, 1.2)
let printtarg = PrinttargConfig(
preset: preset,
basename: "j",
workingDirectory: nil,
calibrationFile: preset.calibrationFile
)
XCTAssertEqual(printtarg.instrument, .p3)
XCTAssertEqual(printtarg.pageSize, .custom)
XCTAssertEqual(printtarg.customPageWidth, 250)
XCTAssertEqual(printtarg.customPageHeight, 300)
XCTAssertEqual(printtarg.bitDepth, .sixteen)
XCTAssertEqual(printtarg.dpi, 360)
XCTAssertEqual(printtarg.layoutOrder, .customSeed)
XCTAssertEqual(printtarg.customSeed, 42)
XCTAssertEqual(printtarg.calibrationFile, "/tmp/a.cal")
let colprof = ColprofConfig(preset: preset, basename: "j", workingDirectory: nil)
XCTAssertEqual(colprof.algorithm, "x")
XCTAssertEqual(colprof.quality, "u")
XCTAssertEqual(colprof.intent, "p")
XCTAssertEqual(colprof.fwa, "D65")
XCTAssertEqual(colprof.illuminant, "D65")
XCTAssertEqual(colprof.observer, "1931_2")
XCTAssertEqual(colprof.inputViewingCond, "D50_2")
XCTAssertEqual(colprof.outputViewingCond, "D65_2")
let back = ProfilingPreset(
id: preset.id,
name: preset.name,
description: preset.description,
targen: targen,
printtarg: printtarg,
colprof: colprof,
calibrationFile: preset.calibrationFile,
applyCalibration: preset.applyCalibration
)
XCTAssertEqual(back, preset)
}
func testFullSpreadAlgorithms() {
let cases: [(String, FullSpreadAlgorithm)] = [
("ofps", .ofps),
("t", .target),
("r", .random),
("R", .uniformRandom),
("q", .quasiRandom),
("Q", .uniformQuasiRandom),
("i", .invertedQuasiRandom),
("I", .invertedUniformQuasiRandom)
]
for (value, expected) in cases {
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
preset.fullSpreadAlgorithm = value
let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
if expected == .ofps {
// ofps is the default no flag emitted, stored value is nil.
XCTAssertNil(cfg.fullSpreadAlgorithm)
} else {
XCTAssertEqual(cfg.fullSpreadAlgorithm, expected)
}
let back = ProfilingPreset(
id: "x", name: "n", description: "",
targen: cfg,
printtarg: PrinttargConfig(
preset: preset, basename: "t",
workingDirectory: nil, calibrationFile: nil
),
colprof: ColprofConfig(preset: preset, basename: "t", workingDirectory: nil),
calibrationFile: nil,
applyCalibration: nil
)
XCTAssertEqual(back.fullSpreadAlgorithm, value)
}
}
func testOfpsHighQualityFalse() {
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
preset.ofpsHighQuality = false
let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
XCTAssertEqual(cfg.ofpsHighQuality, false)
preset.ofpsHighQuality = nil
let nilCfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
XCTAssertNil(nilCfg.ofpsHighQuality)
}
func testLayoutMapping() {
let cases: [(Bool?, Int?, LayoutOrder, Int)] = [
(true, nil, .raster, 1),
(true, 7, .raster, 7),
(false, nil, .deterministic, 1),
(false, 1, .deterministic, 1),
(nil, 1, .deterministic, 1),
(false, 5, .customSeed, 5)
]
for (noRandomize, seed, layout, expectedSeed) in cases {
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
preset.noRandomize = noRandomize
preset.randomSeed = seed
let cfg = PrinttargConfig(
preset: preset, basename: "t",
workingDirectory: nil, calibrationFile: nil
)
XCTAssertEqual(cfg.layoutOrder, layout)
XCTAssertEqual(cfg.customSeed, expectedSeed)
}
}
func testCustomPageFallback() {
let cases: [(String, PageSize, Double, Double)] = [
("250x300", .custom, 250.0, 300.0),
("50x50", .custom, 50.0, 50.0),
("foo", .a4, 210.0, 297.0),
("30x40", .a4, 210.0, 297.0),
("210x", .a4, 210.0, 297.0)
]
for (raw, page, w, h) in cases {
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
preset.pageSize = raw
let cfg = PrinttargConfig(
preset: preset, basename: "t",
workingDirectory: nil, calibrationFile: nil
)
XCTAssertEqual(cfg.pageSize, page)
XCTAssertEqual(cfg.customPageWidth, w)
XCTAssertEqual(cfg.customPageHeight, h)
}
}
func testFwaToSelection() {
let cases: [(String?, ColprofFwaSelection)] = [
(nil, .none),
("none", .none),
("NONE", .none),
("", .empty),
("D50", .D50),
("d50", .D50),
("D65", .D65),
("d65", .D65),
("/tmp/fwa.sp", .custom)
]
for (raw, expected) in cases {
XCTAssertEqual(ColprofFwaSelection(presetValue: raw), expected)
}
}
func testFwaToPresetValue() {
let cases: [(ColprofFwaSelection, String?)] = [
(.none, nil),
(.empty, ""),
(.D50, "D50"),
(.D65, "D65"),
(.custom, "/tmp/fwa.sp")
]
for (selection, expected) in cases {
XCTAssertEqual(selection.presetValue(customPath: "/tmp/fwa.sp"), expected)
}
}
}
@@ -0,0 +1,121 @@
import Foundation
import XCTest
@testable import ICCeryCore
@testable import ICCery
/// Issue #82 preset application through the live view models, under an
/// isolated `TestAppEnvironment` (temp stores, fresh ProcessManager).
@MainActor
final class PresetViewModelMappingTests: XCTestCase {
private func makeWorkflow() throws -> (TestAppEnvironment, TargetWorkflowViewModel) {
let env = try TestAppEnvironment.make()
return (env, TargetWorkflowViewModel(environment: env.environment))
}
func testNilFwaClearsCustomPath() throws {
let (env, vm) = try makeWorkflow()
defer { env.cleanup() }
var customPreset = ProfilingPreset(
id: "c-fwa", name: "FWA", patchCount: 800,
colprofFwa: "/tmp/fwa.sp"
)
vm.applyPreset(customPreset)
XCTAssertEqual(vm.profile.fwaSelection, .custom)
XCTAssertEqual(vm.profile.fwaCustomPath, "/tmp/fwa.sp")
customPreset.colprofFwa = nil
vm.applyPreset(customPreset)
XCTAssertEqual(vm.profile.fwaSelection, .none)
XCTAssertEqual(vm.profile.fwaCustomPath, "")
XCTAssertNil(vm.profile.fwaValue)
}
func testCustomFwaRoundTrip() throws {
let (env, vm) = try makeWorkflow()
defer { env.cleanup() }
let preset = ProfilingPreset(
id: "c-fwa2", name: "FWA2", patchCount: 800,
colprofFwa: "/tmp/other.sp"
)
vm.applyPreset(preset)
XCTAssertEqual(vm.profile.fwaSelection, .custom)
XCTAssertEqual(vm.profile.fwaCustomPath, "/tmp/other.sp")
XCTAssertEqual(vm.profile.fwaValue, "/tmp/other.sp")
}
func testPresetCalibrationReachesStage2() throws {
let (env, vm) = try makeWorkflow()
defer { env.cleanup() }
// Stale live state must not leak into the preset-applied layout.
vm.profile.applyCalibration = true
vm.profile.calibrationFile = "/tmp/stale.cal"
let preset = ProfilingPreset(
id: "c-cal", name: "Cal", patchCount: 800,
calibrationFile: "/tmp/preset.cal",
applyCalibration: true
)
vm.applyPreset(preset)
XCTAssertTrue(vm.profile.applyCalibration)
XCTAssertEqual(vm.profile.calibrationFile, "/tmp/preset.cal")
XCTAssertEqual(vm.buildPrinttargConfig().calibrationFile, "/tmp/preset.cal")
}
func testDisabledCalibrationClearsStage2() throws {
let (env, vm) = try makeWorkflow()
defer { env.cleanup() }
vm.profile.applyCalibration = true
vm.profile.calibrationFile = "/tmp/stale.cal"
let preset = ProfilingPreset(
id: "c-nocal", name: "NoCal", patchCount: 800,
calibrationFile: "/tmp/preset.cal",
applyCalibration: nil
)
vm.applyPreset(preset)
XCTAssertFalse(vm.profile.applyCalibration)
XCTAssertNil(vm.buildPrinttargConfig().calibrationFile)
}
func testFormFieldsApply() throws {
let (env, vm) = try makeWorkflow()
defer { env.cleanup() }
var preset = ProfilingPreset(
id: "c-form", name: "Form",
colourSpace: "cmyk", patchCount: 1500,
whitePatches: 6,
blackPatches: 8,
greySteps: 9,
fullSpreadAlgorithm: "r",
pageSize: "250x300",
dpi: 150
)
vm.applyPreset(preset)
XCTAssertEqual(vm.colourSpace, .cmyk)
XCTAssertEqual(vm.effectivePatchCount, 1500)
XCTAssertEqual(vm.whitePatches, 6)
XCTAssertEqual(vm.blackPatches, 8)
XCTAssertTrue(vm.greyStepsEnabled && vm.greySteps == 9)
XCTAssertEqual(vm.algorithm, .random)
XCTAssertEqual(vm.tiffDpi, 150)
XCTAssertEqual(vm.pageSize, .custom)
XCTAssertTrue(vm.customPageW == 250 && vm.customPageH == 300)
XCTAssertEqual(vm.selectedPresetID, "c-form")
// Disabled advanced controls stay nil in the snapshot, not
// numeric sentinels.
preset.greySteps = nil
vm.applyPreset(preset)
XCTAssertFalse(vm.greyStepsEnabled)
XCTAssertNil(vm.buildTargenConfig().greySteps)
}
}
+13 -17
View File
@@ -1,13 +1,12 @@
import Testing
import Foundation
import XCTest
@testable import ICCeryCore
@testable import ICCery
/// Issue 13 panel outcome mapping (cancel nil, ok result).
/// The real `NSPrintPanel` is never run in tests; these exercise the
/// `UITestHooks` seam the UI tests rely on.
@Suite("PrintPanelStub")
struct PrintPanelStubTests {
final class PrintPanelStubTests: XCTestCase {
private func withEnv(
_ vars: [String: String?],
@@ -28,19 +27,17 @@ struct PrintPanelStubTests {
try body()
}
@Test("Cancel returns nil — not an error")
func cancelIsNil() throws {
func testCancelIsNil() throws {
try withEnv([
"ICCERY_UI_TESTING": "1",
"ICCERY_TEST_PRINT_PANEL": "cancel",
]) {
#expect(UITestHooks.printPanelStubbed)
#expect(UITestHooks.printPanelResult(forQueue: "q") == nil)
XCTAssertTrue(UITestHooks.printPanelStubbed)
XCTAssertNil(UITestHooks.printPanelResult(forQueue: "q"))
}
}
@Test("OK returns captured options + selected printer")
func okResult() throws {
func testOkResult() throws {
try withEnv([
"ICCERY_UI_TESTING": "1",
"ICCERY_TEST_PRINT_PANEL": "ok",
@@ -48,15 +45,14 @@ struct PrintPanelStubTests {
"ICCERY_TEST_PANEL_PRINTER": "Other_Queue",
]) {
let result = UITestHooks.printPanelResult(forQueue: "q")
#expect(result?.selectedPrinter == "Other_Queue")
#expect(result?.options.cupsOptions == "MediaType=Photo InputSlot=Rear")
#expect(result?.options.mediaType == "Photo")
#expect(result?.options.ppdUncorrectedPassthrough == true)
XCTAssertEqual(result?.selectedPrinter, "Other_Queue")
XCTAssertEqual(result?.options.cupsOptions, "MediaType=Photo InputSlot=Rear")
XCTAssertEqual(result?.options.mediaType, "Photo")
XCTAssertEqual(result?.options.ppdUncorrectedPassthrough, true)
}
}
@Test("OK defaults selected printer to the opened queue")
func okDefaultsPrinter() throws {
func testOkDefaultsPrinter() throws {
try withEnv([
"ICCERY_UI_TESTING": "1",
"ICCERY_TEST_PRINT_PANEL": "ok",
@@ -64,8 +60,8 @@ struct PrintPanelStubTests {
"ICCERY_TEST_PANEL_PRINTER": nil,
]) {
let result = UITestHooks.printPanelResult(forQueue: "My_Queue")
#expect(result?.selectedPrinter == "My_Queue")
#expect(result?.options.cupsOptions == nil)
XCTAssertEqual(result?.selectedPrinter, "My_Queue")
XCTAssertNil(result?.options.cupsOptions)
}
}
}

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