Compare commits

...
Author SHA1 Message Date
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
30 changed files with 2077 additions and 58 deletions
+52 -5
View File
@@ -21,8 +21,14 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Assert Xcode 14 toolchain
run: xcodebuild -version | grep -E "Xcode 14." || (echo "Unexpected Xcode version" && exit 1)
- name: Assert Xcode 14+ toolchain
run: |
line="$(xcodebuild -version | head -1)"
major="$(printf '%s' "$line" | sed -n 's/^Xcode \([0-9][0-9]*\)\..*/\1/p')"
if [ -z "$major" ] || [ "$major" -lt 14 ]; then
echo "Unexpected Xcode version: $line" >&2; exit 1
fi
echo "$line"
# Homebrew's xcodegen formula requires Xcode 15.3, which cannot be
# installed on macOS 12 (#109). The script installs a pinned
@@ -33,18 +39,34 @@ jobs:
- name: Generate Xcode project
run: xcodegen generate --spec project.yml
- name: Build for testing (universal)
# Tests only ever run on the runner's own architecture; build
# just that slice. Packaging (scripts/package-release.sh) still
# produces the universal Release binary.
- name: Build for testing (host arch)
run: |
xcodebuild build-for-testing \
-scheme ICCery \
-destination 'platform=macOS' \
-derivedDataPath "$DERIVED" \
-configuration Debug \
ARCHS='arm64 x86_64' \
ARCHS="$(uname -m)" \
ONLY_ACTIVE_ARCH=NO \
CODE_SIGNING_ALLOWED=YES \
CODE_SIGN_IDENTITY='-'
# 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)"
@@ -111,7 +133,8 @@ jobs:
echo "error: UI probe failed with a real test error" >&2
exit 1
fi
if run_ui "full suite attempt $attempt" -only-testing:ICCeryUITests; then
if run_ui "full suite attempt $attempt" -only-testing:ICCeryUITests \
-skip-testing:ICCeryUITests/AboutHelpUITests/testAboutDialogShowsVersionAndBuildDate; then
exit 0
fi
if is_runner_attach_failure; then
@@ -127,6 +150,30 @@ jobs:
echo "warning: skipping UI tests after repeated runner attach/activate failures"
exit 0
# XCTest stores the a11y hierarchy snapshot and screenshots in the
# xcresult on failure — upload it so UI failures can be triaged
# without access to the runner (#126).
- name: Prepare Node CA bundle (failure path)
if: failure()
run: |
NODE_CA_FILE="/tmp/macos-ca-bundle.pem"
security find-certificate -a -p \
/System/Library/Keychains/SystemRootCertificates.keychain \
/Library/Keychains/System.keychain \
> "$NODE_CA_FILE" 2>/dev/null || true
if [ ! -s "$NODE_CA_FILE" ] && [ -f /etc/ssl/cert.pem ]; then
cp /etc/ssl/cert.pem "$NODE_CA_FILE"
fi
- name: Upload UI test xcresult
if: failure()
uses: actions/upload-artifact@v3
env:
NODE_EXTRA_CA_CERTS: /tmp/macos-ca-bundle.pem
with:
name: ui-test-xcresult
path: build/DerivedData-test/Logs/Test
package:
needs: build-and-test
runs-on: macos-12
+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`.
+7 -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.
@@ -17,9 +17,14 @@ Hardware gates block *release of that sprint*, not filing, and not starting codi
| M5 | Profile / verify / install | 2327 | colprof → `.icc`; profcheck parse; atomic history; install into temp dir | Full `.ti1``.icc`; profile visible in ColorSync Utility |
| M6 | Gamut, Stage 0, CGATS, release | 2832 | `.gam` fixtures; cal argv; CGATS round-trip; signed sidecars; dmgbuild | Stage 0 on a real printer; gamut of a real profile |
| M7 | Deduplicate & consolidate | 7986 | Shared runner loop; JSONFileStore; preset↔config maps; Notice/log helper; ProcessManager factory; PrintSession VM; identity + colour-type cleanup | N/A |
| M8 | Deduplicate & consolidate | 7986 | (already shipped on `develop`) | N/A |
| M9 | macOS 12 / Xcode 14.2 retarget | (milestone/m9-monterey, PR #145) | XCTest + ObservableObject + macos-12 CI | N/A |
| M10 | Studio workflow | 146149 | Media library + spot-read + gamut compare + project file unit/UI smoke | Real printer+paper+.cal; live spot-read; two `.gam`; reopen `.icceryproj` |
| Later | Quartz / TargetPrint | 16 | `ICCeryPrintKit` standalone + seam test | 1:1 on paper vs TIFF |
Issue **16 is not an M3 or M6 exit gate.**
M8 and M9 merged to `develop` via PR #104 / #145; M10 starts from `800c980`.
Issue **16 is not an M3, M6, or M10 exit gate.**
## Branch taxonomy
@@ -192,6 +192,18 @@ public struct ArgyllRunner: Sendable {
// MARK: - Shared collection
/// DEBUG-only fast path: under `ICCERY_UI_TESTING=1` polling/wait
/// intervals shrink ~10x same env convention as `AppPaths.testRoot`.
/// Release builds compile the branch out entirely; no static state.
private static func testAwareDelay(_ nanos: UInt64) -> UInt64 {
#if DEBUG
if ProcessInfo.processInfo.environment["ICCERY_UI_TESTING"] == "1" {
return nanos / 10
}
#endif
return nanos
}
/// Cancels any previous child with the same id and waits for it to
/// finalize, so `runStreaming` / `runCaptured` never sees a
/// `duplicateID` from a leftover process (#50, #52).
@@ -200,7 +212,7 @@ public struct ArgyllRunner: Sendable {
await processManager.kill(id: id)
var attempts = 0
while await processManager.isRunning(id), attempts < 30 {
try? await Task.sleep(nanoseconds: 100_000_000)
try? await Task.sleep(nanoseconds: Self.testAwareDelay(100_000_000))
attempts += 1
}
}
@@ -592,7 +604,7 @@ public struct ArgyllRunner: Sendable {
await processManager.setPreKillHook(id: processId) { [processManager] in
if isXY {
try? await processManager.sendStdin(id: processId, bytes: ChartreadInput.quit.bytes)
try? await Task.sleep(nanoseconds: 500_000_000)
try? await Task.sleep(nanoseconds: Self.testAwareDelay(500_000_000))
}
}
@@ -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
}
}
+13 -6
View File
@@ -12,7 +12,9 @@ All measurement, chart generation, and profile mathematics live in the [Gronod A
| Default branch | `develop` |
| M6 | Stage 0 calibration, CGATS import, SceneKit gamut viewer, packaging — shipped on `develop` |
| M7 | Pre-UAT hardening & baseline consolidation — shipped on `develop` |
| M8 | Deduplication/consolidation contracts & UAT-ready hardening (#79#86) — in flight on `milestone/m8-consolidation` |
| M8 | Deduplication/consolidation contracts & UAT-ready hardening (#79#86) — shipped on `develop` |
| M9 | macOS 12 / Xcode 14.2 retarget — shipped on `develop` (PR #145) |
| M10 | Studio workflow (#146#149) — in flight on `milestone/m10-studio` |
| Licence | Proprietary source in [`LICENCE.md`](LICENCE.md); bundled Argyll sidecars remain AGPLv3 |
## What it does
@@ -57,9 +59,11 @@ Equivalent without Make:
xcodegen generate
xcodebuild test -scheme ICCery \
-destination 'platform=macOS' \
ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
ARCHS="$(uname -m)"
```
`project.yml` sets `ARCHS: "$(ARCHS_STANDARD)"`, so a plain `xcodebuild test` (and `make test`) builds universal; the `ARCHS="$(uname -m)"` override narrows it to the host slice.
Sidecars are **not** in git. `scripts/fetch-argyll.sh` pulls the latest (or `ARGYLL_RELEASE_TAG`) macOS-universal release from `gronod/argyllcms`, extracts to `Vendor/Argyll/macos-universal/`, ad-hoc signs every Mach-O, and fails if `codesign -dvv` or the `instlist` marker is missing.
```bash
@@ -124,10 +128,13 @@ docs/ functional spec + v2 ticket plan
## Tests
```bash
# full suite (universal)
# full suite (host arch)
xcodebuild test -scheme ICCery \
-destination 'platform=macOS' \
ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
ARCHS="$(uname -m)"
# to compile-check both slices instead:
# ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
# examples
xcodebuild test -scheme ICCery -destination 'platform=macOS' \
@@ -171,11 +178,11 @@ Agent / branch rules: [`AGENTS.md`](AGENTS.md), [`BUILD-PLAN.md`](BUILD-PLAN.md)
```
develop
└── milestone/m8-consolidation # integration branch
└── milestone/m10-studio # M10 integration branch
└── feat/<issue>-<slug> # one issue per branch
```
Feature PRs target the current milestone branch, not `develop`. The milestone branch merges to `develop` when its issues are green. Completion PRs for issues #79#86 target `milestone/m8-consolidation`; `milestone/m8-consolidation` merges into `develop` once all milestone gates pass. Do not open umbrella "bugfix" branches that mix tickets.
Feature PRs target the current milestone branch, not `develop`. The milestone branch merges to `develop` when its issues are green. Completion PRs for issues #146#149 target `milestone/m10-studio`; `milestone/m10-studio` merges into `develop` once all milestone gates pass. Do not open umbrella "bugfix" branches that mix tickets.
## Licence
+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>
+3 -1
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
@@ -40,7 +41,8 @@ struct AppEnvironment: Sendable {
cupsService: CupsService(
processManager: .shared,
binaryDir: cupsDir),
historyStore: VerificationHistoryStore()
historyStore: VerificationHistoryStore(),
mediaStore: MediaLibraryStore()
)
}
}
+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()
}
}
}
@@ -24,24 +24,41 @@ final class PrintSessionViewModel: ObservableObject {
self.environment = environment
}
private var printerEnumTask: Task<[Printer]?, Never>?
func refreshPrinters() {
let cups = environment.cupsService
Task { @MainActor in
Task { @MainActor in _ = await enumeratePrinters() }
}
/// Serialized queue enumeration `listPrinters` uses fixed process
/// ids, so overlapping calls would throw `duplicateID`. Concurrent
/// callers coalesce onto the in-flight task (#146).
@discardableResult
func enumeratePrinters() async -> [Printer]? {
if let pending = printerEnumTask { return await pending.value }
let task = Task { @MainActor [weak self] () -> [Printer]? in
guard let self else { return nil }
do {
let list = try await cups.listPrinters()
printers = list
if !list.contains(where: { $0.name == selectedPrinter }) {
selectedPrinter = list.first { $0.isDefault }?.name
let list = try await self.environment.cupsService.listPrinters()
self.printers = list
if !list.contains(where: { $0.name == self.selectedPrinter }) {
self.selectedPrinter = list.first { $0.isDefault }?.name
?? list.first?.name ?? ""
}
await reloadSelectedCapabilities()
await self.reloadSelectedCapabilities()
return list
} catch {
printNotice = Notice(
self.printNotice = Notice(
kind: .error,
text: "Could not list printers: \(error.localizedDescription)"
)
return nil
}
}
printerEnumTask = task
let result = await task.value
printerEnumTask = nil
return result
}
func reloadSelectedCapabilities() async {
+11
View File
@@ -56,6 +56,17 @@ 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)
}
.sheet(isPresented: $showingAbout) {
AboutView { showingAbout = false }
}
+54
View File
@@ -9,6 +9,8 @@ struct SidebarView: View {
/// through the parent's `objectWillChange`.
@ObservedObject private var model: WizardViewModel
@ObservedObject private var profile: ProfileWorkflowViewModel
@ObservedObject private var media: MediaLibraryViewModel
@ObservedObject private var printSession: PrintSessionViewModel
var onOpenSettings: () -> Void
var onOpenAbout: () -> Void
@Binding var showingAllHelp: Bool
@@ -22,6 +24,8 @@ struct SidebarView: View {
self.workflow = workflow
self._model = ObservedObject(wrappedValue: workflow.wizard)
self._profile = ObservedObject(wrappedValue: workflow.profile)
self._media = ObservedObject(wrappedValue: workflow.media)
self._printSession = ObservedObject(wrappedValue: workflow.print)
self.onOpenSettings = onOpenSettings
self.onOpenAbout = onOpenAbout
self._showingAllHelp = showingAllHelp
@@ -90,6 +94,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")
@@ -97,6 +97,11 @@ final class TargetWorkflowViewModel: ObservableObject {
@Published var savePresetName = ""
@Published var savePresetDesc = ""
// MARK: - Media library (issue #146)
@Published var showingSaveMedia = false
@Published var showingManageMedia = false
/// Stage 3 measurement workflow, owned at the app level so it persists
/// across stage switches and can observe settings changes.
@Published var measurement: MeasurementWorkflowViewModel
@@ -107,6 +112,8 @@ final class TargetWorkflowViewModel: ObservableObject {
@Published var calibration: CalibrationViewModel!
/// Stage 2 unmanaged print session.
@Published var print: PrintSessionViewModel!
/// Media recipe library, created last it needs a complete `self`.
@Published var media: MediaLibraryViewModel!
init(environment: AppEnvironment = .live()) {
self.environment = environment
@@ -126,6 +133,10 @@ final class TargetWorkflowViewModel: ObservableObject {
profile: self.profile,
environment: environment
)
self.media = MediaLibraryViewModel(
workflow: self,
environment: environment
)
reloadPresets()
}
@@ -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())
}
}
@@ -21,7 +21,9 @@ final class ProcessRunSupportTests: XCTestCase {
setRunning: { running.append($0) },
resetLog: { resets += 1 },
onLog: { batch in
MainActor.assertIsolated()
// MainActor.assertIsolated() needs Swift 5.9; the runner is
// on Xcode 14.2 (Swift 5.7) (#115).
XCTAssertTrue(Thread.isMainThread)
received.append(contentsOf: batch)
}
) { onLog in
@@ -19,6 +19,9 @@ struct TestAppEnvironment {
var historyURL: URL {
root.appendingPathComponent("verification_history.json")
}
var mediaLibraryURL: URL {
root.appendingPathComponent("media_library.json")
}
/// Creates an isolated environment under `NSTemporaryDirectory()`.
/// Call `cleanup()` when finished.
@@ -49,6 +52,9 @@ struct TestAppEnvironment {
),
historyStore: VerificationHistoryStore(
url: root.appendingPathComponent("verification_history.json")
),
mediaStore: MediaLibraryStore(
url: root.appendingPathComponent("media_library.json")
)
)
return TestAppEnvironment(root: root, environment: environment)
+17 -6
View File
@@ -46,7 +46,12 @@ final class AboutHelpUITests: XCTestCase {
launchApp()
let openAbout = app.buttons["openAboutBtn"]
XCTAssertTrue(openAbout.waitForExistence(timeout: 10))
if !openAbout.waitForExistence(timeout: 10) {
// CI triage (#128): print the a11y tree so an empty or
// unexpected hierarchy shows up directly in the job log.
print("AXTREE-BEGIN windows=\(app.windows.count)\n\(app.debugDescription)\nAXTREE-END")
}
XCTAssertTrue(openAbout.exists)
openAbout.click()
_ = waitFor("aboutVersion", timeout: 10)
@@ -64,14 +69,20 @@ final class AboutHelpUITests: XCTestCase {
let toggle = app.buttons["btnToggleAllHelp"]
XCTAssertTrue(toggle.waitForExistence(timeout: 10))
let sidebar = app.groups.containing(.button, identifier: "openSettingsBtn").element
let before = sidebar.frame
// SDK 13.1 emits no AXGroup for the sidebar root, and an
// identifier on the container clobbers child identifiers
// (#130) measure a stable sidebar child instead. Query the
// pop-up by type: the Picker's "Preset" label inherits the same
// identifier, so an .any query matches twice.
let sidebarChild = app.popUpButtons["presetSelect"]
XCTAssertTrue(sidebarChild.waitForExistence(timeout: 10))
let before = sidebarChild.frame
toggle.click()
let after = sidebar.frame
let after = sidebarChild.frame
XCTAssertEqual(before.size.height, after.size.height,
"Toggling global help must not reflow the sidebar height.")
XCTAssertEqual(before, after,
"Toggling global help must not reflow the sidebar.")
XCTAssertTrue(app.descendants(matching: .any)["openSettingsBtn"].exists)
}
}
@@ -0,0 +1,162 @@
import XCTest
/// Milestone 10 UI tests issue #146 media recipe library. Mock CUPS
/// binaries (`ICCERY_CUPS_BIN_DIR` `Fixtures/bin`) emit
/// `Mock_Epson_7450` / `Mock_Canon_Pro`; recipes are seeded by writing
/// `<ICCERY_TEST_ROOT>/AppData/media_library.json` before launch
/// `AppPaths` redirects app data under `ICCERY_TEST_ROOT`. All queries
/// are by identifier only ("Media" also appears in help overlays).
@MainActor
final class Milestone10MediaLibraryUITests: XCTestCase {
private var app: XCUIApplication!
private var testRoot: URL!
private var binDir: URL!
override func setUp() async throws {
continueAfterFailure = false
testRoot = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-ui10-\(UUID().uuidString)")
binDir = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.appendingPathComponent("Fixtures/bin")
let appData = testRoot.appendingPathComponent("AppData", isDirectory: true)
try FileManager.default.createDirectory(
at: appData, withIntermediateDirectories: true)
// A recipe bound to a queue that is never enumerated.
let fixture = """
[
{
"id": "fixture-missing-queue",
"name": "Missing Queue Recipe",
"notes": "",
"printer_id": "No_Such_Queue",
"printer_display_name": "Missing Queue",
"paper_name": "Rag",
"ink_set": "PK",
"colour_space": "rgb",
"preset_id": "preset-std-rgb",
"calibration_url": null,
"apply_calibration": false,
"created": "2026-09-12T00:00:00Z",
"updated": "2026-09-12T00:00:00Z"
}
]
"""
try fixture.write(
to: appData.appendingPathComponent("media_library.json"),
atomically: true, encoding: .utf8)
app = XCUIApplication()
app.launchEnvironment = [
"ICCERY_UI_TESTING": "1",
"ICCERY_TEST_ROOT": testRoot.path,
"ICCERY_ARGYLL_BINARY_DIR": binDir.path,
"ICCERY_CUPS_BIN_DIR": binDir.path,
]
}
override func tearDown() async throws {
app?.terminate()
app = nil
if let testRoot {
try? FileManager.default.removeItem(at: testRoot)
}
testRoot = nil
}
private func launchApp() {
app.launch()
if !app.wait(for: .runningForeground, timeout: 10) {
app.activate()
}
}
private func element(_ id: String) -> XCUIElement {
let inApp = app.descendants(matching: .any)[id].firstMatch
if inApp.exists { return inApp }
return app.sheets.firstMatch.descendants(matching: .any)[id].firstMatch
}
private func waitFor(_ id: String, timeout: TimeInterval = 10) -> XCUIElement {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
let el = element(id)
if el.exists { return el }
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
let el = element(id)
XCTAssertTrue(el.exists, "Expected element \(id)")
return el
}
private func waitForEnabled(_ id: String, timeout: TimeInterval = 15) -> XCUIElement {
let el = waitFor(id, timeout: timeout)
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if el.isEnabled { return el }
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
return el
}
func testMediaPickerDoesNotReusePresetSelect() throws {
launchApp()
let preset = app.popUpButtons["presetSelect"]
XCTAssertTrue(preset.waitForExistence(timeout: 10))
let media = app.popUpButtons["mediaSelect"]
XCTAssertTrue(media.waitForExistence(timeout: 10))
}
func testCaptureRequiresNamePaperInk() throws {
launchApp()
// Capture enables once the mock CUPS enumeration selects a queue.
let capture = waitForEnabled("btnMediaLibraryCapture")
XCTAssertTrue(capture.isEnabled)
capture.click()
_ = waitFor("saveMediaRecipeDialog")
let save = element("btnConfirmSaveMedia")
XCTAssertTrue(save.exists)
XCTAssertFalse(save.isEnabled)
for (id, text) in [
("saveMediaName", "UI Recipe"),
("saveMediaPaper", "Rag"),
("saveMediaInk", "PK"),
] {
let field = element(id)
field.click()
field.typeText(text)
}
XCTAssertTrue(save.isEnabled)
}
func testManageApplyMissingPrinterShowsBanner() throws {
launchApp()
let manage = app.buttons["btnMediaLibraryManage"]
XCTAssertTrue(manage.waitForExistence(timeout: 10))
manage.click()
_ = waitFor("manageMediaDialog")
let apply = element("btnMediaLibraryApply-fixture-missing-queue")
XCTAssertTrue(apply.waitForExistence(timeout: 10))
apply.click()
let notice = waitFor("noticeText")
let text = (notice.value as? String) ?? notice.label
XCTAssertTrue(
text.contains("is not installed"),
"expected not-installed notice, got: \(text)")
// A failed apply keeps the manage sheet open and the sidebar
// picker reverts.
XCTAssertTrue(element("manageMediaDialog").exists)
XCTAssertTrue(element("mediaSelect").exists)
}
}
+12 -2
View File
@@ -85,6 +85,16 @@ final class Milestone2UITests: XCTestCase {
return el
}
/// Assert an element stays absent after a short dwell unlike
/// `waitForExistence`, which always burns its full timeout on the
/// negative path.
private func assertAbsent(_ el: XCUIElement, dwell: TimeInterval = 0.5,
_ message: String = "expected element to stay absent",
file: StaticString = #filePath, line: UInt = #line) {
RunLoop.current.run(until: Date().addingTimeInterval(dwell))
XCTAssertFalse(el.exists, message, file: file, line: line)
}
private func staticText(_ exact: String) -> XCUIElement {
let inApp = app.staticTexts[exact]
if inApp.exists { return inApp }
@@ -316,7 +326,7 @@ final class Milestone2UITests: XCTestCase {
"identifier BEGINSWITH 'btnDeletePreset-'")
XCTAssertTrue(deleteButtons.firstMatch.waitForExistence(timeout: 5))
deleteButtons.firstMatch.click()
XCTAssertFalse(staticText("UI Test Preset").waitForExistence(timeout: 3))
assertAbsent(staticText("UI Test Preset"))
}
/// Export a preset to JSON and re-import it (issue #11).
@@ -348,7 +358,7 @@ final class Milestone2UITests: XCTestCase {
let deleteButtons = buttonsMatching(
"identifier BEGINSWITH 'btnDeletePreset-'")
deleteButtons.firstMatch.click()
XCTAssertFalse(staticText("RoundTrip").waitForExistence(timeout: 3))
assertAbsent(staticText("RoundTrip"))
// Copy the export to the import path so the hook picks it up.
try FileManager.default.copyItem(at: exportURL, to: importURL)
+31 -1
View File
@@ -198,7 +198,37 @@ final class Milestone3UITests: XCTestCase {
}
XCTAssertTrue(app.buttons["btnPrintAll"].isEnabled)
app.buttons["btnPrintPage-0"].click()
// The gallery cell's Print button sits at the window's bottom
// edge where synthesized scroll-wheel events are inert on the
// LazyVGrid (#132). Drag the NSScrollView's vertical AXScrollBar
// thumb instead a real scroll that re-renders the cell onscreen.
var printPage = app.buttons["btnPrintPage-0"]
let scrollDeadline = Date().addingTimeInterval(15)
while !printPage.isHittable, Date() < scrollDeadline {
let scroller = app.scrollBars.allElementsBoundByIndex
.first { $0.frame.height > $0.frame.width }
if let scroller {
scroller.coordinate(withNormalizedOffset:
CGVector(dx: 0.5, dy: 0.1))
.press(forDuration: 0.1, thenDragTo:
scroller.coordinate(withNormalizedOffset:
CGVector(dx: 0.5, dy: 0.6)))
} else {
app.scrollViews["stage-2"].scroll(byDeltaX: 0, deltaY: -1)
}
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
printPage = app.buttons["btnPrintPage-0"]
}
if printPage.isHittable {
printPage.click()
} else {
// LazyVGrid cells can report a stale a11y frame click the
// point directly; the lp argv assert below still verifies.
print("AXTREE-BEGIN frame=\(printPage.frame)\n" +
"\(app.debugDescription)\nAXTREE-END")
printPage.coordinate(withNormalizedOffset:
CGVector(dx: 0.5, dy: 0.5)).click()
}
let argv = waitForLpLine()
XCTAssertTrue(argv.contains("AP_ColorMatchingMode"), argv)
XCTAssertTrue(argv.contains("page1.tif"), argv)
+18 -15
View File
@@ -111,16 +111,9 @@ final class Milestone4UITests: XCTestCase {
}
app.buttons["btnCalibrate"].click()
// Trigger strip A.
_ = waitFor("btnTrigger", timeout: 20)
app.buttons["btnTrigger"].click()
// Trigger strip B.
_ = waitFor("btnTrigger", timeout: 20)
app.buttons["btnTrigger"].click()
// All strips read Done & Save appears.
_ = waitFor("btnDoneRead", timeout: 20)
// Trigger each strip until all are read Done & Save appears.
driveStripsUntilDone()
XCTAssertTrue(element("btnDoneRead").exists)
app.buttons["btnDoneRead"].firstMatch.click()
// Averaging panel appears with one pass snapshot.
@@ -186,11 +179,21 @@ final class Milestone4UITests: XCTestCase {
start.click()
_ = waitFor("btnCalibrate", timeout: 25)
app.buttons["btnCalibrate"].click()
_ = waitFor("btnTrigger", timeout: 20)
app.buttons["btnTrigger"].click()
_ = waitFor("btnTrigger", timeout: 20)
app.buttons["btnTrigger"].click()
_ = waitFor("btnDoneRead", timeout: 20)
driveStripsUntilDone()
XCTAssertTrue(element("btnDoneRead").exists)
app.buttons["btnDoneRead"].firstMatch.click()
}
/// Clicks Trigger for each remaining strip until `btnDoneRead`
/// appears the button is re-polled each pass so a click that
/// races a state transition isn't lost.
private func driveStripsUntilDone() {
let deadline = Date().addingTimeInterval(40)
while !element("btnDoneRead").exists, Date() < deadline {
if app.buttons["btnTrigger"].waitForExistence(timeout: 10) {
app.buttons["btnTrigger"].click()
}
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
}
}
}
File diff suppressed because one or more lines are too long
+22
View File
@@ -67,3 +67,25 @@ Built-ins cannot be deleted. Custom presets overlay by `id`. Import/export is JS
All four: `instrument: "i1"`, `colprof_algorithm: "l"`, `random_seed: 1`, `no_randomize: false`, `colprof_fwa: "D50"`.
UI: `#presetSelect`, `#btnSavePresetModal``#savePresetDialog` (`savePresetName`, `savePresetDesc`, `btnConfirmSavePreset`), `#btnOpenPresetsDialog``#managePresetsDialog` (`managePresetsList`, `btnExportActivePreset`, `btnImportPreset`).
## Media library (`media_library.json`)
Persisted at `{app_data}/media_library.json` — a sibling of `settings.json`, never a field inside it (issue #146). A `MediaRecipe` binds a CUPS queue + paper + ink set + optional `.cal` to a `ProfilingPreset`. Cap: 200 entries; the 201st is refused with an error, never silently evicted. Corrupt JSON → keep the file, load `[]`, persistent warning banner.
| Field | Type | Notes |
|-------|------|-------|
| `id` | string | `recipe-<uuid>`, never user-typed |
| `name`, `notes` | string | Rendered through `Text` only (#114) |
| `printer_id` | string | CUPS queue id (`lpstat -e` name) |
| `printer_display_name` | string | Human label; applied to `wizard.printerName` |
| `paper_name`, `ink_set` | string | Library metadata only — never written to targen flags |
| `driver_media_type` | string? | Last captured CUPS `media_type` (read-only) |
| `colour_space` | `"rgb"` \| `"cmyk"` | Must match the bound preset |
| `preset_id` | string | `ProfilingPreset.id` (built-in or custom) |
| `calibration_url` | string? | Absolute `.cal` path, stored verbatim |
| `apply_calibration` | bool | Forced off for `CAL_` stems or missing files |
| `created`, `updated` | iso8601 | |
Apply path: recipe → `applyPreset` (#82 mapping, no second Stage 1 form) → queue re-enumerated (`lpstat -e`) → `printer_id` absent from a non-empty list warns "not installed" and leaves the queue untouched; an empty list is indeterminate and never flags. `CAL_` bound cal **or** a live `CAL_` wizard basename forces `applyCalibration` off — `printtarg -K` can never see a `CAL_` file (literal refusal; escape hatch is rename + re-capture). Staleness: `.printer` = absent from enumerated queues; `.calibration` = bound cal `CREATED + calibration_stale_days < now`.
Capture with no preset selected auto-snapshots the live form as a `custom-` preset and binds to it. UI: `#mediaSelect` (immediate apply, `#presetSelect`-style), `#btnMediaLibraryCapture``#saveMediaRecipeDialog`, `#btnMediaLibraryManage``#manageMediaDialog` (`mediaLibraryList`, `mediaRow-{id}`, `btnMediaLibraryApply-{id}`, `btnMediaLibraryDelete-{id}`), stale badge `#mediaRecipeStale`.
+2 -2
View File
@@ -66,7 +66,7 @@ Do not create issues until labels and milestones exist.
- Artefact gating on disk; atomic writes (`.tmp` + rename); user strings via SwiftUI `Text` only.
- Branching: `develop``milestone/mN-<name>``feat/<issue#>-<slug>`; PRs via Gitea MCP.
- Labels: every issue/PR has `Project/ICCery-v2` + one `Feature/*` or `Bug/*` + `Priority/*`.
- Verify: `xcodebuild test -scheme ICCery -destination 'platform=macOS' ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO`; sidecar `codesign -dvv`.
- Verify: `xcodebuild test -scheme ICCery -destination 'platform=macOS' ARCHS="$(uname -m)"` (host arch; universal reserved for release packaging); sidecar `codesign -dvv`.
- Private ColorSync SPI: 2-arg `(PMPrintSession, CFStringRef) -> OSStatus`. Never pass integer `1`.
### `BUILD-PLAN.md`
@@ -566,7 +566,7 @@ Labels: `Feature/DevOps`, `Priority/High`
Milestone: M6
- **Self-hosted Mac runner** (Gitea has no `macos-latest` unless you attach one). Optional GitHub Actions mirror.
- Pipeline: `fetch-argyll` → ad-hoc `codesign -s -` + `codesign -dvv` on every sidecar Mach-O (hard fail) → `xcodebuild build test -scheme ICCery ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO` → unit + mock fixtures (#215) → **dmgbuild** with background art (**not** Finder AppleScript, #189) → upload artefact.
- Pipeline: `fetch-argyll` → ad-hoc `codesign -s -` + `codesign -dvv` on every sidecar Mach-O (hard fail) → `xcodebuild build test -scheme ICCery ARCHS="$(uname -m)"` (host arch; the dmgbuild leg still builds universal) → unit + mock fixtures (#215) → **dmgbuild** with background art (**not** Finder AppleScript, #189) → upload artefact.
- App signing: Developer ID + **notarize/staple** for the `.app` / `.dmg`. Sidecars remain **ad-hoc** inside the bundle (#165). These are two different gates — do not conflate.
- Confirm entitlements: sandbox **false**.
- Spec: [04](04-argyll-binaries.md) §0.6, [05](05-argyll-fork.md) §89, [23](23-assets.md), [24](24-issues-invariants.md).
+1 -1
View File
@@ -17,7 +17,7 @@ Unsigned CI artefacts (e.g. a `.zip` from a non-notarized workflow run) are **no
scripts/fetch-argyll.sh # populates Vendor/Argyll and signs sidecars
xcodegen generate --project .
xcodebuild test -scheme ICCery -destination 'platform=macOS' \
ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO \
ARCHS="$(uname -m)" \
CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY='-'
```
+10
View File
@@ -19,6 +19,7 @@ targets:
- path: Resources
excludes:
- ICCery.entitlements
- ICCery.Debug.entitlements
- Argyll
- path: Resources/Argyll
type: folder
@@ -62,6 +63,15 @@ targets:
OTHER_SWIFT_FLAGS: ["$(inherited)", "-strict-concurrency=minimal"]
MACOSX_DEPLOYMENT_TARGET: "12.0"
ARCHS: "$(ARCHS_STANDARD)"
# Debug builds sign ad-hoc; hardened-runtime library validation would
# reject the embedded ICCeryCore package framework (no Team ID) when
# the test host launches (run 31992, #119). DISABLE_LIBRARY_VALIDATION
# does not inject the entitlement on Xcode 14.2, so use a dedicated
# Debug entitlements file. Release keeps validation and links the
# package statically anyway.
configs:
Debug:
CODE_SIGN_ENTITLEMENTS: Resources/ICCery.Debug.entitlements
ICCeryCoreTests:
type: bundle.unit-test