diff --git a/.gitea/workflows/macos.yml b/.gitea/workflows/macos.yml new file mode 100644 index 0000000..029a94a --- /dev/null +++ b/.gitea/workflows/macos.yml @@ -0,0 +1,240 @@ +name: macOS CI + +on: + push: + branches: + - develop + tags: + - 'v*' + pull_request: + branches: + - develop + +jobs: + build-and-test: + # Prefer a self-hosted Mac runner if your Gitea has one. If not, + # macos-14 works for this pipeline. + runs-on: macos-12 + env: + DERIVED: build/DerivedData-test + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Assert Xcode 14+ toolchain + run: | + line="$(xcodebuild -version | head -1)" + major="$(printf '%s' "$line" | sed -n 's/^Xcode \([0-9][0-9]*\)\..*/\1/p')" + if [ -z "$major" ] || [ "$major" -lt 14 ]; then + echo "Unexpected Xcode version: $line" >&2; exit 1 + fi + echo "$line" + + # Tag pushes whose name contains "prerelease" skip the test build and both + # test legs: they exist to package a build already validated elsewhere. + # The job still succeeds quickly so `package`'s `needs:` stays satisfied. + # Homebrew's xcodegen formula requires Xcode 15.3, which cannot be + # installed on macOS 12 (#109). The script installs a pinned + # prebuilt release instead. dmgbuild is not installed here — the + # test job does not package (#95). + - name: Ensure host tools + if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))" + run: scripts/ensure-host-tools.sh + + - name: Generate Xcode project + if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))" + run: xcodegen generate --spec project.yml + + # Tests only ever run on the runner's own architecture; build + # just that slice. Packaging (scripts/package-release.sh) still + # produces the universal Release binary. + - name: Build for testing (host arch) + if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))" + run: | + xcodebuild build-for-testing \ + -scheme ICCery \ + -destination 'platform=macOS' \ + -derivedDataPath "$DERIVED" \ + -configuration Debug \ + 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 + if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))" + 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) + if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))" + run: | + XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)" + if [ -z "$XCTESTRUN" ] || [ ! -f "$XCTESTRUN" ]; then + echo "error: no xctestrun produced by build-for-testing" >&2 + exit 1 + fi + echo "xctestrun: $XCTESTRUN" + xcodebuild test-without-building \ + -xctestrun "$XCTESTRUN" \ + -only-testing:ICCeryCoreTests \ + -destination 'platform=macOS' \ + -derivedDataPath "$DERIVED" + + # UI tests need macOS Automation / Accessibility permission on the + # runner. The self-hosted Mac intermittently times out enabling + # that mode (run 29700) or launches the app into + # `.runningBackground` without ever activating it (run 29804). + # Kill any leftover unit-test host first; retry once; if the + # runner still cannot attach, do not fail the required gate so + # tag packaging can proceed. Real XCTest assertion failures + # still fail the job. + - name: Test UI (ICCeryUITests) + if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))" + run: | + set -o pipefail + XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)" + LOG="$DERIVED/ui-test.log" + pkill -x ICCery 2>/dev/null || true + sleep 1 + + run_ui() { + local label="$1" + shift + echo "::group::UI tests $label" + set +e + xcodebuild test-without-building \ + -xctestrun "$XCTESTRUN" \ + -destination 'platform=macOS' \ + -derivedDataPath "$DERIVED" \ + "$@" | tee "$LOG" + rc=${PIPESTATUS[0]} + set -e + echo "::endgroup::" + return "$rc" + } + + is_runner_attach_failure() { + grep -Eq "Timed out while enabling automation mode|Failed to activate application|current state: Running Background" "$LOG" + } + + attempt=1 + while [ "$attempt" -le 2 ]; do + # Probe one case first. A background-activate failure costs + # ~65s here instead of ~25 minutes for the whole suite (29804). + if ! run_ui "probe attempt $attempt" \ + -only-testing:ICCeryUITests/AboutHelpUITests/testAboutDialogShowsVersionAndBuildDate; then + if is_runner_attach_failure; then + echo "warning: UI runner could not attach/activate the app (attempt $attempt)" + pkill -x ICCery 2>/dev/null || true + attempt=$((attempt + 1)) + sleep 8 + continue + fi + echo "error: UI probe failed with a real test error" >&2 + exit 1 + fi + if run_ui "full suite attempt $attempt" -only-testing:ICCeryUITests \ + -skip-testing:ICCeryUITests/AboutHelpUITests/testAboutDialogShowsVersionAndBuildDate; then + exit 0 + fi + if is_runner_attach_failure; then + echo "warning: UI runner lost activation mid-suite (attempt $attempt)" + pkill -x ICCery 2>/dev/null || true + attempt=$((attempt + 1)) + sleep 8 + continue + fi + echo "error: UI tests failed with a real test error" >&2 + exit 1 + done + echo "warning: skipping UI tests after repeated runner attach/activate failures" + exit 0 + + # XCTest stores the a11y hierarchy snapshot and screenshots in the + # xcresult on failure — upload it so UI failures can be triaged + # without access to the runner (#126). + - name: Prepare Node CA bundle (failure path) + if: "failure() && !(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))" + 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() && !(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))" + 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 + if: github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/tags/v') + steps: + - name: Checkout + uses: actions/checkout@v4 + + # scripts/package-release.sh runs `xcodegen generate` and dmgbuild; + # see build-and-test for why brew is not used on macOS 12 (#109). + # INSTALL_DMGBUILD isolates dmgbuild in build/.venv-dmgbuild so + # the test job never pip-installs it (#95). + - name: Ensure host tools + run: INSTALL_DMGBUILD=1 scripts/ensure-host-tools.sh + + - name: Package release + run: scripts/package-release.sh + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + CODESIGN_IDENTITY: ${{ secrets.CODESIGN_IDENTITY }} + DEVELOPMENT_TEAM: ${{ secrets.DEVELOPMENT_TEAM }} + NOTARIZE_APPLE_ID: ${{ secrets.NOTARIZE_APPLE_ID }} + NOTARIZE_PASSWORD: ${{ secrets.NOTARIZE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + + - name: Prepare Node CA bundle + 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 DMG artifact + uses: actions/upload-artifact@v3 + env: + NODE_EXTRA_CA_CERTS: /tmp/macos-ca-bundle.pem + with: + name: iccery-dmg + path: ICCery-*.dmg + + - name: Attach DMG to Gitea release + if: startsWith(github.ref, 'refs/tags/v') + run: scripts/attach-release-asset.sh + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + GITHUB_TOKEN: ${{ github.token }} + GITEA_SERVER_URL: ${{ github.server_url }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_REF_NAME: ${{ github.ref_name }} + GITHUB_SHA: ${{ github.sha }} diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml new file mode 100644 index 0000000..0ff14c9 --- /dev/null +++ b/.github/workflows/macos.yml @@ -0,0 +1,231 @@ +# GitHub Actions twin of .gitea/workflows/macos.yml. +# Deltas from the Gitea file (everything else is the same jobs/steps): +# - runs-on macos-14: github.com retired macos-12. Do not use +# macos-latest — in 2026 that is macos-26-arm64, where a 1280×800 +# window hangs off the virtual display and XCTest marks sidebar +# controls (x ≈ -116) as not hittable (run 34864198118). +# - actions/upload-artifact@v4: v3 is shut down on github.com. Gitea act_runner +# still uses v3. +# - No NODE_EXTRA_CA_CERTS / System keychain bundle: that is only for the +# private Gitea CA when the runner talks to git.i3omb.com. +# - Tag DMGs go to a GitHub Release via `gh` instead of +# scripts/attach-release-asset.sh (Gitea /api/v1). +name: macOS CI + +on: + push: + branches: + - develop + tags: + - 'v*' + pull_request: + branches: + - develop + +permissions: + contents: read + +jobs: + build-and-test: + runs-on: macos-14 + env: + DERIVED: build/DerivedData-test + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Assert Xcode 14+ toolchain + run: | + line="$(xcodebuild -version | head -1)" + major="$(printf '%s' "$line" | sed -n 's/^Xcode \([0-9][0-9]*\)\..*/\1/p')" + if [ -z "$major" ] || [ "$major" -lt 14 ]; then + echo "Unexpected Xcode version: $line" >&2; exit 1 + fi + echo "$line" + + # Tag pushes whose name contains "prerelease" skip the test build and both + # test legs: they exist to package a build already validated elsewhere. + # The job still succeeds quickly so `package`'s `needs:` stays satisfied. + # Homebrew's xcodegen formula requires Xcode 15.3, which cannot be + # installed on macOS 12 (#109). The script installs a pinned + # prebuilt release instead. dmgbuild is not installed here — the + # test job does not package (#95). + - name: Ensure host tools + if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))" + run: scripts/ensure-host-tools.sh + + - name: Generate Xcode project + if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))" + run: xcodegen generate --spec project.yml + + # Tests only ever run on the runner's own architecture; build + # just that slice. Packaging (scripts/package-release.sh) still + # produces the universal Release binary. + - name: Build for testing (host arch) + if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))" + run: | + xcodebuild build-for-testing \ + -scheme ICCery \ + -destination 'platform=macOS' \ + -derivedDataPath "$DERIVED" \ + -configuration Debug \ + 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 + if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))" + 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) + if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))" + run: | + XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)" + if [ -z "$XCTESTRUN" ] || [ ! -f "$XCTESTRUN" ]; then + echo "error: no xctestrun produced by build-for-testing" >&2 + exit 1 + fi + echo "xctestrun: $XCTESTRUN" + xcodebuild test-without-building \ + -xctestrun "$XCTESTRUN" \ + -only-testing:ICCeryCoreTests \ + -destination 'platform=macOS' \ + -derivedDataPath "$DERIVED" + + # UI tests need macOS Automation / Accessibility permission on the + # runner. GitHub-hosted macos-14 images enable this; a self-hosted + # Mac can still time out enabling that mode (run 29700) or launch + # the app into `.runningBackground` (run 29804). Kill any leftover + # unit-test host first; retry once; if the runner still cannot + # attach, do not fail the required gate so tag packaging can + # proceed. Real XCTest assertion failures still fail the job. + - name: Test UI (ICCeryUITests) + if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))" + run: | + set -o pipefail + XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)" + LOG="$DERIVED/ui-test.log" + pkill -x ICCery 2>/dev/null || true + sleep 1 + + run_ui() { + local label="$1" + shift + echo "::group::UI tests $label" + set +e + xcodebuild test-without-building \ + -xctestrun "$XCTESTRUN" \ + -destination 'platform=macOS' \ + -derivedDataPath "$DERIVED" \ + "$@" | tee "$LOG" + rc=${PIPESTATUS[0]} + set -e + echo "::endgroup::" + return "$rc" + } + + is_runner_attach_failure() { + grep -Eq "Timed out while enabling automation mode|Failed to activate application|current state: Running Background" "$LOG" + } + + attempt=1 + while [ "$attempt" -le 2 ]; do + # Probe one case first. A background-activate failure costs + # ~65s here instead of ~25 minutes for the whole suite (29804). + if ! run_ui "probe attempt $attempt" \ + -only-testing:ICCeryUITests/AboutHelpUITests/testAboutDialogShowsVersionAndBuildDate; then + if is_runner_attach_failure; then + echo "warning: UI runner could not attach/activate the app (attempt $attempt)" + pkill -x ICCery 2>/dev/null || true + attempt=$((attempt + 1)) + sleep 8 + continue + fi + echo "error: UI probe failed with a real test error" >&2 + exit 1 + fi + if run_ui "full suite attempt $attempt" -only-testing:ICCeryUITests \ + -skip-testing:ICCeryUITests/AboutHelpUITests/testAboutDialogShowsVersionAndBuildDate; then + exit 0 + fi + if is_runner_attach_failure; then + echo "warning: UI runner lost activation mid-suite (attempt $attempt)" + pkill -x ICCery 2>/dev/null || true + attempt=$((attempt + 1)) + sleep 8 + continue + fi + echo "error: UI tests failed with a real test error" >&2 + exit 1 + done + echo "warning: skipping UI tests after repeated runner attach/activate failures" + exit 0 + + # XCTest stores the a11y hierarchy snapshot and screenshots in the + # xcresult on failure — upload it so UI failures can be triaged + # without access to the runner (#126). + - name: Upload UI test xcresult + if: "failure() && !(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))" + uses: actions/upload-artifact@v4 + with: + name: ui-test-xcresult + path: build/DerivedData-test/Logs/Test + + package: + needs: build-and-test + runs-on: macos-14 + if: github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/tags/v') + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + # scripts/package-release.sh runs `xcodegen generate` and dmgbuild; + # see build-and-test for why brew is not used on macOS 12 (#109). + # INSTALL_DMGBUILD isolates dmgbuild in build/.venv-dmgbuild so + # the test job never pip-installs it (#95). + - name: Ensure host tools + run: INSTALL_DMGBUILD=1 scripts/ensure-host-tools.sh + + - name: Package release + run: scripts/package-release.sh + env: + CODESIGN_IDENTITY: ${{ secrets.CODESIGN_IDENTITY }} + DEVELOPMENT_TEAM: ${{ secrets.DEVELOPMENT_TEAM }} + NOTARIZE_APPLE_ID: ${{ secrets.NOTARIZE_APPLE_ID }} + NOTARIZE_PASSWORD: ${{ secrets.NOTARIZE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + + - name: Upload DMG artifact + uses: actions/upload-artifact@v4 + with: + name: iccery-dmg + path: ICCery-*.dmg + + - name: Attach DMG to GitHub release + if: startsWith(github.ref, 'refs/tags/v') + env: + GH_TOKEN: ${{ github.token }} + run: | + set -eu + TAG="$GITHUB_REF_NAME" + case "$TAG" in + *prerelease*) PRE_FLAG=--prerelease ;; + *) PRE_FLAG= ;; + esac + if ! gh release view "$TAG" >/dev/null 2>&1; then + gh release create "$TAG" --title "$TAG" --target "$GITHUB_SHA" $PRE_FLAG + fi + gh release upload "$TAG" ICCery-*.dmg --clobber diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e1f98dc --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Xcode +*.xcuserstate +xcuserdata/ +DerivedData/ +*.xccheckout +*.moved-aside +*.xcscmblueprint +*.xccrashreport + +# Swift Package Manager +.build/ +.swiftpm/ +Package.resolved + +# Fetched Argyll sidecars (release artefacts, not git blobs — #127) +Vendor/Argyll/ + +# XcodeGen output (regenerate with `make gen`) +ICCery.xcodeproj/ + +# macOS +.DS_Store + +# Release artefacts (not git blobs) +*.dmg +*.zip +Release/ +notarization/ +build/ +docs/megaplans/* +docs/megaplans diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6344dd5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,56 @@ +# AGENTS.md — ICCery v2 Mac + +## Product +Native macOS printer ICC/ICM profiling frontend. Drives the Gronod ArgyllCMS 3.5.0 fork as AGPL-isolated subprocesses. Spec snapshot lives in `docs/` (chapters 01–25). Ticket plan: `BUILD-PLAN.md`. + +## Stack +- SwiftUI (`@Observable`, `@MainActor` view models) + AppKit for printing / panels / file dialogs. +- Minimum macOS **14.0**. Universal `arm64` + `x86_64`. +- Bundle id **`com.gronod.iccery2`**. Product name ICCery. +- App Sandbox **OFF**. Hardened Runtime **ON**. Entitlements in `ICCery.entitlements`. +- No Tauri, no Rust host, no WKWebView, no Three.js. + +## Package layout +- `ICCery` — app target (SwiftUI shell). +- `ICCeryCore` — wizard state, ProcessManager, argv builders, settings, CGATS, ΔE₀₀ (no AppKit print panel). +- `ICCeryPrintKit` — v2.1 only (issue 16). Zero deps on wizard types. + +## AGPL boundary +Never link Argyll. Spawn only. +- Streaming: `ProcessManager` actor (`targen`, `printtarg`, `chartread`, `average`, `colprof`, `profcheck`, `iccgamut`, `instlist`). +- Captured: `runCaptured` (`printcal`, `applycal` only). +Both paths set `ARGYLL_NOT_INTERACTIVE=1`. Never search `$PATH` for binaries. + +## Concurrency +No blocking subprocess I/O on `@MainActor`. +Do not hop to main per stdout line (colprof emits thousands of `.`). +Stdin handle is independent of wait (#84). Process ids are exclusive leases (#116). +`killAll` on `NSApplication.willTerminate` and last-window close (#147, #149). +XY cancel: send `q\n`, wait ~500 ms, then kill. + +## Argyll flag discipline +See `docs/25-rewrite-notes.md` and `docs/04-argyll-binaries.md` §15. +`-d` / `-r` / `-R` / `-u` / `-Y` / `-c` mean different things per tool. +v2.0 `-u` policy: printtarg + chartread + profcheck only. + +## Files +Artefact gating on disk. No placeholder basenames (#60). +Empty cwd illegal (#59). Atomic writes = `.tmp` + rename (#213). +User-supplied strings via SwiftUI `Text` only (#114). +TIFF never rendered directly — host-side PNG preview (#58). + +## Branching +`develop` ← `milestone/mN-` ← `feat/-`. +PRs via Gitea MCP. Every issue/PR: `Project/ICCery-v2` + `Feature/*` or `Bug/*` + `Priority/*`. + +## Verify +``` +xcodebuild test -scheme ICCery -destination 'platform=macOS' ARCHS="$(uname -m)" +codesign -dvv +``` +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`. +Modes: `AP_ApplicationColorMatching` then `ApplicationColorMatching`. +`lp` path and Quartz/`ICCeryPrintKit` path use **different** ColorSync dictionaries. Never mix. diff --git a/BUILD-PLAN.md b/BUILD-PLAN.md new file mode 100644 index 0000000..049fa24 --- /dev/null +++ b/BUILD-PLAN.md @@ -0,0 +1,54 @@ +# BUILD-PLAN.md — ICCery v2 Mac + +Spec snapshot: `docs/`. Source of tickets: Gitea milestones M1–M6 + M10 (id 32) + Later. + +## Sprint rule +Do not start milestone N+1 implementation until milestone N **CI/mock gate** is green. +Hardware gates block *release of that sprint*, not filing, and not starting coding of the next sprint's non-dependent tickets. + +## Milestone map + +| Id | Name | Issues | CI/mock gate | Hardware gate | +|----|------|--------|--------------|---------------| +| M1 | Foundation & process core | 1–6 | App launches; wizard shell; ProcessManager + `runCaptured`; artefact gating tests; settings persist + dialog | N/A | +| M2 | Target generation & layout | 7–11 | targen → `.ti1`; printtarg → `.ti2`+TIFF; manifest+gallery; resume; presets | N/A | +| M3 | Unmanaged printing (`lp`) | 12–15, 17 | parsers; `build_lp_args` goldens (both `AP_*`); cancel → nil | Preferences shows driver PDE; unmanaged page on Epson or Canon | +| M4 | Measurement | 18–22 | `chartread.mock`; 39+ classifier fixtures; ΔE₀₀; snapshot/average | Detect real instrument; one strip or XY through Done → `.ti3` | +| M5 | Profile / verify / install | 23–27 | colprof → `.icc`; profcheck parse; atomic history; install into temp dir | Full `.ti1`→`.icc`; profile visible in ColorSync Utility | +| M6 | Gamut, Stage 0, CGATS, release | 28–32 | `.gam` fixtures; cal argv; CGATS round-trip; signed sidecars; dmgbuild | Stage 0 on a real printer; gamut of a real profile | +| M7 | Deduplicate & consolidate | 79–86 | Shared runner loop; JSONFileStore; preset↔config maps; Notice/log helper; ProcessManager factory; PrintSession VM; identity + colour-type cleanup | N/A | +| M8 | Deduplicate & consolidate | 79–86 | (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 | 146–149 | Media library + spot-read + gamut compare + project file unit/UI smoke | Real printer+paper+.cal; live spot-read; two `.gam`; reopen `.icceryproj` | +| Later | Quartz / TargetPrint | 16 | `ICCeryPrintKit` standalone + seam test | 1:1 on paper vs TIFF | + +M8 and M9 merged to `develop` via PR #104 / #145; M10 starts from `800c980`. + +Issue **16 is not an M3, M6, or M10 exit gate.** + +## Branch taxonomy + +```mermaid +flowchart LR + main[main] + develop[develop] + m1["milestone/m1-foundation"] + m2["milestone/m2-targets"] + feat["feat/7-targen-argv"] + main --> develop + develop --> m1 + develop --> m2 + m2 --> feat +``` + +Quoted node labels are required (v0.8.5 #80). + +## Command surface (parity with v0.8.5, native names) + +Process: `spawn`, `sendStdin`, `kill`, `killAll`, `resolveBinary`, `runCaptured`. +Files: dedicated picker per purpose; `readTiffPreviewPng`; `parseTi2Header`. +Wizard: `verifyStageArtefacts`, `getProfilePath`, `snapshotTi3`, `promoteTi3`. +Runners: `runTargen`, `runPrinttarg`, `runChartread`, `runAverage`, `runColprof`, `runProfcheck`, `extractGamut`, `detectInstruments`. +Cal: `generateCalibrationTarget`, `computeCalibrationCurves`, `applyCalibration`, `parseCalFile`, library + project state. +Print: `getPrinters`, `getPrinterCapabilities`, `showPrinterProperties`, `printTargetNative`. +Install / quality / settings / CGATS: same semantics as `docs/25-rewrite-notes.md` host command list. diff --git a/LICENCE.md b/LICENCE.md new file mode 100644 index 0000000..69c6431 --- /dev/null +++ b/LICENCE.md @@ -0,0 +1,20 @@ +# LICENCE + +**Copyright (c) 2026 Gordon Bolton** +**All Rights Reserved.** + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), strictly to view the source code and execute the Software for the sole purpose of personal testing, evaluation, and providing feedback. + +Under this licence, you may **not**: + +* Modify, alter, or create derivative works of the Software. +* Distribute, publish, or sublicense the Software or any derivatives. +* Use the Software for any commercial or production purpose. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--- + +## Bundled ArgyllCMS sidecar binaries + +This application bundles and invokes command-line binaries from the Gronod fork of ArgyllCMS. Those binaries are licensed separately under the **GNU Affero General Public License v3 (AGPLv3)**. They are executed strictly as independent subprocesses — they are never linked, loaded, or incorporated into this application — and a copy of `License.txt` is shipped beside the binaries in `Resources/argyll/`. The terms above apply only to the ICCery application source code, not to the ArgyllCMS binaries. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..70e8da4 --- /dev/null +++ b/Makefile @@ -0,0 +1,23 @@ +SCHEME := ICCery +DEST := 'platform=macOS' + +.PHONY: gen build test universal fetch-argyll clean + +gen: + xcodegen generate + +build: gen + xcodebuild build -scheme $(SCHEME) -destination $(DEST) + +test: gen + xcodebuild build test -scheme $(SCHEME) -destination $(DEST) + +universal: gen + xcodebuild build -scheme $(SCHEME) -destination $(DEST) \ + ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO + +fetch-argyll: + scripts/fetch-argyll.sh + +clean: + rm -rf ICCery.xcodeproj DerivedData Packages/ICCeryCore/.build diff --git a/PROPOSED_CHANGES.md b/PROPOSED_CHANGES.md new file mode 100644 index 0000000..56610b4 --- /dev/null +++ b/PROPOSED_CHANGES.md @@ -0,0 +1,15 @@ +## PROP-M6-MEGAPLAN +Status: PENDING +Intent: Produce the M6 milestone planning deliverables (M6-MEGAPLAN.md, M6-BRANCH-MAP.md, M6-RISK-REGISTER.md) per the M6_MEGAPLAN_PROMPT.md baseline. + +## PROP-M6-MEGAPLAN-REV1 +Status: PENDING +Intent: Revise M6 megaplan per review (unstack #28, pre-split #29/#30, name CGATS open-panel test, sRGB.gam gate, Phase 0 spot-check). + +## PROP-M7-MEGAPLAN +Status: PENDING +Intent: Plan M7 UAT-ready hardening + repo cleanup. No product code in this step. #16 out. + +## PROP-M7-CI-RUNNER-SIGN-FIX +Status: PENDING +Intent: Correct the M7 U1 CI signing block so `*Runner.app` is not manually re-signed with `codesign --options runtime`; preserves the `xctrunner` entitlements and allows `ICCeryUITests` to boot. diff --git a/Packages/ICCeryCore/Package.swift b/Packages/ICCeryCore/Package.swift new file mode 100644 index 0000000..5fde803 --- /dev/null +++ b/Packages/ICCeryCore/Package.swift @@ -0,0 +1,13 @@ +// swift-tools-version: 5.7 +import PackageDescription + +let package = Package( + name: "ICCeryCore", + platforms: [.macOS(.v12)], + products: [ + .library(name: "ICCeryCore", targets: ["ICCeryCore"]), + ], + targets: [ + .target(name: "ICCeryCore"), + ] +) diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/ArgsBuilder.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/ArgsBuilder.swift new file mode 100644 index 0000000..c858a06 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/ArgsBuilder.swift @@ -0,0 +1,35 @@ +import Foundation + +/// Tiny argv helpers. Each Argyll tool keeps its own `*Args` enum — +/// `-d` / `-u` / `-r` still mean different things per binary. +public enum ArgsBuilder { + /// `["-f", value]` when `value` is non-nil. + public static func option(_ flag: String, _ value: String?) -> [String] { + guard let value else { return [] } + return [flag, value] + } + + /// `["-f", trimmed]` when trimmed is non-empty. + public static func optionIfNonEmpty(_ flag: String, _ value: String?) -> [String] { + guard let raw = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty else { return [] } + return [flag, raw] + } + + /// Omits the flag when `value` is nil or within `epsilon` of `skip`. + public static func optionUnlessApprox( + _ flag: String, + _ value: Double?, + skip: Double, + epsilon: Double = 0.001, + format: String = "%.2f" + ) -> [String] { + guard let value, abs(value - skip) >= epsilon else { return [] } + return [flag, String(format: format, locale: Locale(identifier: "en_US_POSIX"), value)] + } + + /// Bare flag when `when` is true. + public static func flag(_ flag: String, when: Bool) -> [String] { + when ? [flag] : [] + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/ArgyllRunner.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/ArgyllRunner.swift new file mode 100644 index 0000000..df8b192 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/ArgyllRunner.swift @@ -0,0 +1,1009 @@ +import Foundation + +/// Errors from `ArgyllRunner` executions. +public enum ArgyllRunnerError: LocalizedError, Equatable, Sendable { + case toolFailed(tool: String, code: Int32, logs: [String]) + case missingArtefact(String) + case malformedManifest(String) + case instrumentDetectionFailed(String) + case profcheckUnparseable + + public var errorDescription: String? { + switch self { + case .toolFailed(let tool, let code, let logs): + let detail = logs.last.flatMap { $0.isEmpty ? nil : $0 } + ?? "exited with code \(code)" + switch tool { + case "chartread": + return "Chartread failed: \(detail)" + case "average": + return "Averaging failed: \(detail)" + case "colprof": + return "Profile creation failed: \(detail)" + case "printcal": + return "Calibration curve computation failed: \(detail)" + case "applycal": + return "Apply calibration failed: \(detail)" + case "iccgamut": + return "Gamut extraction failed: \(detail)" + case "profcheck": + return "Profile verification failed: \(detail)" + default: + return "Process exited with code \(code)" + } + case .missingArtefact(let path): + return "Expected output file was not created: \(path)" + case .malformedManifest(let reason): + return "Failed to parse printtarg manifest: \(reason)" + case .instrumentDetectionFailed(let reason): + return "Instrument detection failed: \(reason)" + case .profcheckUnparseable: + return "Profile verification produced unparseable output" + } + } +} + +/// Result of a successful `printtarg` run: the `.ti2` artefact plus the +/// validated manifest with per-page PNG previews already decoded. +public struct PrinttargResult: Sendable, Equatable { + public let ti2URL: URL + public let manifest: PrinttargManifest + public let pages: [GalleryPage] +} + +/// Service driving Argyll subprocesses off the main actor +/// (docs/03, docs/08, docs/09). +/// +/// - Subscribes to the event bus *before* spawning so no stdout or exit +/// is ever lost (subscription is synchronous in `ProcessManager`). +/// - Accumulates stdout/stderr without touching `@MainActor`; the +/// optional `onLogBatch` callback receives coalesced chunks (20 lines +/// or ~100 ms), never one call per line. +/// - Exit code 0 is necessary but not sufficient: the expected artefact +/// (`.ti1` / `.ti2`) must exist on disk, and printtarg must emit a +/// valid `-u` manifest. +public struct ArgyllRunner: Sendable { + public let processManager: ProcessManager + public let binaryResolver: BinaryResolver + + public init( + processManager: ProcessManager = .shared, + binaryResolver: BinaryResolver = BinaryResolver() + ) { + self.processManager = processManager + self.binaryResolver = binaryResolver + } + + // MARK: - Shared streaming loop (issue #79) + + private func runStreamingTool( + name: String, + id: String, + arguments: [String], + workingDirectory: URL?, + flushPartialLines: Bool = false, + onLogBatch: (@Sendable ([String]) -> Void)? = nil + ) async throws -> CollectedRun { + let binaryURL = binaryResolver.resolve(name) + await ensureNotRunning(id: id) + let events = processManager.events() + try await processManager.runStreaming( + id: id, + binary: binaryURL, + arguments: arguments, + workingDirectory: workingDirectory + ) + let run = await collect( + id: id, + events: events, + onLogBatch: onLogBatch, + flushPartialLines: flushPartialLines + ) + guard run.exitCode == 0 else { + throw ArgyllRunnerError.toolFailed( + tool: name, + code: run.exitCode ?? -1, + logs: run.lines + ) + } + return run + } + + private func requireArtefact(_ url: URL) throws -> URL { + guard FileManager.default.fileExists(atPath: url.path) else { + throw ArgyllRunnerError.missingArtefact(url.path) + } + return url + } + + // MARK: - targen (Stage 1) + + /// Runs `targen` streaming, collecting logs and verifying `.ti1` + /// upon completion. Returns the `.ti1` URL. + public func runTargen( + config: TargenConfig, + onLogBatch: (@Sendable ([String]) -> Void)? = nil + ) async throws -> URL { + let cleanBasename = try PathSecurity.sanitizeBasename(config.basename) + let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory) + let args = try TargenArgs.build(config: config) + let processId = ProcessID.targen(cleanBasename) + _ = try await runStreamingTool( + name: "targen", + id: processId, + arguments: args, + workingDirectory: cwd, + onLogBatch: onLogBatch + ) + let ti1URL = cwd.appendingPathComponent("\(cleanBasename).ti1") + return try requireArtefact(ti1URL) + } + + // MARK: - printtarg (Stage 2) + + /// Runs `printtarg` streaming, then parses the `-u` manifest from + /// the complete accumulated stdout and loads each page's PNG + /// preview via `TiffPreview` (host-side, never raw TIFF to the UI). + public func runPrinttarg( + config: PrinttargConfig, + onLogBatch: (@Sendable ([String]) -> Void)? = nil + ) async throws -> PrinttargResult { + let cleanBasename = try PathSecurity.sanitizeBasename(config.basename) + let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory) + let args = try PrinttargArgs.build(config: config) + let processId = ProcessID.printtarg(cleanBasename) + let run = try await runStreamingTool( + name: "printtarg", + id: processId, + arguments: args, + workingDirectory: cwd, + onLogBatch: onLogBatch + ) + let ti2URL = try requireArtefact(cwd.appendingPathComponent("\(cleanBasename).ti2")) + + let manifest: PrinttargManifest + do { + manifest = try PrinttargManifestExtractor.manifest(from: run.stdout) + } catch { + throw ArgyllRunnerError.malformedManifest(error.localizedDescription) + } + + let pages = manifest.pages.enumerated().map { index, page -> GalleryPage in + let fileURL = cwd.appendingPathComponent(page.filename) + guard FileManager.default.fileExists(atPath: fileURL.path) else { + return GalleryPage( + index: index, page: page, fileURL: fileURL, + previewPNG: nil, previewError: "File not found" + ) + } + if let png = TiffPreview.previewPNG(tiff: fileURL) { + return GalleryPage( + index: index, page: page, fileURL: fileURL, + previewPNG: png, previewError: nil + ) + } + return GalleryPage( + index: index, page: page, fileURL: fileURL, + previewPNG: nil, previewError: "Could not decode TIFF" + ) + } + return PrinttargResult(ti2URL: ti2URL, manifest: manifest, pages: pages) + } + + // 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). + private func ensureNotRunning(id: String) async { + guard await processManager.isRunning(id) else { return } + await processManager.kill(id: id) + var attempts = 0 + while await processManager.isRunning(id), attempts < 30 { + try? await Task.sleep(nanoseconds: Self.testAwareDelay(100_000_000)) + attempts += 1 + } + } + + private struct CollectedRun { + var exitCode: Int32? + var stdout: String + var stderr: String + var lines: [String] + } + + /// Drains the event stream until this child's `exit` event. + /// stdout is accumulated both per-line (logs) and verbatim (for + /// the manifest parse — the pretty JSON needs its newlines). + /// + /// When `flushPartialLines` is `true`, a background `Task` flushes + /// unterminated output every 500 ms so tools like `colprof` that + /// print dots without newlines still produce log batches. + private func collect( + id processId: String, + events: AsyncStream, + onLogBatch: (@Sendable ([String]) -> Void)?, + flushPartialLines: Bool = false + ) async -> CollectedRun { + var lines: [String] = [] + var stdout = "" + var stderr = "" + var pendingBatch: [String] = [] + var exitCode: Int32? + var lastFlush = Date() + + func flush(_ batch: inout [String]) { + guard !batch.isEmpty else { return } + let out = batch + batch.removeAll(keepingCapacity: true) + onLogBatch?(out) + } + + var dotFlushTask: Task? + if flushPartialLines { + dotFlushTask = Task { [processManager] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 500_000_000) + if Task.isCancelled { break } + await processManager.flushPartialLine(id: processId) + } + } + } + + for await event in events { + guard event.id == processId else { continue } + switch event { + case .stdout(_, let line): + lines.append(line) + stdout += line + "\n" + pendingBatch.append(line) + case .stderr(_, let line): + lines.append(line) + stderr += line + "\n" + pendingBatch.append(line) + case .error(_, let message): + lines.append("Error: \(message)") + pendingBatch.append("Error: \(message)") + case .jsonRow: + // Only chartread emits these; targen/printtarg never do. + break + case .exit(_, let code): + exitCode = code + } + if exitCode == nil, + pendingBatch.count >= 20 + || Date().timeIntervalSince(lastFlush) >= 0.1 { + flush(&pendingBatch) + lastFlush = Date() + } + if exitCode != nil { + flush(&pendingBatch) + break + } + } + + dotFlushTask?.cancel() + if let dotFlushTask { + _ = await dotFlushTask.value + } + + return CollectedRun(exitCode: exitCode, stdout: stdout, stderr: stderr, lines: lines) + } + + // MARK: - instlist (Stage 3 detection) + + /// Runs `instlist` and returns the detected devices. + /// + /// The fork emits pretty-printed JSON; if that cannot be decoded a regex + /// fallback constrained to known instrument tokens is used. + public func detectInstruments() async throws -> [InstrumentDevice] { + let binaryURL = binaryResolver.resolve("instlist") + let processId = ProcessID.instlist + + await ensureNotRunning(id: processId) + let events = processManager.events() + try await processManager.runStreaming( + id: processId, + binary: binaryURL, + arguments: [], + workingDirectory: nil + ) + + var accumulator = JSONAccumulator() + var stdout = "" + var stderr: [String] = [] + var exitCode: Int32? + + for await event in events { + guard event.id == processId else { continue } + switch event { + case .stdout(_, let line): + stdout += line + "\n" + _ = accumulator.feed(line: line) + case .stderr(_, let line): + stderr.append(line) + case .exit(_, let code): + exitCode = code + default: + break + } + if exitCode != nil { break } + } + + if let data = accumulator.completeData ?? stdout.trimmingCharacters(in: .whitespacesAndNewlines).data(using: .utf8) { + if let devices = try? InstrumentParser.parse(String(data: data, encoding: .utf8) ?? stdout) { + return devices + } + } + + if let code = exitCode, code != 0, stderr.isEmpty == false { + throw ArgyllRunnerError.instrumentDetectionFailed(stderr.joined(separator: "\n")) + } + + // Final fallback: try to parse the raw stdout as a text document. + if let devices = try? InstrumentParser.parse(stdout) { + return devices + } + + throw ArgyllRunnerError.instrumentDetectionFailed("Could not parse instlist output") + } + + // MARK: - average (Stage 3 multi-pass finish) + + /// Runs `average` to merge two or more pass snapshots into the canonical `.ti3`. + public func runAverage( + config: AverageConfig, + onLogBatch: (@Sendable ([String]) -> Void)? = nil + ) async throws -> URL { + let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory) + let args = try AverageArgs.build(config: config) + let processId = ProcessID.average(config.basename) + _ = try await runStreamingTool( + name: "average", + id: processId, + arguments: args, + workingDirectory: cwd, + onLogBatch: onLogBatch + ) + let canonical = cwd.appendingPathComponent("\(config.basename).ti3") + return try requireArtefact(canonical) + } + + // MARK: - colprof (Stage 4) + + /// Runs `colprof` streaming, collecting logs and classifying progress + /// until the profile is written. + public func runColprof( + config: ColprofConfig, + onLogBatch: (@Sendable ([String]) -> Void)? = nil + ) async throws -> URL { + let cleanBasename = try PathSecurity.sanitizeBasename(config.basename) + let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory) + let args = try ColprofArgs.build(config: config) + let processId = ProcessID.colprof(cleanBasename) + _ = try await runStreamingTool( + name: "colprof", + id: processId, + arguments: args, + workingDirectory: cwd, + flushPartialLines: true, + onLogBatch: onLogBatch + ) + + // Argyll may produce `.icm` on Windows, but on macOS we expect `.icc`. + // `resolveProfile` checks `.icm` first, then `.icc`, matching #69. + guard let profileURL = ArtefactProbe.resolveProfile( + basename: cleanBasename, + cwd: cwd + ) else { + let defaultURL = cwd.appendingPathComponent("\(cleanBasename).icc") + throw ArgyllRunnerError.missingArtefact(defaultURL.path) + } + return profileURL + } + + // MARK: - applycal (post-colprof calibration curve) + + /// Embeds a `.cal` curve into an `.icc`/`.icm` profile. + /// + /// Runs `applycal` captured and performs an in-place replace via + /// `{input}.applycal.tmp` then `replaceItemAt`. On failure the tmp + /// file is removed and the original is left untouched. The UI must + /// never request `unapply` (#52). + public func runApplycal( + config: ApplycalConfig + ) async throws -> URL { + assert(!config.unapply, "runApplycal does not support unapply") + + let inputURL = config.inputProfileURL + let cwd = inputURL.deletingLastPathComponent() + let binaryURL = binaryResolver.resolve("applycal") + let processId = ProcessID.applycal(inputURL.lastPathComponent) + + let tmpURL = inputURL.appendingPathExtension("applycal.tmp") + let fm = FileManager.default + + // Remove any stale tmp from a previous crash. + try? fm.removeItem(at: tmpURL) + + await ensureNotRunning(id: processId) + + let outputConfig = ApplycalConfig( + calibrationPath: config.calibrationPath, + inputProfileURL: inputURL, + outputProfileURL: tmpURL, + unapply: false + ) + let outputArgs = try ApplycalArgs.build(config: outputConfig) + + let result = try await processManager.runCaptured( + id: processId, + binary: binaryURL, + arguments: outputArgs, + workingDirectory: cwd + ) + + guard result.exitCode == 0, !Task.isCancelled else { + try? fm.removeItem(at: tmpURL) + if Task.isCancelled { + throw CancellationError() + } + throw ArgyllRunnerError.toolFailed( + tool: "applycal", + code: result.exitCode, + logs: [result.stderr.isEmpty + ? "applycal exited with code \(result.exitCode)" + : result.stderr] + ) + } + + guard fm.fileExists(atPath: tmpURL.path) else { + throw ArgyllRunnerError.toolFailed( + tool: "applycal", + code: -1, + logs: ["applycal did not create temp profile"] + ) + } + + let attrs = try? fm.attributesOfItem(atPath: tmpURL.path) + let size = attrs?[.size] as? UInt64 ?? 0 + guard size >= 128 else { + try? fm.removeItem(at: tmpURL) + throw ArgyllRunnerError.toolFailed( + tool: "applycal", + code: -1, + logs: ["calibrated profile is too small (\(size) bytes)"] + ) + } + + do { + if fm.fileExists(atPath: inputURL.path) { + _ = try fm.replaceItemAt(inputURL, withItemAt: tmpURL) + } else { + try fm.moveItem(at: tmpURL, to: inputURL) + } + } catch { + try? fm.removeItem(at: tmpURL) + throw ArgyllRunnerError.toolFailed( + tool: "applycal", + code: -1, + logs: [error.localizedDescription] + ) + } + + return inputURL + } + + // MARK: - iccgamut (post-colprof gamut mesh) + + /// Extracts a `.gam` mesh from the finished profile. + public func runIccgamut( + config: IccgamutConfig, + onLogBatch: (@Sendable ([String]) -> Void)? = nil + ) async throws -> URL { + let profileURL = config.profileURL + let cwd = profileURL.deletingLastPathComponent() + let stem = profileURL.deletingPathExtension().lastPathComponent + let args = try IccgamutArgs.build(config: config) + let processId = ProcessID.iccgamut(stem: stem) + _ = try await runStreamingTool( + name: "iccgamut", + id: processId, + arguments: args, + workingDirectory: cwd, + onLogBatch: onLogBatch + ) + let gamURL = cwd.appendingPathComponent("\(stem).gam") + return try requireArtefact(gamURL) + } + + // MARK: - profcheck (Stage 5 verification) + + /// Verifies a profile against the canonical `.ti3`. + public func runProfcheck( + config: ProfcheckConfig, + onLogBatch: (@Sendable ([String]) -> Void)? = nil + ) async throws -> ProfcheckReport { + let cwd = config.ti3URL.deletingLastPathComponent() + let ti3Path = config.ti3URL.path + + let iccURL = ArtefactProbe.resolveProfile(config.iccURL) + let config = ProfcheckConfig(ti3URL: config.ti3URL, iccURL: iccURL) + + let args = try ProfcheckArgs.build(config: config) + let processId = ProcessID.profcheck(ti3Path: ti3Path) + let run = try await runStreamingTool( + name: "profcheck", + id: processId, + arguments: args, + workingDirectory: cwd, + onLogBatch: onLogBatch + ) + + let output = (run.stdout + "\n" + run.stderr).trimmingCharacters(in: .whitespacesAndNewlines) + let report = ProfcheckParser.parse(output) + guard report.isValid else { + throw ArgyllRunnerError.profcheckUnparseable + } + return report + } + + // MARK: - chartread (Stage 3 interactive) + + /// Runs `chartread` and returns an `AsyncStream` of typed events. + /// + /// Subscribe-before-spawn, prompt/row/log forwarding, and exit verification + /// are all handled here. Use `sendChartreadInput` to drive the child and + /// `cancelChartread` to terminate it. + public func runChartread(config: ChartreadConfig) -> AsyncStream { + let cleanBasename: String + let cwd: URL + do { + cleanBasename = try PathSecurity.sanitizeBasename(config.basename) + cwd = PathSecurity.resolveSafeCwd(config.workingDirectory) + } catch { + return AsyncStream { continuation in + continuation.yield(.failed(ArgyllRunnerError.toolFailed(tool: "chartread", code: -1, logs: [error.localizedDescription]))) + continuation.finish() + } + } + + let args: [String] + do { + args = try ChartreadArgs.build(config: config) + } catch { + return AsyncStream { continuation in + continuation.yield(.failed(ArgyllRunnerError.toolFailed(tool: "chartread", code: -1, logs: [error.localizedDescription]))) + continuation.finish() + } + } + + let binaryURL = binaryResolver.resolve("chartread") + let processId = ProcessID.chartread(cleanBasename) + let processManager = self.processManager + let isXY = config.isXY + + return AsyncStream { continuation in + let task = Task { + await ensureNotRunning(id: processId) + let events = processManager.events() + + // Register the XY parking hook before spawning. + await processManager.setPreKillHook(id: processId) { [processManager] in + if isXY { + try? await processManager.sendStdin(id: processId, bytes: ChartreadInput.quit.bytes) + try? await Task.sleep(nanoseconds: Self.testAwareDelay(500_000_000)) + } + } + + do { + try await processManager.runStreaming( + id: processId, + binary: binaryURL, + arguments: args, + workingDirectory: cwd + ) + } catch { + continuation.yield(.failed(ArgyllRunnerError.toolFailed(tool: "chartread", code: -1, logs: [error.localizedDescription]))) + continuation.finish() + return + } + + var state: ChartreadState = .idle + var pendingLogs: [String] = [] + var lastFlush = Date() + var exitCode: Int32? + + func flushLogs() { + guard !pendingLogs.isEmpty else { return } + let batch = pendingLogs + pendingLogs.removeAll(keepingCapacity: true) + continuation.yield(.log(batch)) + } + + for await event in events { + guard event.id == processId else { continue } + + switch event { + case .stdout(_, let line): + let previous = state + let classified = ChartreadClassifier.classify(line: line, previousState: previous) + state = classified.state + + if classified.isRemoveSheetNotice { + continuation.yield(.removeSheetNotice) + } + + let shouldPrompt = + classified.sheetNumber != nil + || classified.alignmentPatch != nil + || classified.requestedWarningKey != nil + || classified.state != previous + || classified.isTableContinuation + + if shouldPrompt { + continuation.yield(.prompt(classified)) + } + + pendingLogs.append(line) + + case .stderr(_, let line): + pendingLogs.append(line) + + case .jsonRow(_, let payload): + do { + let row = try JSONDecoder().decode(ChartreadRow.self, from: payload) + state = row.isFinalRow ? .allStripsRead : state + continuation.yield(.row(row)) + } catch { + pendingLogs.append("Malformed row JSON: \(error.localizedDescription)") + } + + case .error(_, let message): + pendingLogs.append("Error: \(message)") + + case .exit(_, let code): + exitCode = code + } + + if exitCode == nil, + pendingLogs.count >= 20 || Date().timeIntervalSince(lastFlush) >= 0.1 { + flushLogs() + lastFlush = Date() + } + + if exitCode != nil { + flushLogs() + break + } + } + + if Task.isCancelled { + continuation.finish() + return + } + + let canonical = cwd.appendingPathComponent("\(cleanBasename).ti3") + if let code = exitCode, code == 0 { + if FileManager.default.fileExists(atPath: canonical.path) { + continuation.yield(.completed(canonical)) + } else { + continuation.yield(.failed(ArgyllRunnerError.missingArtefact(canonical.path))) + } + } else { + continuation.yield(.failed(ArgyllRunnerError.toolFailed( + tool: "chartread", + code: exitCode ?? -1, + logs: ["chartread exited with code \(exitCode ?? -1)"] + ))) + } + continuation.finish() + } + + continuation.onTermination = { _ in + task.cancel() + Task { + await processManager.kill(id: processId) + } + } + } + } + + /// Send an exact input sequence to the running `chartread` child. + public func sendChartreadInput(basename: String, input: ChartreadInput) async throws { + let cleanBasename = try PathSecurity.sanitizeBasename(basename) + let processId = ProcessID.chartread(cleanBasename) + try await processManager.sendStdin(id: processId, bytes: input.bytes) + } + + /// Terminate a running `chartread` child. + /// + /// The actual XY parking is handled by the pre-kill hook registered in + /// `runChartread`. + public func cancelChartread(basename: String, isXY: Bool = false) { + let cleanBasename = try? PathSecurity.sanitizeBasename(basename) + guard let cleanBasename else { return } + let processId = ProcessID.chartread(cleanBasename) + + Task { + await processManager.kill(id: processId) + } + } + + // MARK: - spotread (spot-read console, issue #148) + + /// Runs `spotread` and returns an `AsyncStream` of typed events. + /// + /// Same subscribe-before-spawn shape as `runChartread`, but there is + /// no artefact: the stream ends with `.exit(code)`. The single-lease + /// process id is `ProcessID.spotread` — never `chartread_{basename}`. + /// Missing sidecar surfaces as `.failed`; there is no `$PATH` or + /// `chartread` fallback (#116, R14/R21). + public func runSpotread(config: SpotReadConfig) -> AsyncStream { + let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory) + let args = SpotReadArgs.build(config: config) + let binaryURL = binaryResolver.resolve("spotread") + let processId = ProcessID.spotread + let processManager = self.processManager + let isXY = config.isXY + let instrumentName = config.instrumentName + let instrumentPort = config.instrumentPort + + return AsyncStream { continuation in + let task = Task { + await ensureNotRunning(id: processId) + let events = processManager.events() + + // XY parking hook before any kill, same as chartread. + await processManager.setPreKillHook(id: processId) { [processManager] in + if isXY { + try? await processManager.sendStdin(id: processId, bytes: ChartreadInput.quit.bytes) + try? await Task.sleep(nanoseconds: Self.testAwareDelay(500_000_000)) + } + } + + guard binaryResolver.exists(binaryURL) else { + continuation.yield(.failed(ArgyllRunnerError.toolFailed( + tool: "spotread", code: -1, + logs: ["spotread sidecar missing — run fetch-argyll"]))) + continuation.finish() + return + } + + do { + try await processManager.runStreaming( + id: processId, + binary: binaryURL, + arguments: args, + workingDirectory: cwd + ) + } catch { + continuation.yield(.failed(ArgyllRunnerError.toolFailed( + tool: "spotread", code: -1, logs: [error.localizedDescription]))) + continuation.finish() + return + } + + var state: ChartreadState = .idle + var pendingLogs: [String] = [] + var lastFlush = Date() + var exitCode: Int32? + + func flushLogs() { + guard !pendingLogs.isEmpty else { return } + let batch = pendingLogs + pendingLogs.removeAll(keepingCapacity: true) + continuation.yield(.log(batch)) + } + + for await event in events { + guard event.id == processId else { continue } + + switch event { + case .stdout(_, let line): + if let parsed = SpotReadParser.parse(line: line) { + continuation.yield(.sample(SpotReadSample( + lab: parsed.lab, + xyz: parsed.xyz, + instrumentName: instrumentName, + port: instrumentPort, + rawLine: line + ))) + } + let classified = SpotReadClassifier.classify( + line: line, previousState: state) + if classified.state != state + || classified.requestedWarningKey != nil { + state = classified.state + continuation.yield(.prompt(classified)) + } + pendingLogs.append(line) + + case .stderr(_, let line): + pendingLogs.append(line) + + case .jsonRow: + // spotread is never run with `-u`. + break + + case .error(_, let message): + pendingLogs.append("Error: \(message)") + + case .exit(_, let code): + exitCode = code + } + + if exitCode == nil, + pendingLogs.count >= 20 || Date().timeIntervalSince(lastFlush) >= 0.1 { + flushLogs() + lastFlush = Date() + } + + if exitCode != nil { + flushLogs() + break + } + } + + continuation.yield(.exit(exitCode ?? -1)) + continuation.finish() + } + + continuation.onTermination = { _ in + task.cancel() + Task { + await processManager.kill(id: processId) + } + } + } + } + + /// Send input bytes to the running `spotread` child. Reuses + /// `ChartreadInput` — the stdin protocol is identical. + public func sendSpotreadInput(_ input: ChartreadInput) async throws { + try await processManager.sendStdin(id: ProcessID.spotread, bytes: input.bytes) + } + + /// Terminate a running `spotread` child. The XY park (`q\n` + + /// ~500 ms) runs in the pre-kill hook registered by `runSpotread`. + public func cancelSpotread() { + Task { + await processManager.kill(id: ProcessID.spotread) + } + } + + // MARK: - Stage 0 calibration + + /// Generates a calibration wedge `.ti1`. + public func runCalibrationTargen( + config: CalibrationTargenConfig, + onLogBatch: (@Sendable ([String]) -> Void)? = nil + ) async throws -> URL { + let args = try CalibrationTargenArgs.build(config: config) + let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory) + let cleanBasename = try PathSecurity.sanitizeBasename( + CalibrationIdentity.prefix(config.basename) + ) + let processId = ProcessID.targen(cleanBasename) + _ = try await runStreamingTool( + name: "targen", + id: processId, + arguments: args, + workingDirectory: cwd, + onLogBatch: onLogBatch + ) + let ti1URL = cwd.appendingPathComponent("\(cleanBasename).ti1") + return try requireArtefact(ti1URL) + } + + /// Computes a `.cal` curve from a measured `CAL_*.ti3`. + /// + /// `printcal` is captured (not streamed) and is exempt from the `-u` + /// JSON policy. + public func runPrintcal( + config: PrintcalConfig, + onLogBatch: (@Sendable ([String]) -> Void)? = nil + ) async throws -> URL { + let args = try PrintcalArgs.build(config: config) + let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory) + let binaryURL = binaryResolver.resolve("printcal") + let calBasename = CalibrationIdentity.prefix(config.ti3Basename) + let processId = ProcessID.printcal(calBasename) + + await ensureNotRunning(id: processId) + let result = try await processManager.runCaptured( + id: processId, + binary: binaryURL, + arguments: args, + workingDirectory: cwd + ) + + if let onLogBatch = onLogBatch, !result.stdout.isEmpty { + onLogBatch(result.stdout.components(separatedBy: .newlines)) + } + + guard result.exitCode == 0 else { + throw ArgyllRunnerError.toolFailed( + tool: "printcal", + code: result.exitCode, + logs: [result.stderr.isEmpty + ? "printcal exited with code \(result.exitCode)" + : result.stderr] + ) + } + + let calURL = config.outputURL + guard FileManager.default.fileExists(atPath: calURL.path) else { + throw ArgyllRunnerError.missingArtefact(calURL.path) + } + return calURL + } +} + +/// Events emitted by a running `chartread` session. +public enum ChartreadEvent: Sendable { + /// Classified prompt / state update. + case prompt(ChartreadClassifyResult) + /// A decoded `ROW_COLORS_JSON` row. + case row(ChartreadRow) + /// A batched log chunk (stdout + stderr lines). + case log([String]) + /// Informational "remove last sheet" notice. + case removeSheetNotice + /// Process exited with the given code. + case exit(Int32) + /// Successful completion with the canonical `.ti3` URL. + case completed(URL) + /// Failure (non-zero exit, missing artefact, spawn/parse error). + case failed(ArgyllRunnerError) +} + +/// Events emitted by a running `spotread` session (issue #148). +public enum SpotReadEvent: Sendable { + /// Classified prompt / state update (reuses `ChartreadState`). + case prompt(ChartreadClassifyResult) + /// A parsed `Result is …` sample line. + case sample(SpotReadSample) + /// A batched log chunk (stdout + stderr lines). + case log([String]) + /// Process exited with the given code. + case exit(Int32) + /// Failure (missing sidecar, spawn error). + case failed(ArgyllRunnerError) +} + +/// Exact bytes sent to `chartread` stdin. +public enum ChartreadInput: Sendable { + case trigger // " \n" + case accept // "\n" + case done // "d\n" + case quit // "q\n" + case customKey(String) + + public var bytes: Data { + switch self { + case .trigger: + return Data(" \n".utf8) + case .accept: + return Data("\n".utf8) + case .done: + return Data("d\n".utf8) + case .quit: + return Data("q\n".utf8) + case .customKey(let key): + return Data("\(key)\n".utf8) + } + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/BinaryResolver.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/BinaryResolver.swift new file mode 100644 index 0000000..1c1d1c5 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/BinaryResolver.swift @@ -0,0 +1,101 @@ +import Foundation + +/// Resolves Argyll sidecar binaries (docs/04 §0.1 `resolve_binary`). +/// +/// Order: +/// 1. Settings `argyll_binary_dir` override — only if `/` +/// exists there. +/// 2. Bundled `/Resources/Argyll//`. +/// On macOS, `macos-universal` wins whenever it contains the `instlist` +/// marker; otherwise `macos-arm64` / `macos-x86_64` by host arch. +/// 3. If nothing exists the *constructed* bundled path is still returned +/// — a missing binary surfaces later as `process:error` on spawn, +/// matching v1 semantics. +public struct BinaryResolver: Sendable { + + /// Root that contains the platform dirs — `Bundle.resource/Argyll` in + /// the app, a fixture dir in tests. + public let bundledRoot: URL + /// `settings.argyll_binary_dir`, already expanded to a URL. + public let overrideDir: URL? + /// Host architecture directory names, universal preferred. + public let archDirs: [String] + + public init( + bundledRoot: URL = AppPaths.bundledArgyllDir, + overrideDir: URL? = nil, + archDirs: [String]? = nil + ) { + self.bundledRoot = bundledRoot + self.overrideDir = overrideDir + #if arch(arm64) + let fallback = ["macos-arm64", "macos-aarch64"] + #else + let fallback = ["macos-x86_64"] + #endif + self.archDirs = archDirs ?? ["macos-universal"] + fallback + } + + /// Marker used to decide whether `macos-universal` is usable. + public static let markerBinary = "instlist" + + /// Resolves a tool name to an absolute URL (never throws — see type + /// docs). `name` is the bare tool name, e.g. `"targen"`. + public func resolve(_ name: String) -> URL { + let fm = FileManager.default + + if let dir = overrideDir { + let candidate = dir.appendingPathComponent(name) + if fm.fileExists(atPath: candidate.path) { + return candidate + } + } + + return bundledRoot + .appendingPathComponent(platformDir(), isDirectory: true) + .appendingPathComponent(name, isDirectory: false) + } + + /// The bundled platform directory that resolution will use. + public func platformDir() -> String { + let fm = FileManager.default + let universal = bundledRoot.appendingPathComponent("macos-universal") + if fm.fileExists( + atPath: universal.appendingPathComponent(Self.markerBinary).path + ) { + return "macos-universal" + } + for dir in archDirs where dir != "macos-universal" { + if fm.fileExists( + atPath: bundledRoot + .appendingPathComponent(dir) + .appendingPathComponent(Self.markerBinary).path + ) { + return dir + } + } + // Nothing present — still return the preferred dir so the error + // message points at where the user should drop binaries. + return archDirs.first ?? "macos-universal" + } + + /// Bundled mock tool (tracked in git under `Resources/Argyll/mocks/`). + public func mock(_ name: String) -> URL { + bundledRoot + .appendingPathComponent("mocks", isDirectory: true) + .appendingPathComponent("\(name).mock", isDirectory: false) + } + + /// Bundled reference gamut (`Resources/Argyll/reference_gamuts/`). + public func referenceGamut(_ name: String) -> URL { + let stem = name.hasSuffix(".gam") ? name : "\(name).gam" + return bundledRoot + .appendingPathComponent("reference_gamuts", isDirectory: true) + .appendingPathComponent(stem, isDirectory: false) + } + + /// Whether the resolved path exists and is executable. + public func exists(_ url: URL) -> Bool { + FileManager.default.isExecutableFile(atPath: url.path) + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/PrinttargArgs.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/PrinttargArgs.swift new file mode 100644 index 0000000..e4d966b --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/PrinttargArgs.swift @@ -0,0 +1,95 @@ +import Foundation + +/// Errors during `buildPrinttargArgs` validation (docs/04 §2.2). +public enum PrinttargArgError: LocalizedError, Equatable { + case invalidCustomPageDimension(Double) + case invalidDPI(Int) + case invalidSeed(Int) + + public var errorDescription: String? { + switch self { + case .invalidCustomPageDimension(let mm): + return "Custom page dimensions must be at least 50 mm, got: \(mm)" + case .invalidDPI(let dpi): + return "TIFF DPI must be between 72 and 600, got: \(dpi)" + case .invalidSeed(let seed): + return "Custom layout seed must be ≥ 1, got: \(seed)" + } + } +} + +/// Pure argv builder for Argyll's `printtarg` tool (docs/09, docs/04 §2.2). +/// +/// Contract: +/// ``` +/// -v -u -i {instrument} -p {page} [-r | -R seed] [-d label] {-t|-T} {dpi} [-K|-I cal] basename +/// ``` +public enum PrinttargArgs { + + /// Builds the exact command-line arguments for `printtarg`. + /// + /// Invariants: + /// - Always `-v -u` (the fork's `-u` emits the JSON page manifest). + /// - Default layout is deterministic `-R 1` (#163) — a missing seed + /// reshuffles patches on every re-run and desyncs print vs `.ti2`. + /// - `.raster` emits `-r` and supersedes any seed. This is NOT + /// targen's `-r` full-spread algorithm (docs/25). + /// - `-d` is the chart **label** string, not colour space. + /// - `-K`/`-I` are never emitted for `CAL_` basenames — the + /// calibration chart must not embed its own curves. + /// - Basename is the last positional argument. + public static func build(config: PrinttargConfig) throws -> [String] { + let cleanBasename = try PathSecurity.sanitizeBasename(config.basename) + + var args: [String] = [ + "-v", "-u", + "-i", config.instrument.rawValue, + "-p", try pageSizeValue(config), + ] + + switch config.layoutOrder { + case .deterministic: + args.append(contentsOf: ["-R", "1"]) + case .customSeed: + guard config.customSeed >= 1 else { + throw PrinttargArgError.invalidSeed(config.customSeed) + } + args.append(contentsOf: ["-R", "\(config.customSeed)"]) + case .raster: + args.append(contentsOf: ArgsBuilder.flag("-r", when: true)) + } + + args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-d", config.label)) + + guard (72...600).contains(config.dpi) else { + throw PrinttargArgError.invalidDPI(config.dpi) + } + args.append(contentsOf: [config.bitDepth.flag, "\(config.dpi)"]) + + if !CalibrationIdentity.isCalibration(cleanBasename) { + args.append(contentsOf: ArgsBuilder.optionIfNonEmpty( + config.calibrationEmbedOnly ? "-I" : "-K", config.calibrationFile)) + } + + args.append(cleanBasename) + return args + } + + private static func pageSizeValue(_ config: PrinttargConfig) throws -> String { + guard config.pageSize == .custom else { return config.pageSize.rawValue } + for dim in [config.customPageWidth, config.customPageHeight] { + guard dim >= 50 else { + throw PrinttargArgError.invalidCustomPageDimension(dim) + } + } + return "\(formatMM(config.customPageWidth))x\(formatMM(config.customPageHeight))" + } + + /// Formats millimetres as an integer when exact, else decimal. + private static func formatMM(_ value: Double) -> String { + if value == value.rounded(), abs(value) < 1e15 { + return "\(Int(value))" + } + return String(format: "%.1f", locale: Locale(identifier: "en_US_POSIX"), value) + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/PrinttargConfig.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/PrinttargConfig.swift new file mode 100644 index 0000000..6e93351 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/PrinttargConfig.swift @@ -0,0 +1,208 @@ +import Foundation + +/// Measurement instrument for `printtarg -i` chart geometry +/// (docs/09, docs/04 §2.2). Raw values are the Argyll codes. +public enum PrintInstrument: String, Codable, Sendable, CaseIterable { + case i1 + case p3 + case cm = "CM" + case ss = "SS" + case dtp20 = "20" + case dtp22 = "22" + case dtp41 = "41" + case dtp51 = "51" + + public var displayName: String { + switch self { + case .i1: return "X-Rite i1Pro / i1Pro 2" + case .p3: return "X-Rite i1Pro 3 / 3 Plus" + case .cm: return "ColorMunki" + case .ss: return "Specbos / Spectraval (XY table)" + case .dtp20: return "Gretag i1Display 2" + case .dtp22: return "X-Rite i1Display Pro / ColorMunki Display" + case .dtp41: return "Datacolor Spyder 4/5" + case .dtp51: return "Spyder X" + } + } +} + +/// Page size for `printtarg -p` (docs/09). `.custom` emits `{W}x{H}` mm. +public enum PageSize: String, Codable, Sendable, CaseIterable { + case a4 = "A4" + case a4r = "A4R" + case a3 = "A3" + case a2 = "A2" + case letter = "Letter" + case letterR = "LetterR" + case legal = "Legal" + case fourBySix = "4x6" + case elevenBySeventeen = "11x17" + case custom = "custom" + + public var isCustom: Bool { self == .custom } +} + +/// TIFF bit depth: `-t` (8-bit) or `-T` (16-bit). +public enum TiffBitDepth: Int, Codable, Sendable, CaseIterable { + case eight = 8 + case sixteen = 16 + + public var flag: String { + switch self { + case .eight: return "-t" + case .sixteen: return "-T" + } + } +} + +/// Patch layout order (docs/09 §Randomisation, #163). +/// `.deterministic` is the default (`-R 1`); `.raster` emits `-r` and +/// supersedes any seed — never confuse with targen's `-r` algorithm. +public enum LayoutOrder: String, Codable, Sendable, CaseIterable { + case deterministic + case customSeed = "custom_seed" + case raster + + public var displayName: String { + switch self { + case .deterministic: return "Deterministic (seed 1)" + case .customSeed: return "Custom seed" + case .raster: return "Raster order (no shuffle)" + } + } +} + +/// Chart label metadata used to assemble the automatic `printtarg -d` +/// label. Any empty/missing component becomes `Unspecified` until real +/// printer metadata lands in M3. +public struct TargetLabelMetadata: Codable, Equatable, Sendable { + public var printer: String + public var inkSet: String + public var driverPaper: String + public var actualPaper: String + + public init( + printer: String = "", + inkSet: String = "", + driverPaper: String = "", + actualPaper: String = "" + ) { + self.printer = printer + self.inkSet = inkSet + self.driverPaper = driverPaper + self.actualPaper = actualPaper + } +} + +/// Builds the chart legend for `printtarg -d` (fork argyllcms#19, +/// ICCery #119). `-d` here is a **label string** — not targen's colour +/// space, not iccgamut's density (docs/25). +public enum PrinttargLabel { + + public static let unspecified = "Unspecified" + + /// `ICCery - {basename} - {printer} - {ink} - {driverPaper} - + /// {actualPaper} - DD/MM/YYYY HH:MM` + /// + /// `date` is injected for deterministic tests; production passes + /// the current local time. A fixed POSIX locale keeps the format + /// stable regardless of user locale. + public static func automatic( + basename: String, + metadata: TargetLabelMetadata, + date: Date = Date(), + timeZone: TimeZone = .current + ) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = timeZone + formatter.dateFormat = "dd/MM/yyyy HH:mm" + + return [ + "ICCery", + basename, + field(metadata.printer), + field(metadata.inkSet), + field(metadata.driverPaper), + field(metadata.actualPaper), + formatter.string(from: date), + ].joined(separator: " - ") + } + + /// Resolves the label to emit: an explicit non-empty manual label + /// wins; otherwise the assembled automatic label. + public static func resolved( + customLabel: String?, + basename: String, + metadata: TargetLabelMetadata, + date: Date = Date(), + timeZone: TimeZone = .current + ) -> String { + if let label = customLabel?.trimmingCharacters(in: .whitespacesAndNewlines), + !label.isEmpty { + return label + } + return automatic(basename: basename, metadata: metadata, date: date, timeZone: timeZone) + } + + private static func field(_ value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? unspecified : trimmed + } +} + +/// Configuration model for `printtarg` invocation (docs/09, docs/04 §2.2). +public struct PrinttargConfig: Codable, Equatable, Sendable { + public var instrument: PrintInstrument + public var pageSize: PageSize + /// Custom page dimensions in millimetres; each must be ≥ 50 when + /// `pageSize == .custom`. + public var customPageWidth: Double + public var customPageHeight: Double + public var bitDepth: TiffBitDepth + public var dpi: Int + public var layoutOrder: LayoutOrder + /// Seed for `.customSeed` layout (`-R N`, N ≥ 1). Ignored for + /// `.deterministic` (fixed `-R 1`) and `.raster` (`-r`). + public var customSeed: Int + /// Resolved `-d` label. Callers usually compute this via + /// `PrinttargLabel.resolved` so tests can inject the clock. + public var label: String? + /// `.cal` file applied to printed patches (`-K`), or embedded + /// without applying (`-I` when `calibrationEmbedOnly`). Never + /// emitted for `CAL_` basenames (Stage 0 protection). + public var calibrationFile: String? + public var calibrationEmbedOnly: Bool + public var basename: String + public var workingDirectory: URL? + + public init( + instrument: PrintInstrument = .i1, + pageSize: PageSize = .a4, + customPageWidth: Double = 210, + customPageHeight: Double = 297, + bitDepth: TiffBitDepth = .eight, + dpi: Int = 300, + layoutOrder: LayoutOrder = .deterministic, + customSeed: Int = 1, + label: String? = nil, + calibrationFile: String? = nil, + calibrationEmbedOnly: Bool = false, + basename: String = "", + workingDirectory: URL? = nil + ) { + self.instrument = instrument + self.pageSize = pageSize + self.customPageWidth = customPageWidth + self.customPageHeight = customPageHeight + self.bitDepth = bitDepth + self.dpi = dpi + self.layoutOrder = layoutOrder + self.customSeed = customSeed + self.label = label + self.calibrationFile = calibrationFile + self.calibrationEmbedOnly = calibrationEmbedOnly + self.basename = basename + self.workingDirectory = workingDirectory + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/PrinttargManifest.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/PrinttargManifest.swift new file mode 100644 index 0000000..b5c9e70 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/PrinttargManifest.swift @@ -0,0 +1,180 @@ +import Foundation + +/// One page of a `printtarg -u` manifest (docs/05 §2.3). +/// `patches` is a page-assigned count including TID/padding cells, +/// not strictly user patches. +public struct PrinttargPage: Codable, Equatable, Sendable { + public var filename: String + public var patches: Int + public var widthMm: Double + public var heightMm: Double + + public init(filename: String, patches: Int, widthMm: Double, heightMm: Double) { + self.filename = filename + self.patches = patches + self.widthMm = widthMm + self.heightMm = heightMm + } + + enum CodingKeys: String, CodingKey { + case filename, patches + case widthMm = "width_mm" + case heightMm = "height_mm" + } +} + +/// The final-only, pretty-printed, **unprefixed** JSON object emitted +/// by fork `printtarg -u` after all pages are written (docs/05 §2.3). +public struct PrinttargManifest: Codable, Equatable, Sendable { + public var event: String + public var pages: [PrinttargPage] + + public init(event: String = "manifest", pages: [PrinttargPage]) { + self.event = event + self.pages = pages + } +} + +/// A manifest page resolved against the working directory, with its +/// host-side PNG preview (#58 — TIFF is never fed to the UI directly). +public struct GalleryPage: Equatable, Sendable, Identifiable { + public var id: Int { index } + public let index: Int + public let page: PrinttargPage + public let fileURL: URL + public let previewPNG: Data? + public let previewError: String? + + public init(index: Int, page: PrinttargPage, fileURL: URL, previewPNG: Data?, previewError: String?) { + self.index = index + self.page = page + self.fileURL = fileURL + self.previewPNG = previewPNG + self.previewError = previewError + } +} + +public enum ManifestError: LocalizedError, Equatable { + case noJSONDocument + case wrongEvent(String) + case decodeFailed(String) + case invalidPage(String) + + public var errorDescription: String? { + switch self { + case .noJSONDocument: + return "No JSON document found in printtarg stdout." + case .wrongEvent(let event): + return "Unexpected JSON event \"\(event)\" — expected \"manifest\"." + case .decodeFailed(let reason): + return "printtarg manifest JSON failed to decode: \(reason)" + case .invalidPage(let reason): + return "printtarg manifest page is invalid: \(reason)" + } + } +} + +/// Extracts and decodes the `printtarg -u` manifest from the complete +/// accumulated stdout (docs/04 §2.3, docs/09 §JSON manifest). +/// +/// #68 invariant: the JSON is a structured document, not a brace-hunt. +/// Extraction is string/escape-aware — a `{` or `}` inside a quoted +/// filename can never corrupt the scan — and starts only at a `{` that +/// begins a trimmed stdout line. +public enum PrinttargManifestExtractor { + + /// Finds the manifest object in accumulated stdout. + public static func manifest(from stdout: String) throws -> PrinttargManifest { + for block in jsonObjects(in: stdout) { + let data = Data(block.utf8) + guard let manifest = try? JSONDecoder().decode(PrinttargManifest.self, from: data) else { + continue + } + guard manifest.event == "manifest" else { + throw ManifestError.wrongEvent(manifest.event) + } + try validate(manifest) + return manifest + } + if let first = jsonObjects(in: stdout).first, + let obj = try? JSONSerialization.jsonObject(with: Data(first.utf8)) as? [String: Any], + let event = obj["event"] as? String { + throw ManifestError.wrongEvent(event) + } + throw ManifestError.noJSONDocument + } + + private static func validate(_ manifest: PrinttargManifest) throws { + for page in manifest.pages { + guard page.patches >= 0 else { + throw ManifestError.invalidPage("negative patch count \(page.patches)") + } + guard page.widthMm > 0, page.heightMm > 0 else { + throw ManifestError.invalidPage("non-positive page size \(page.widthMm)x\(page.heightMm)") + } + let name = page.filename + guard !name.isEmpty, + !name.hasPrefix("/"), + !name.contains("/"), + !name.contains("\\"), + !name.contains("..") else { + throw ManifestError.invalidPage("unsafe filename \"\(name)\"") + } + let ext = (name as NSString).pathExtension.lowercased() + guard ext == "tif" || ext == "tiff" else { + throw ManifestError.invalidPage("non-TIFF filename \"\(name)\"") + } + } + } + + /// Yields every complete top-level JSON object `{...}` found at a + /// trimmed line boundary, in document order. Depth tracking respects + /// quoted strings and backslash escapes. + static func jsonObjects(in text: String) -> [String] { + var out: [String] = [] + let scalars = Array(text.unicodeScalars) + var i = 0 + + func isLineStart(_ idx: Int) -> Bool { + var j = idx - 1 + while j >= 0 && scalars[j] != "\n" { + if scalars[j] != " " && scalars[j] != "\t" && scalars[j] != "\r" { + return false + } + j -= 1 + } + return true + } + + while i < scalars.count { + if scalars[i] == "{", isLineStart(i) { + var depth = 0 + var inString = false + var escaped = false + var j = i + while j < scalars.count { + let c = scalars[j] + if inString { + if escaped { escaped = false } + else if c == "\\" { escaped = true } + else if c == "\"" { inString = false } + } else { + if c == "\"" { inString = true } + else if c == "{" { depth += 1 } + else if c == "}" { + depth -= 1 + if depth == 0 { + out.append(String(String.UnicodeScalarView(scalars[i...j]))) + i = j + break + } + } + } + j += 1 + } + } + i += 1 + } + return out + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/SpotReadArgs.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/SpotReadArgs.swift new file mode 100644 index 0000000..f454672 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/SpotReadArgs.swift @@ -0,0 +1,28 @@ +import Foundation + +/// Pure argv builder for Argyll's `spotread` tool (issue #148). +public enum SpotReadArgs { + + /// Builds `spotread` argv per the Gronod fork protocol. + /// + /// - Always `-v -e` (paper / reflective; never display `-d`). + /// - `-c N` is emitted only for `selectedPort != nil` and `N > 1` + /// (Auto and port 1 omit it, #111). + /// - `-Y l` (letter L) is emitted only when `enableLEDs` is `true` (#204). + /// - Never `-u`: the v2.0 `-u` policy covers printtarg + chartread + + /// profcheck only. + /// - No basename — `spotread` writes no artefact. + public static func build(config: SpotReadConfig) -> [String] { + var args: [String] = ["-v", "-e"] + + if let port = config.selectedPort, port > 1 { + args.append(contentsOf: ["-c", "\(port)"]) + } + + if config.enableLEDs { + args.append(contentsOf: ["-Y", "l"]) + } + + return args + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/SpotReadConfig.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/SpotReadConfig.swift new file mode 100644 index 0000000..2d815e2 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/SpotReadConfig.swift @@ -0,0 +1,37 @@ +import Foundation + +/// Configuration for a `spotread` invocation (issue #148). +/// +/// `spotread` writes no artefact; `workingDirectory` is still required +/// for the spawn (#59 — empty cwd is illegal). +public struct SpotReadConfig: Codable, Equatable, Sendable { + public var workingDirectory: URL? + /// Communication port for `spotread -c`. + /// `nil` means omit `-c` (Auto or port 1, #111). Never an array index. + public var selectedPort: Int? + /// Enable i1Pro 2 visual LEDs (`-Y l`, #204). + public var enableLEDs: Bool + /// Whether the selected instrument is an XY table — controls the + /// `q\n` + ~500 ms park before kill on cancel. + public var isXY: Bool + /// Display name stamped onto each `SpotReadSample`. + public var instrumentName: String + /// Instrument port stamped onto each sample (nil for Auto). + public var instrumentPort: Int? + + public init( + workingDirectory: URL? = nil, + selectedPort: Int? = nil, + enableLEDs: Bool = false, + isXY: Bool = false, + instrumentName: String = "", + instrumentPort: Int? = nil + ) { + self.workingDirectory = workingDirectory + self.selectedPort = selectedPort + self.enableLEDs = enableLEDs + self.isXY = isXY + self.instrumentName = instrumentName + self.instrumentPort = instrumentPort + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/TargenArgs.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/TargenArgs.swift new file mode 100644 index 0000000..6ad53ad --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/TargenArgs.swift @@ -0,0 +1,96 @@ +import Foundation + +/// Errors during `buildTargenArgs` validation (docs/04 §1.2). +public enum TargenArgError: LocalizedError, Equatable { + case invalidBasename(String) + case invalidPatchCount(Int) + case invalidWhitePatches(Int) + case invalidBlackPatches(Int) + case invalidInkLimit(Int) + + public var errorDescription: String? { + switch self { + case .invalidBasename(let name): + return "Invalid target basename: \(name)" + case .invalidPatchCount(let count): + return "Patch count must be positive, got: \(count)" + case .invalidWhitePatches(let count): + return "White patches cannot be negative, got: \(count)" + case .invalidBlackPatches(let count): + return "Black patches cannot be negative, got: \(count)" + case .invalidInkLimit(let limit): + return "Ink limit must be between 1 and 400, got: \(limit)" + } + } +} + +/// Pure argv builder for Argyll's `targen` tool (docs/08, docs/04 §1.2). +public enum TargenArgs { + + /// Builds the exact command-line arguments for `targen`. + /// + /// Invariants: + /// - Always starts `-v -d {2|4}` (RGB=2, CMYK=4). + /// - Never emits `-u` (Argyll fork progress is not enabled for targen). + /// - Always emits `-f N` when patchCount > 0 (#44). + /// - White `-e`, Black `-B`. + /// - `-N` omitted when approximately 0.50. + /// - `-A` is emitted even at 0.10 (no default-skip). + /// - `-l` is CMYK only (1...400). + /// - `-V` omitted when approximately 1.0. + /// - `-p` omitted when non-positive or approximately 1.0. + /// - Basename is the last positional argument. + public static func build(config: TargenConfig) throws -> [String] { + let cleanBasename = try PathSecurity.sanitizeBasename(config.basename) + + guard config.patchCount > 0 else { + throw TargenArgError.invalidPatchCount(config.patchCount) + } + guard config.whitePatches >= 0 else { + throw TargenArgError.invalidWhitePatches(config.whitePatches) + } + guard config.blackPatches >= 0 else { + throw TargenArgError.invalidBlackPatches(config.blackPatches) + } + + var args: [String] = [ + "-v", + "-d", config.colourSpace.dFlagValue, + "-f", "\(config.patchCount)", + "-e", "\(config.whitePatches)", + "-B", "\(config.blackPatches)" + ] + + if let g = config.greySteps, g > 0 { + args.append(contentsOf: ["-g", "\(g)"]) + } + if let s = config.singleChannelSteps, s > 0 { + args.append(contentsOf: ["-s", "\(s)"]) + } + if let n = config.neutralSteps, n > 0 { + args.append(contentsOf: ["-n", "\(n)"]) + } + args.append(contentsOf: ArgsBuilder.optionUnlessApprox("-N", config.neutralConcentration, skip: 0.50)) + args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-c", config.preconditioningProfile)) + args.append(contentsOf: ArgsBuilder.flag("-G", when: config.ofpsHighQuality == true)) + args.append(contentsOf: ArgsBuilder.option("-A", config.ofpsAdaptation.map { + String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), $0) + })) + if let algFlag = config.fullSpreadAlgorithm?.flag { + args.append(algFlag) + } + if config.colourSpace == .cmyk, let inkLimit = config.totalInkLimit { + guard (1...400).contains(inkLimit) else { + throw TargenArgError.invalidInkLimit(inkLimit) + } + args.append(contentsOf: ["-l", "\(inkLimit)"]) + } + args.append(contentsOf: ArgsBuilder.optionUnlessApprox("-V", config.darkEmphasis, skip: 1.0)) + if let p = config.devicePower, p > 0 { + args.append(contentsOf: ArgsBuilder.optionUnlessApprox("-p", p, skip: 1.0)) + } + + args.append(cleanBasename) + return args + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/TargenConfig.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/TargenConfig.swift new file mode 100644 index 0000000..fa62fdd --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/TargenConfig.swift @@ -0,0 +1,150 @@ +import Foundation + +/// Colour space for patch generation (docs/08, docs/04 §1.2). +public enum ColourSpace: String, Codable, Sendable, CaseIterable { + case rgb + case cmyk + + /// Argyll targen `-d` flag argument: 2 for RGB, 4 for CMYK. + public var dFlagValue: String { + switch self { + case .rgb: return "2" + case .cmyk: return "4" + } + } +} + +/// Patch count preset for Stage 1. +public enum PatchCountPreset: String, Codable, Sendable, CaseIterable { + case draft400 = "400" + case standard800 = "800" + case photo1500 = "1500" + case custom = "custom" + + public var patchCount: Int? { + switch self { + case .draft400: return 400 + case .standard800: return 800 + case .photo1500: return 1500 + case .custom: return nil + } + } + + public var title: String { + switch self { + case .draft400: return "Draft (400)" + case .standard800: return "Standard (800)" + case .photo1500: return "Photo (1500)" + case .custom: return "Custom" + } + } +} + +/// Full spread patch distribution algorithm (docs/08). +/// Default is "ofps" (no flag emitted). +public enum FullSpreadAlgorithm: String, Codable, Sendable, CaseIterable { + case ofps = "ofps" + case target = "-t" + case random = "-r" + case uniformRandom = "-R" + case quasiRandom = "-q" + case uniformQuasiRandom = "-Q" + case invertedQuasiRandom = "-i" + case invertedUniformQuasiRandom = "-I" + + public var displayName: String { + switch self { + case .ofps: return "OFPS (Default)" + case .target: return "Target (-t)" + case .random: return "Random (-r)" + case .uniformRandom: return "Uniform Random (-R)" + case .quasiRandom: return "Quasi-random (-q)" + case .uniformQuasiRandom: return "Uniform Quasi-random (-Q)" + case .invertedQuasiRandom: return "Inverted Quasi-random (-i)" + case .invertedUniformQuasiRandom: return "Inverted Uniform Quasi-random (-I)" + } + } + + public var flag: String? { + switch self { + case .ofps: return nil + default: return rawValue + } + } + + /// Preset JSON value: `"ofps"` or the bare flag letter + /// (`t`, `r`, `R`, `q`, `Q`, `i`, `I`) — docs/22. + public var presetValue: String { + switch self { + case .ofps: return "ofps" + default: return String(rawValue.dropFirst()) + } + } + + public init?(presetValue: String) { + if presetValue == "ofps" { + self = .ofps + } else { + self.init(rawValue: "-" + presetValue) + } + } +} + +/// Configuration model for `targen` invocation (docs/08, docs/04 §1.2). +public struct TargenConfig: Codable, Equatable, Sendable { + public var colourSpace: ColourSpace + public var patchCount: Int + public var whitePatches: Int + public var blackPatches: Int + public var greySteps: Int? + public var singleChannelSteps: Int? + public var neutralSteps: Int? + public var neutralConcentration: Double? + public var preconditioningProfile: String? + public var ofpsHighQuality: Bool? + public var ofpsAdaptation: Double? + public var fullSpreadAlgorithm: FullSpreadAlgorithm? + public var totalInkLimit: Int? + public var darkEmphasis: Double? + public var devicePower: Double? + public var basename: String + public var workingDirectory: URL? + + public init( + colourSpace: ColourSpace = .rgb, + patchCount: Int = 800, + whitePatches: Int = 4, + blackPatches: Int = 4, + greySteps: Int? = nil, + singleChannelSteps: Int? = nil, + neutralSteps: Int? = nil, + neutralConcentration: Double? = nil, + preconditioningProfile: String? = nil, + ofpsHighQuality: Bool? = nil, + ofpsAdaptation: Double? = nil, + fullSpreadAlgorithm: FullSpreadAlgorithm? = nil, + totalInkLimit: Int? = nil, + darkEmphasis: Double? = nil, + devicePower: Double? = nil, + basename: String = "", + workingDirectory: URL? = nil + ) { + self.colourSpace = colourSpace + self.patchCount = patchCount + self.whitePatches = whitePatches + self.blackPatches = blackPatches + self.greySteps = greySteps + self.singleChannelSteps = singleChannelSteps + self.neutralSteps = neutralSteps + self.neutralConcentration = neutralConcentration + self.preconditioningProfile = preconditioningProfile + self.ofpsHighQuality = ofpsHighQuality + self.ofpsAdaptation = ofpsAdaptation + self.fullSpreadAlgorithm = fullSpreadAlgorithm + self.totalInkLimit = totalInkLimit + self.darkEmphasis = darkEmphasis + self.devicePower = devicePower + self.basename = basename + self.workingDirectory = workingDirectory + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/CGATS/CGATSParser.swift b/Packages/ICCeryCore/Sources/ICCeryCore/CGATS/CGATSParser.swift new file mode 100644 index 0000000..8b0fc25 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/CGATS/CGATSParser.swift @@ -0,0 +1,372 @@ +import Foundation + +/// Errors that can occur while parsing CGATS-like data. +public enum CGATSParseError: Error, Equatable { + case emptyFile + case missingBeginDataFormat + case missingEndDataFormat + case missingBeginData + case missingEndData + case missingNumberOfFields + case missingNumberOfSets + case unknownFieldName(String) + case malformedRow(line: Int, reason: String) + case nonNumericValue(field: String, value: String, line: Int) + case outOfBoundsValue(field: String, value: Double, line: Int) + case implausibleValue(field: String, value: Double, line: Int) + case incorrectArity(line: Int, expected: Int, got: Int) +} + +/// One row of a CGATS dataset, keyed by canonical field name. +public struct CGATSSample: Sendable, Equatable { + public var id: String + public var loc: String? + public var values: [String: String] + + public init(id: String, loc: String? = nil, values: [String: String] = [:]) { + self.id = id + self.loc = loc + self.values = values + } +} + +/// A parsed CGATS / CTI3 / CSV dataset. +public struct CGATSDataset: Sendable, Equatable { + public var format: CGATSFormat + public var keywords: [String: String] + public var fieldNames: [String] + public var samples: [CGATSSample] + public var colorRep: String? + public var deviceClass: String? + public var targetInstrument: String? + + public init( + format: CGATSFormat, + keywords: [String: String] = [:], + fieldNames: [String] = [], + samples: [CGATSSample] = [], + colorRep: String? = nil, + deviceClass: String? = nil, + targetInstrument: String? = nil + ) { + self.format = format + self.keywords = keywords + self.fieldNames = fieldNames + self.samples = samples + self.colorRep = colorRep + self.deviceClass = deviceClass + self.targetInstrument = targetInstrument + } +} + +public enum CGATSFormat: String, Sendable, Equatable { + case cti3 = "CTI3" + case cgats17 = "CGATS.17" + case csv = "CSV" +} + +/// Parser for CGATS.17, CTI3, ISO28178, and simple CSV datasets. +public enum CGATSParser { + + /// Parse the contents of a CGATS-like file. + public static func parse( + _ contents: String, + sourceURL: URL? = nil + ) throws -> CGATSDataset { + guard !contents.isEmpty else { throw CGATSParseError.emptyFile } + + let ext = sourceURL?.pathExtension.lowercased() ?? "" + let isCSV = ext == "csv" || contents.trimmingCharacters(in: .whitespacesAndNewlines) + .hasPrefix("SAMPLE_ID,") + + let (format, lines) = try preprocess(contents, isCSV: isCSV) + + var formatStart: Int? + var formatEnd: Int? + var dataStart: Int? + var dataEnd: Int? + var keywords = [String: String]() + + for (index, line) in lines.enumerated() { + switch Self.normalizedKeyword(line) { + case "BEGIN_DATA_FORMAT": formatStart = index + case "END_DATA_FORMAT": formatEnd = index + case "BEGIN_DATA": dataStart = index + case "END_DATA": dataEnd = index + default: + if let (key, value) = parseKeyword(line) { + keywords[key] = value + } + } + } + + guard let formatStart, let formatEnd, formatEnd > formatStart + 1 else { + throw CGATSParseError.missingBeginDataFormat + } + guard let dataStart, let dataEnd, dataEnd > dataStart + 1 else { + throw CGATSParseError.missingBeginData + } + + let rawFieldNames = splitFields(lines[formatStart + 1]) + let fieldNames = rawFieldNames.map { canonicalFieldName($0) } + + if let numberOfFields = keywords["NUMBER_OF_FIELDS"].flatMap(Int.init), + numberOfFields != fieldNames.count { + // Warn only; the data format line is the source of truth. + } else if keywords["NUMBER_OF_FIELDS"] == nil { + // Optional header; do not fail. + } + + if let numberOfSets = keywords["NUMBER_OF_SETS"].flatMap(Int.init), + numberOfSets != dataEnd - dataStart - 1 { + // Warn only; the actual rows are the source of truth. + } else if keywords["NUMBER_OF_SETS"] == nil { + // Optional header; do not fail. + } + + struct RawSample { + var id: String + var loc: String? + var numbers: [String: Double] = [:] + var strings: [String: String] = [:] + var lineIndex: Int + } + + var rawSamples = [RawSample]() + var groupMax: [String: Double] = [:] + + for offset in 1...(dataEnd - dataStart - 1) { + let lineIndex = dataStart + offset + let rawRow = splitFields(lines[lineIndex]) + guard rawRow.count == fieldNames.count else { + throw CGATSParseError.incorrectArity(line: lineIndex + 1, expected: fieldNames.count, got: rawRow.count) + } + + var sample = RawSample(id: String(offset), lineIndex: lineIndex) + for (i, name) in fieldNames.enumerated() { + let raw = stripInlineComment(rawRow[i]) + if isNumericField(name) { + let cleaned = raw.trimmingCharacters(in: .whitespaces) + if let number = parseNumber(cleaned) { + sample.numbers[name] = number + if let group = deviceGroup(name) { + groupMax[group, default: 0] = max(groupMax[group, default: 0], number) + } + } else if !cleaned.isEmpty { + throw CGATSParseError.nonNumericValue(field: name, value: raw, line: lineIndex + 1) + } + } else { + sample.strings[name] = raw + } + } + + sample.id = sample.strings["SAMPLE_ID"] ?? sample.numbers["SAMPLE_ID"].map { String(format: "%.0f", $0) } ?? String(offset) + sample.loc = sample.strings["SAMPLE_LOC"] + rawSamples.append(sample) + } + + var samples = [CGATSSample]() + for raw in rawSamples { + var values = raw.strings + for (name, number) in raw.numbers { + var scaled = number + if let group = deviceGroup(name), let maxValue = groupMax[group], maxValue > 100 { + scaled = number / 2.55 + } + values[name] = validateValue(scaled, field: name, line: raw.lineIndex + 1) + } + + var sample = CGATSSample(id: raw.id, loc: raw.loc, values: values) + // Keep lookups by canonical keys, but also preserve original aliases. + let rawRow = splitFields(lines[raw.lineIndex]) + for (i, rawName) in rawFieldNames.enumerated() { + let canonical = canonicalFieldName(rawName) + if canonical != rawName { + sample.values[rawName] = rawRow[i] + } + } + samples.append(sample) + } + + let colorRep = keywords["COLOR_REP"] ?? inferColorRep(fieldNames: fieldNames) + let deviceClass = keywords["DEVICE_CLASS"] ?? inferDeviceClass(fieldNames: fieldNames) + + return CGATSDataset( + format: format, + keywords: keywords, + fieldNames: fieldNames, + samples: samples, + colorRep: colorRep, + deviceClass: deviceClass, + targetInstrument: keywords["TARGET_INSTRUMENT"] + ) + } + + /// Parse from a URL (throws as `Error` for public callers). + public static func parse(url: URL) throws -> CGATSDataset { + let contents = try String(contentsOf: url) + return try parse(contents, sourceURL: url) + } + + // MARK: - Internals + + private static func preprocess( + _ contents: String, + isCSV: Bool + ) throws -> (CGATSFormat, [String]) { + let allLines = contents.components(separatedBy: .newlines) + var lines = [String]() + + var format: CGATSFormat? + for var line in allLines { + line = stripComment(line) + line = line.trimmingCharacters(in: .whitespaces) + guard !line.isEmpty else { continue } + + if format == nil { + if line.hasPrefix("CTI3") { format = .cti3 } + else if line.hasPrefix("CGATS.17") { format = .cgats17 } + else if isCSV { format = .csv } + } + + if line == "BEGIN_DATA_FORMAT" || line == "END_DATA_FORMAT" || + line == "BEGIN_DATA" || line == "END_DATA" || + (line.hasPrefix("BEGIN_DATA_FORMAT") || line.hasPrefix("END_DATA_FORMAT") || + line.hasPrefix("BEGIN_DATA") || line.hasPrefix("END_DATA")) { + // These are exact keywords; keep them intact. + } + + lines.append(line) + } + + guard !lines.isEmpty else { throw CGATSParseError.emptyFile } + + // Wrap a bare CSV / ISO28178 file in the canonical CGATS block + // structure so the boundary-based parser below can handle it. + if let format, format == .csv, + !lines.contains(where: { Self.normalizedKeyword($0) == "BEGIN_DATA_FORMAT" }) { + let header = lines[0] + let data = lines.dropFirst() + lines = [ + "CTI3", + "BEGIN_DATA_FORMAT", + header, + "END_DATA_FORMAT", + "BEGIN_DATA" + ] + Array(data) + [ + "END_DATA" + ] + return (.csv, lines) + } + + return (format ?? .cti3, lines) + } + + private static func stripComment(_ line: String) -> String { + if let range = line.range(of: "#") { + return String(line[.. String { + if let range = token.range(of: "#") { + return String(token[.. [String] { + // CTI3/CGATS.17 use whitespace/tabs; CSV uses commas. + if line.contains(",") { + return line.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespaces) } + } + return line.components(separatedBy: .whitespaces).filter { !$0.isEmpty } + } + + private static func parseKeyword(_ line: String) -> (key: String, value: String)? { + // KEYWORD value or KEYWORD "value" + let tokens = splitFields(line) + guard let key = tokens.first else { return nil } + + // Data-boundary keywords are not value keywords. + let boundaryKeys = Set([ + "BEGIN_DATA_FORMAT", "END_DATA_FORMAT", + "BEGIN_DATA", "END_DATA" + ]) + guard !boundaryKeys.contains(key) else { return nil } + + let rawValue = tokens.dropFirst().joined(separator: " ") + let value = rawValue.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + return (key, value) + } + + private static func normalizedKeyword(_ line: String) -> String { + line.uppercased().trimmingCharacters(in: .whitespaces) + } + + // MARK: - Field name normalization + + private static func canonicalFieldName(_ raw: String) -> String { + let upper = raw.uppercased() + .replacingOccurrences(of: " ", with: "_") + .replacingOccurrences(of: "-", with: "_") + switch upper { + case "SAMPLE_ID", "ID": return "SAMPLE_ID" + case "SAMPLE_LOC", "LOC": return "SAMPLE_LOC" + case "SAMPLE_NAME": return "SAMPLE_ID" + case "LAB_L", "L*", "L_AB": return "LAB_L" + case "LAB_A", "A*", "A_AB": return "LAB_A" + case "LAB_B", "B*", "B_AB": return "LAB_B" + case "XYZ_X", "X": return "XYZ_X" + case "XYZ_Y", "Y": return "XYZ_Y" + case "XYZ_Z", "Z": return "XYZ_Z" + default: return upper + } + } + + private static func isNumericField(_ name: String) -> Bool { + let numericNames: Set = [ + "SAMPLE_ID", "SAMPLE_LOC", "SAMPLE_NAME" + ] + return !numericNames.contains(name) + } + + private static func parseNumber(_ raw: String) -> Double? { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + return formatter.number(from: raw)?.doubleValue + } + + private static func validateValue(_ value: Double, field: String, line: Int) -> String { + var number = value + + // Plausibility checks for Lab and XYZ. + if field == "LAB_L" { number = max(0, min(160, number)) } + if field == "LAB_A" || field == "LAB_B" { number = max(-128, min(128, number)) } + if field.hasPrefix("XYZ_") { number = max(0, min(200, number)) } + + return String(format: "%.4f", number) + } + + private static func deviceGroup(_ name: String) -> String? { + if name.hasPrefix("RGB_") { return "RGB" } + if name.hasPrefix("CMYK_") { return "CMYK" } + if name.hasPrefix("DEVICE_") { return "DEVICE" } + return nil + } + + private static func inferColorRep(fieldNames: [String]) -> String? { + if fieldNames.contains(where: { $0.hasPrefix("CMYK_") }) { return "CMYK" } + if fieldNames.contains(where: { $0.hasPrefix("RGB_") }) { return "RGB" } + if fieldNames.contains(where: { $0.hasPrefix("LAB_") }) { return "LAB" } + if fieldNames.contains(where: { $0.hasPrefix("XYZ_") }) { return "XYZ" } + return nil + } + + private static func inferDeviceClass(fieldNames: [String]) -> String? { + if fieldNames.contains(where: { $0.hasPrefix("CMYK_") }) { return "PRINTER" } + if fieldNames.contains(where: { $0.hasPrefix("RGB_") }) { return "DISPLAY" } + return "OUTPUT" + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/CGATS/CGATSSummary.swift b/Packages/ICCeryCore/Sources/ICCeryCore/CGATS/CGATSSummary.swift new file mode 100644 index 0000000..8c602a0 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/CGATS/CGATSSummary.swift @@ -0,0 +1,20 @@ +import Foundation + +/// Human-readable summary of an imported CGATS dataset. +public struct CGATSSummary: Sendable, Equatable { + public let patchCount: Int + public let colorSpace: String? + public let deviceClass: String? + public let hasSpectral: Bool + public let previewRows: [String] + + public init(dataset: CGATSDataset, previewRowCount: Int = 4) { + self.patchCount = dataset.samples.count + self.colorSpace = dataset.colorRep + self.deviceClass = dataset.deviceClass + self.hasSpectral = dataset.fieldNames.contains { $0.hasPrefix("SPECTRAL_") } + self.previewRows = Array(dataset.samples.prefix(previewRowCount).map { sample in + "\(sample.id)" + (sample.loc.map { " \($0)" } ?? "") + }) + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/CGATS/CGATSWriter.swift b/Packages/ICCeryCore/Sources/ICCeryCore/CGATS/CGATSWriter.swift new file mode 100644 index 0000000..f73c889 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/CGATS/CGATSWriter.swift @@ -0,0 +1,82 @@ +import Foundation + +/// Errors from writing a canonical `.ti3` dataset. +public enum CGATSWriterError: Error, Equatable { + case noSamples + case missingRequiredField(String) + case invalidValue(field: String, value: String) +} + +/// Write a `CGATSDataset` to Argyll-consumable `.ti3` text. +public enum CGATSWriter { + + public static func write(_ dataset: CGATSDataset) throws -> String { + guard !dataset.samples.isEmpty, !dataset.fieldNames.isEmpty else { + throw CGATSWriterError.noSamples + } + + var lines = [String]() + + // Header + lines.append(dataset.format.rawValue) + lines.append("") + + lines.append("DESCRIPTOR \"ICCery CGATS export\"") + if let colorRep = dataset.colorRep { + lines.append("COLOR_REP \"\(colorRep)\"") + } + if let deviceClass = dataset.deviceClass { + lines.append("DEVICE_CLASS \"\(deviceClass)\"") + } + if let instrument = dataset.targetInstrument { + lines.append("TARGET_INSTRUMENT \"\(instrument)\"") + } + + lines.append("NUMBER_OF_FIELDS \(dataset.fieldNames.count)") + lines.append("NUMBER_OF_SETS \(dataset.samples.count)") + lines.append("") + + lines.append("BEGIN_DATA_FORMAT") + lines.append(dataset.fieldNames.joined(separator: "\t")) + lines.append("END_DATA_FORMAT") + lines.append("") + + lines.append("BEGIN_DATA") + for sample in dataset.samples { + let row = try dataset.fieldNames.map { field in + guard let raw = sample.values[field], !raw.isEmpty else { + throw CGATSWriterError.missingRequiredField(field) + } + // Normalize numeric fields to a compact decimal. + if isNumeric(field) { + return normalizedNumber(raw) + } + return raw + } + lines.append(row.joined(separator: "\t")) + } + lines.append("END_DATA") + + return lines.joined(separator: "\n") + "\n" + } + + public static func write(_ dataset: CGATSDataset, to url: URL) throws { + let text = try write(dataset) + try text.write(to: url, atomically: true, encoding: .utf8) + } + + // MARK: - Internals + + private static func isNumeric(_ field: String) -> Bool { + let nonNumeric: Set = ["SAMPLE_ID", "SAMPLE_LOC", "SAMPLE_NAME"] + return !nonNumeric.contains(field) + } + + private static func normalizedNumber(_ raw: String) -> String { + guard let number = Double(raw) else { return raw } + if number == floor(number) { + return String(format: "%.0f", number) + } + return String(format: "%.4f", number) + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactFiles.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactFiles.swift new file mode 100644 index 0000000..d40b262 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactFiles.swift @@ -0,0 +1,40 @@ +import Foundation + +/// Small host-side file helpers (issue #6). +public enum ArtefactFiles { + + /// `get_default_working_dir` — `resolveSafeCwd(nil)`. + public static func defaultWorkingDirectory() -> URL { + PathSecurity.resolveSafeCwd(nil) + } + + /// `read_file_base64` — for **text artefacts** the UI needs verbatim + /// (ti1/ti2 previews, CGATS datasets, logs). Binary payloads (TIFF) + /// go through `TiffPreview` instead. + public static func readBase64(_ url: URL) throws -> String { + try Data(contentsOf: url).base64EncodedString() + } + + /// `get_app_info` — version, build, and build date for the About dialog. + public static func appInfo( + bundle: Bundle = .main + ) -> (version: String, build: String, buildDate: String) { + let info = bundle.infoDictionary ?? [:] + let version = info["CFBundleShortVersionString"] as? String ?? "0.0.0" + let build = info["CFBundleVersion"] as? String ?? "0" + + let url = bundle.executableURL ?? bundle.bundleURL + let buildDate: String + if let values = try? url.resourceValues(forKeys: [.contentModificationDateKey]), + let date = values.contentModificationDate { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .none + buildDate = formatter.string(from: date) + } else { + buildDate = "Unknown" + } + + return (version, build, buildDate) + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactProbe.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactProbe.swift new file mode 100644 index 0000000..0e32fb1 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactProbe.swift @@ -0,0 +1,135 @@ +import Foundation + +/// Result of `verify_stage_artefacts(cwd, basename)` (docs/06). +public struct StageArtefacts: Sendable, Equatable { + /// `.ti1` exists (Stage 1 done → unlocks Stage 2). + public var stage1Complete = false + /// `.ti2` exists (Stage 2 done → with ti1, unlocks Stage 3). + public var stage2Complete = false + /// `.ti3` exists (Stage 3 done → unlocks Stage 4). + public var stage3Complete = false + /// `.icc`/`.icm` exists (Stage 4 done → with ti3, unlocks Stage 5). + public var stage4Complete = false + /// Absolute path of the profile file when present. + public var profilePath: URL? + /// Absolute path of the `.gam` gamut mesh when present (issue #28). + public var gamPath: URL? + + public init( + stage1Complete: Bool = false, + stage2Complete: Bool = false, + stage3Complete: Bool = false, + stage4Complete: Bool = false, + profilePath: URL? = nil, + gamPath: URL? = nil + ) { + self.stage1Complete = stage1Complete + self.stage2Complete = stage2Complete + self.stage3Complete = stage3Complete + self.stage4Complete = stage4Complete + self.profilePath = profilePath + self.gamPath = gamPath + } +} + +/// Filesystem probing for wizard artefacts (docs/02 §Working directory, +/// docs/06 §Stages). All artefacts live next to each other in `cwd`. +public enum ArtefactProbe { + + /// `verify_stage_artefacts` — the gating truth source. + public static func verify( + basename: String, + cwd: URL, + fileManager: FileManager = .default + ) -> StageArtefacts { + var out = StageArtefacts() + out.stage1Complete = exists(artefact(basename, "ti1", cwd), fm: fileManager) + out.stage2Complete = exists(artefact(basename, "ti2", cwd), fm: fileManager) + out.stage3Complete = exists(artefact(basename, "ti3", cwd), fm: fileManager) + if let profile = resolveProfile(basename: basename, cwd: cwd, fileManager: fileManager) { + out.stage4Complete = true + out.profilePath = profile + let gam = artefact(basename, "gam", cwd) + if exists(gam, fm: fileManager) { + out.gamPath = gam + } + } + return out + } + + /// `/.` — the canonical artefact URL. + public static func artefact(_ basename: String, _ ext: String, _ cwd: URL) -> URL { + cwd.appendingPathComponent("\(basename).\(ext)", isDirectory: false) + } + + /// Profile extension resolution (#69): existing `.icm` wins over + /// `.icc`; when neither exists the macOS default is `.icc`. + /// (`profcheck`/`iccgamut` swap extension when the requested path is + /// missing.) + public static func resolveProfile( + basename: String, + cwd: URL, + fileManager: FileManager = .default + ) -> URL? { + let icm = artefact(basename, "icm", cwd) + if exists(icm, fm: fileManager) { return icm } + let icc = artefact(basename, "icc", cwd) + if exists(icc, fm: fileManager) { return icc } + return nil + } + + /// Resolve an explicit profile URL, flipping `.icc` ↔ `.icm` when the + /// requested path is missing (#69 / issue #83). Any other extension + /// (`.mpp`, `.txt`, …) is returned unchanged — never rewritten. + public static func resolveProfile( + _ url: URL, + fileManager: FileManager = .default + ) -> URL { + if fileManager.fileExists(atPath: url.path) { return url } + let ext = url.pathExtension.lowercased() + guard ext == "icc" || ext == "icm" else { return url } + let alt = url.deletingPathExtension() + .appendingPathExtension(ext == "icc" ? "icm" : "icc") + return fileManager.fileExists(atPath: alt.path) ? alt : url + } + + /// Default extension for a *new* profile on macOS (#69). + public static let defaultProfileExtension = "icc" + + /// Every artefact path for a basename: `.ti1 .ti2 .tif .N.tif + /// .ti3 _passN.ti3 .icc .icm .gam` plus the `CAL_` namespace. + /// Multi-page TIFFs match `.tif`, `.1.tif` … and + /// `_NN.tif` (manifest naming). + public static func existingArtefacts( + basename: String, + cwd: URL, + fileManager: FileManager = .default + ) -> [URL] { + guard let entries = try? fileManager.contentsOfDirectory( + at: cwd, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) else { return [] } + + let prefixes = [basename + ".", "CAL_" + basename + "."] + let suffixes: Set = ["ti1", "ti2", "tif", "ti3", "icc", "icm", "gam", "cal"] + let passPrefix = basename + "_pass" + let tifStemPrefix = basename + "_" + let calPrefix = "CAL_" + basename + + return entries.filter { url in + let name = url.lastPathComponent + let ext = url.pathExtension.lowercased() + guard suffixes.contains(ext) else { return false } + if prefixes.contains(where: { name.hasPrefix($0) }) { return true } + if name.hasPrefix(passPrefix), ext == "ti3" { return true } + if name.hasPrefix(tifStemPrefix), ext == "tif" { return true } + if name.hasPrefix(calPrefix) { return true } + return false + }.sorted { $0.lastPathComponent < $1.lastPathComponent } + } + + private static func exists(_ url: URL, fm: FileManager) -> Bool { + fm.fileExists(atPath: url.path) + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Files/AtomicFileWriter.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Files/AtomicFileWriter.swift new file mode 100644 index 0000000..40a29b0 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Files/AtomicFileWriter.swift @@ -0,0 +1,34 @@ +import Foundation + +/// Atomic `.tmp`-then-rename file writes — the convention used by +/// settings.json, verification_history.json and wizard_state.json +/// (docs/02 §Persistence, #213). +public enum AtomicFileWriter { + + /// Writes `data` to `url` atomically: sibling `.tmp`, then a + /// rename (which is atomic on APFS/HFS+). Parent dirs are created. + public static func write(_ data: Data, to url: URL) throws { + let fm = FileManager.default + let dir = url.deletingLastPathComponent() + try fm.createDirectory(at: dir, withIntermediateDirectories: true) + + let tmp = url.appendingPathExtension("tmp") + do { + try data.write(to: tmp, options: []) + // replaceItemAt handles same-volume atomic swap and removes + // the destination cleanly; fall back to remove+move. + if fm.fileExists(atPath: url.path) { + _ = try fm.replaceItemAt(url, withItemAt: tmp) + } else { + try fm.moveItem(at: tmp, to: url) + } + } catch { + try? fm.removeItem(at: tmp) + throw error + } + } + + public static func write(_ text: String, to url: URL) throws { + try write(Data(text.utf8), to: url) + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Files/JSONFileStore.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Files/JSONFileStore.swift new file mode 100644 index 0000000..d9a5afe --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Files/JSONFileStore.swift @@ -0,0 +1,85 @@ +import Foundation + +/// Policy when a JSON file exists but cannot be decoded. +public enum JSONCorruptPolicy: Sendable { + /// Return `defaultValue` and leave the file untouched. + case replaceWithDefault + /// Throw the decode error. Callers must not overwrite the file. + case throwCorrupt +} + +/// Shared pretty-printed JSON file façade used by settings, wizard state, +/// and verification history. +public struct JSONFileStore: Sendable { + public let fileURL: URL + public let corrupt: JSONCorruptPolicy + private let defaultValue: @Sendable () -> T + private let encoder: JSONEncoder + private let decoder: JSONDecoder + + public init( + fileURL: URL, + corrupt: JSONCorruptPolicy, + defaultValue: @escaping @Sendable () -> T, + dateEncoding: JSONEncoder.DateEncodingStrategy = .deferredToDate, + dateDecoding: JSONDecoder.DateDecodingStrategy = .deferredToDate + ) { + self.fileURL = fileURL + self.corrupt = corrupt + self.defaultValue = defaultValue + self.encoder = JSONEncoder.icceryPretty(dateEncoding: dateEncoding) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = dateDecoding + self.decoder = decoder + } + + /// Encodes `value` with the shared pretty / sorted-keys encoder. + public func encodePretty(_ value: T) throws -> Data { + try encoder.encode(value) + } + + public func load() throws -> T { + let fm = FileManager.default + guard fm.fileExists(atPath: fileURL.path) else { + return defaultValue() + } + let data: Data + do { + data = try Data(contentsOf: fileURL) + } catch { + switch corrupt { + case .replaceWithDefault: + return defaultValue() + case .throwCorrupt: + throw error + } + } + do { + return try decoder.decode(T.self, from: data) + } catch { + switch corrupt { + case .replaceWithDefault: + return defaultValue() + case .throwCorrupt: + throw error + } + } + } + + public func save(_ value: T) throws { + try AtomicFileWriter.write(try encodePretty(value), to: fileURL) + } +} + +extension JSONEncoder { + /// Shared pretty-printed, sorted-keys encoder used by `JSONFileStore` + /// and preset export. + static func icceryPretty( + dateEncoding: JSONEncoder.DateEncodingStrategy = .deferredToDate + ) -> JSONEncoder { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = dateEncoding + return encoder + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Files/PathSecurity.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Files/PathSecurity.swift new file mode 100644 index 0000000..3b158a5 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Files/PathSecurity.swift @@ -0,0 +1,54 @@ +import Foundation + +/// Basename sanitisation and safe working-directory resolution +/// (docs/02 §Working directory, docs/06 §Empty cwd). +public enum PathSecurity { + + public enum Error: Swift.Error, Equatable, Sendable { + case invalidBasename(String) + } + + /// Basenames must not contain `/`, `\`, or `..` and must be + /// non-empty. Never invent a default basename (#60). + public static func isValidBasename(_ name: String) -> Bool { + guard !name.isEmpty else { return false } + return !name.contains("/") && !name.contains("\\") && !name.contains("..") + } + + @discardableResult + public static func sanitizeBasename(_ name: String) throws -> String { + guard isValidBasename(name) else { + throw Error.invalidBasename(name) + } + return name + } + + /// `resolve_safe_cwd` (docs/04 §0.2): explicit real directory → + /// Documents → Home → app-data. Never returns an empty/nil cwd. + public static func resolveSafeCwd( + _ explicit: URL?, + fileManager: FileManager = .default + ) -> URL { + if let explicit, + fileManager.fileExists(atPath: explicit.path, isDirectory: nil) { + return explicit + } + let candidates: [URL?] = [ + fileManager.urls(for: .documentDirectory, in: .userDomainMask).first, + fileManager.homeDirectoryForCurrentUser, + AppPaths.appDataDir, + ] + for candidate in candidates { + guard let url = candidate else { continue } + if !fileManager.fileExists(atPath: url.path) { + try? fileManager.createDirectory(at: url, withIntermediateDirectories: true) + } + if fileManager.fileExists(atPath: url.path, isDirectory: nil) { + return url + } + } + // Last resort: app-data, created unconditionally. + try? fileManager.createDirectory(at: AppPaths.appDataDir, withIntermediateDirectories: true) + return AppPaths.appDataDir + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Files/Ti2Header.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Files/Ti2Header.swift new file mode 100644 index 0000000..79560e9 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Files/Ti2Header.swift @@ -0,0 +1,53 @@ +import Foundation + +/// Parsed header of a `.ti2` chart-layout file (docs/06 §Resume). +/// `parse_ti2_header` reads only CGATS keyword lines — the data grid +/// itself belongs to issue #30. +public struct Ti2Header: Sendable, Equatable { + /// `TARGET_INSTRUMENT` (e.g. `i1`, `i1iO`, `CM`). + public var instrument: String? + /// `NUMBER_OF_SETS` — the patch count. Note: `NUMBER_OF_FIELDS` is + /// the CGATS column count, *not* the patch count. + public var patchCount: Int? + /// `NUMBER_OF_PAGES`. + public var pageCount: Int? + /// A sibling `.ti1` exists next to the parsed file. + public var hasSiblingTi1 = false + + public static func parse( + _ url: URL, + fileManager: FileManager = .default + ) -> Ti2Header { + var header = Ti2Header() + guard let text = try? String(contentsOf: url, encoding: .utf8) else { + return header + } + for rawLine in text.split(whereSeparator: \.isNewline) { + let line = rawLine.trimmingCharacters(in: .whitespaces) + if line.hasPrefix("BEGIN_DATA_FORMAT") || line.hasPrefix("BEGIN_DATA") { + break + } + // CGATS keyword lines: `KEYWORD "value"` or `KEYWORD value`. + guard let space = line.firstIndex(of: " ") else { continue } + let key = String(line[.. Data? { + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { + return nil + } + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceThumbnailMaxPixelSize: maxEdge, + kCGImageSourceCreateThumbnailWithTransform: true, + ] + guard let image = CGImageSourceCreateThumbnailAtIndex( + source, 0, options as CFDictionary + ) else { return nil } + + let out = NSMutableData() + guard let dest = CGImageDestinationCreateWithData( + out, UTType.png.identifier as CFString, 1, nil + ) else { return nil } + CGImageDestinationAddImage(dest, image, nil) + guard CGImageDestinationFinalize(dest) else { return nil } + return out as Data + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Gamut/ApproximateLab.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Gamut/ApproximateLab.swift new file mode 100644 index 0000000..9310082 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Gamut/ApproximateLab.swift @@ -0,0 +1,64 @@ +import Foundation + +/// Approximate sRGB → CIELab D50 conversion for the gamut inspect panel +/// (issue #147). +/// +/// This is a fixed-matrix helper, **not** a colour-management module: it +/// never touches ICC profiles, ColorSync, or lcms. The UI labels its +/// output "approx. Lab, not ColorSync". +public enum ApproximateLab { + + /// linear-sRGB → XYZ (D65) matrix, IEC 61966-2-1. + private static let srgbToXYZ: [[Double]] = [ + [0.4124, 0.3576, 0.1805], + [0.2126, 0.7152, 0.0722], + [0.0193, 0.1192, 0.9505], + ] + + /// 8-bit sRGB triple → Lab D50 (approximate). + public static func srgb8ToLab(r: Int, g: Int, b: Int) -> LabColor { + srgbToLab(DisplayRGB( + r: Double(r) / 255.0, + g: Double(g) / 255.0, + b: Double(b) / 255.0)) + } + + /// 0–1 sRGB triple → Lab D50 (approximate). + public static func srgbToLab(_ rgb: DisplayRGB) -> LabColor { + func linear(_ c: Double) -> Double { + c <= 0.04045 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4) + } + let v = [linear(rgb.r), linear(rgb.g), linear(rgb.b)] + // 0–1 XYZ D65 → the 0–100 scale `LabColorMath` works in. + let xyz65 = XYZColor( + x: (srgbToXYZ[0][0] * v[0] + srgbToXYZ[0][1] * v[1] + srgbToXYZ[0][2] * v[2]) * 100, + y: (srgbToXYZ[1][0] * v[0] + srgbToXYZ[1][1] * v[1] + srgbToXYZ[1][2] * v[2]) * 100, + z: (srgbToXYZ[2][0] * v[0] + srgbToXYZ[2][1] * v[1] + srgbToXYZ[2][2] * v[2]) * 100) + return LabColorMath.xyzToLab(adaptD65ToD50(xyz65)) + } + + /// Bradford D65 → D50 chromatic adaptation — the mirror of + /// `LabColorMath.adaptD50ToD65`. + private static func adaptD65ToD50(_ xyz: XYZColor) -> XYZColor { + let m = LabColorMath.bradford + let inv = LabColorMath.bradfordInv + let d65 = LabColorMath.d65White + let d50 = LabColorMath.d50White + let source = multiply(m, [xyz.x, xyz.y, xyz.z]) + let srcWhite = multiply(m, [d65.X, d65.Y, d65.Z]) + let dstWhite = multiply(m, [d50.X, d50.Y, d50.Z]) + let scaled = [ + source[0] * (dstWhite[0] / srcWhite[0]), + source[1] * (dstWhite[1] / srcWhite[1]), + source[2] * (dstWhite[2] / srcWhite[2]), + ] + return XYZColor( + x: inv[0][0] * scaled[0] + inv[0][1] * scaled[1] + inv[0][2] * scaled[2], + y: inv[1][0] * scaled[0] + inv[1][1] * scaled[1] + inv[1][2] * scaled[2], + z: inv[2][0] * scaled[0] + inv[2][1] * scaled[1] + inv[2][2] * scaled[2]) + } + + private static func multiply(_ m: [[Double]], _ v: [Double]) -> [Double] { + m.map { row in zip(row, v).reduce(0) { $0 + $1.0 * $1.1 } } + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Gamut/GamutContainment.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Gamut/GamutContainment.swift new file mode 100644 index 0000000..9202712 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Gamut/GamutContainment.swift @@ -0,0 +1,115 @@ +import Foundation +import simd + +/// Result of a point-in-gamut test (issue #147). +public enum GamutContainment: String, Sendable, Equatable { + /// The point lies inside the mesh volume. + case inside + /// The point lies outside the mesh volume. + case outside + /// The mesh has no faces to test against. + case unknown +} + +/// Point-in-mesh containment and volume estimation for ``GamutMesh``. +/// +/// Both tests run on the scene-space positions stored on +/// ``GamutVertex/position`` (`x = a*`, `y = L*`, `z = b*`), the same +/// mapping the SceneKit viewer uses. No colour management is involved. +public enum GamutGeometry { + + /// Whether `lab` is inside `mesh`. + /// + /// Ray-casts through the face list: an odd crossing count means the + /// point is inside a closed surface. A ray that grazes a vertex or + /// edge gives an ambiguous count, so the test retries with off-axis + /// directions before answering. Meshes without faces report + /// ``GamutContainment/unknown``. + public static func containment(of lab: LabColor, in mesh: GamutMesh) -> GamutContainment { + guard !mesh.faces.isEmpty else { return .unknown } + let origin = SIMD3(lab.a, lab.l, lab.b) + for direction in rayDirections { + if let inside = castRay(from: origin, direction: direction, mesh: mesh) { + return inside ? .inside : .outside + } + } + return .unknown + } + + /// Approximate mesh volume in Lab-cubic units. + /// + /// Sums signed tetrahedra from the vertex centroid to each face; for + /// a closed surface the magnitude equals the enclosed volume + /// regardless of face winding. Returns 0 for empty or face-less + /// meshes. + public static func volume(of mesh: GamutMesh) -> Double { + guard !mesh.faces.isEmpty, !mesh.vertices.isEmpty else { return 0 } + var centroid = SIMD3.zero + for vertex in mesh.vertices { + centroid += SIMD3(vertex.position) + } + centroid /= Double(mesh.vertices.count) + + var sum = 0.0 + let count = mesh.vertices.count + for face in mesh.faces { + guard Int(face.a) < count, Int(face.b) < count, Int(face.c) < count else { + continue + } + let a = SIMD3(mesh.vertices[Int(face.a)].position) - centroid + let b = SIMD3(mesh.vertices[Int(face.b)].position) - centroid + let c = SIMD3(mesh.vertices[Int(face.c)].position) - centroid + sum += simd_dot(a, simd_cross(b, c)) / 6.0 + } + return abs(sum) + } + + // MARK: - Ray casting + + /// Primary +X ray, then off-axis retries for degenerate edge hits. + private static let rayDirections: [SIMD3] = [ + SIMD3(1, 0, 0), + simd_normalize(SIMD3(0.71, 1.0, 0.53)), + simd_normalize(SIMD3(0.53, 0.71, 1.0)), + ] + + /// Möller–Trumbore crossing count. Returns `nil` when a crossing + /// lands on a triangle edge or vertex (ambiguous parity) so the + /// caller can retry with a different direction. + private static func castRay( + from origin: SIMD3, + direction dir: SIMD3, + mesh: GamutMesh + ) -> Bool? { + let epsilon = 1e-9 + var crossings = 0 + let count = mesh.vertices.count + for face in mesh.faces { + guard Int(face.a) < count, Int(face.b) < count, Int(face.c) < count else { + continue + } + let va = SIMD3(mesh.vertices[Int(face.a)].position) + let vb = SIMD3(mesh.vertices[Int(face.b)].position) + let vc = SIMD3(mesh.vertices[Int(face.c)].position) + + let e1 = vb - va + let e2 = vc - va + let p = simd_cross(dir, e2) + let det = simd_dot(e1, p) + if abs(det) < 1e-12 { continue } // ray parallel to face + let inv = 1.0 / det + let tvec = origin - va + let u = simd_dot(tvec, p) * inv + let q = simd_cross(tvec, e1) + let v = simd_dot(dir, q) * inv + let t = simd_dot(e2, q) * inv + + guard t > epsilon else { continue } + if u < -epsilon || v < -epsilon || u + v > 1 + epsilon { continue } + // Crossing on an edge or vertex — parity is ambiguous. + if u < epsilon || v < epsilon || u + v > 1 - epsilon { return nil } + crossings += 1 + } + return crossings % 2 == 1 + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Gamut/GamutMesh.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Gamut/GamutMesh.swift new file mode 100644 index 0000000..117cbee --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Gamut/GamutMesh.swift @@ -0,0 +1,51 @@ +import Foundation +import simd + +/// A single vertex of an Argyll `.gam` surface mesh. +/// +/// Coordinates follow the v0.8.5 SceneKit convention: `x = a*`, `y = L*`, +/// `z = b*` so that the a* (green-red) axis is horizontal, L* (lightness) +/// is vertical, and b* (blue-yellow) is depth. +public struct GamutVertex: Sendable, Equatable { + public let lab: LabColor + public let rgb: DisplayRGB + public let position: SIMD3 + + public init(lab: LabColor, rgb: DisplayRGB) { + self.lab = lab + self.rgb = rgb + self.position = SIMD3(Float(lab.a), Float(lab.l), Float(lab.b)) + } +} + +/// A face from an Argyll `.gam` file. +/// +/// Indices are 0-based and index into `GamutMesh.vertices` in the order the +/// vertices were pushed by the parser (the `VERTEX_NO` column is discarded). +public struct GamutTriangle: Sendable, Equatable { + public let a: UInt32 + public let b: UInt32 + public let c: UInt32 + + public init(a: UInt32, b: UInt32, c: UInt32) { + self.a = a + self.b = b + self.c = c + } +} + +/// Parsed gamut surface mesh. +public struct GamutMesh: Sendable, Equatable { + public let vertices: [GamutVertex] + public let faces: [GamutTriangle] + + public init(vertices: [GamutVertex], faces: [GamutTriangle]) { + self.vertices = vertices + self.faces = faces + } + + /// A printable summary for diagnostics. + public var summary: String { + "GamutMesh(vertices: \(vertices.count), faces: \(faces.count))" + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Gamut/GamutMeshParser.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Gamut/GamutMeshParser.swift new file mode 100644 index 0000000..56a04d1 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Gamut/GamutMeshParser.swift @@ -0,0 +1,152 @@ +import Foundation + +/// Errors thrown by ``GamutMeshParser``. +public enum GamutMeshParseError: LocalizedError, Equatable, Sendable { + case missingFile + case readFailed(underlying: String) + case emptyFile + case noDataBlock + case malformedVertexLine(line: Int, content: String) + case malformedFaceLine(line: Int, content: String) + case outOfBoundsVertexIndex(UInt32, max: UInt32) + case invalidLabPlausibility(line: Int, content: String) + + public var errorDescription: String? { + switch self { + case .missingFile: + return "Gamut file not found." + case .readFailed(let reason): + return "Could not read gamut file: \(reason)" + case .emptyFile: + return "Gamut file is empty." + case .noDataBlock: + return "Gamut file contains no BEGIN_DATA blocks." + case .malformedVertexLine(let line, let content): + return "Malformed vertex on line \(line): \(content)" + case .malformedFaceLine(let line, let content): + return "Malformed face on line \(line): \(content)" + case .outOfBoundsVertexIndex(let index, let max): + return "Face references vertex \(index) but only \(max + 1) vertices exist." + case .invalidLabPlausibility(let line, let content): + return "Lab value outside plausible range on line \(line): \(content)" + } + } +} + +/// Parses Argyll `.gam` ASCII files into ``GamutMesh``. +/// +/// The parser recognises two `BEGIN_DATA` … `END_DATA` blocks: +/// +/// 1. Vertices: `VERTEX_NO LAB_L LAB_A LAB_B` +/// 2. Faces: `VERTEX_0 VERTEX_1 VERTEX_2` (0-based indices) +/// +/// Lines beginning with `#` and blank lines are ignored. `BEGIN_DATA` and +/// `END_DATA` are matched case-insensitively. The `VERTEX_NO` column is +/// discarded; vertices are indexed in push order, matching Argyll's output. +public enum GamutMeshParser { + + /// Parse the file at `url`. + public static func parse(url: URL) throws -> GamutMesh { + guard FileManager.default.fileExists(atPath: url.path) else { + throw GamutMeshParseError.missingFile + } + guard let data = FileManager.default.contents(atPath: url.path) else { + throw GamutMeshParseError.readFailed(underlying: "contents(atPath:) returned nil") + } + guard let text = String(data: data, encoding: .utf8) ?? String(data: data, encoding: .ascii), + !text.isEmpty else { + throw GamutMeshParseError.emptyFile + } + return try parse(text: text) + } + + /// Parse raw `.gam` text. + public static func parse(text: String) throws -> GamutMesh { + var vertices: [GamutVertex] = [] + var faces: [GamutTriangle] = [] + + var dataBlock = 0 + var inData = false + var lineNumber = 0 + var warnings: [String] = [] + + for rawLine in text.components(separatedBy: .newlines) { + lineNumber += 1 + + // Strip inline `#` comments before any other processing. + let uncommented = rawLine.split(separator: "#", maxSplits: 1).first.map(String.init) ?? "" + let trimmed = uncommented.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + + let upper = trimmed.uppercased() + + if upper == "BEGIN_DATA" { + dataBlock += 1 + inData = true + continue + } + if upper == "END_DATA" { + inData = false + continue + } + + if !inData { continue } + + let parts = trimmed.components(separatedBy: .whitespaces) + .filter { !$0.isEmpty } + .compactMap(Double.init) + + guard !parts.isEmpty else { continue } + + if dataBlock == 1 { + // Vertex format: index L a b + guard parts.count >= 4 else { + warnings.append("vertex arity \(parts.count) on line \(lineNumber)") + continue + } + let l = parts[1] + let a = parts[2] + let b = parts[3] + + if l < 0 || l > 100 || abs(a) > 128 || abs(b) > 128 { + warnings.append("Lab plausibility warning on line \(lineNumber): L=\(l) a=\(a) b=\(b)") + // We still keep the vertex; Argyll can exceed ±128. + } + + let lab = LabColor(l: l, a: a, b: b) + let rgb = LabColorMath.labToSRGB(lab) + vertices.append(GamutVertex(lab: lab, rgb: rgb)) + } else { + // Face format: v0 v1 v2 (can extend for future n-gons, take first 3) + guard parts.count >= 3 else { + warnings.append("face arity \(parts.count) on line \(lineNumber)") + continue + } + let idx = parts.prefix(3).compactMap { UInt32(exactly: $0) } + guard idx.count == 3 else { + warnings.append("non-integer face indices on line \(lineNumber)") + continue + } + faces.append(GamutTriangle(a: idx[0], b: idx[1], c: idx[2])) + } + } + + // Trim out-of-bounds face indices instead of throwing, so a slightly + // malformed file still renders. This matches the Web viewer's + // forgiving posture while surfacing the obvious cases. + let validFaces = faces.filter { face in + let max = UInt32(vertices.count) + guard face.a < max, face.b < max, face.c < max else { + warnings.append("dropping face \(face) referencing missing vertex") + return false + } + return true + } + + if dataBlock == 0 { + throw GamutMeshParseError.noDataBlock + } + + return GamutMesh(vertices: vertices, faces: validFaces) + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Gamut/NamedGamut.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Gamut/NamedGamut.swift new file mode 100644 index 0000000..57f3a79 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Gamut/NamedGamut.swift @@ -0,0 +1,39 @@ +import Foundation + +/// A parsed gamut mesh plus the display metadata the compare viewer +/// needs (issue #147). +/// +/// `displayName` is user-derived (a file name); views must render it +/// through `Text` only (#114). +public struct NamedGamut: Sendable, Equatable, Identifiable { + + /// What the layer is used for in the compare UI. + public enum Role: String, Sendable, Equatable { + /// Bundled reference space (sRGB). Cannot be removed, only hidden. + case reference + /// The workflow's own profile gamut. + case profileA + /// The user-added compare gamut. Replaced, never stacked. + case profileB + } + + public var id: String + public var displayName: String + public var role: Role + public var mesh: GamutMesh + public var sourceURL: URL + + public init( + id: String, + displayName: String, + role: Role, + mesh: GamutMesh, + sourceURL: URL + ) { + self.id = id + self.displayName = displayName + self.role = role + self.mesh = mesh + self.sourceURL = sourceURL + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Library/ICCeryProject.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Library/ICCeryProject.swift new file mode 100644 index 0000000..e45f2b2 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Library/ICCeryProject.swift @@ -0,0 +1,218 @@ +import Foundation + +/// The `.icceryproj` file — a JSON **index** over a basename + working +/// directory + optional media recipe/preset binding + last verification +/// snapshot (issue #149, docs/06 §Project file). +/// +/// The project is never a second source of truth: artefact gating stays +/// on disk (`ArtefactProbe`), and `wizard_state.json` continues to +/// persist the live session. snake_case keys match the v1 schema; +/// `schema_version != 1` is a hard decode error — v2 fields are never +/// partially decoded. +public struct ICCeryProject: Codable, Equatable, Sendable { + + /// The only schema version this build reads and writes. + public static let currentSchemaVersion = 1 + + public var schemaVersion: Int + public var name: String + public var notes: String + /// Run name without extension — never `CAL_`-persisted (#60/R11). + public var basename: String + /// Absolute working directory holding the artefacts (#59). + public var cwd: String + /// May differ from `basename` after a `.ti3` import (#94). + public var profileBasename: String? + /// CUPS queue id, when a printer was selected. + public var printerID: String? + public var printerDisplayName: String? + /// `MediaRecipe.id` — optional; ignored when the library file is + /// absent or the id is unknown (soft-dependency, #146). + public var mediaRecipeID: String? + public var presetID: String? + /// Absolute `.cal` path stored verbatim; `nil` = none. + public var calibrationURL: String? + public var lastVerification: VerificationSnapshot? + public var updated: Date + + public init( + schemaVersion: Int = ICCeryProject.currentSchemaVersion, + name: String = "", + notes: String = "", + basename: String, + cwd: String, + profileBasename: String? = nil, + printerID: String? = nil, + printerDisplayName: String? = nil, + mediaRecipeID: String? = nil, + presetID: String? = nil, + calibrationURL: String? = nil, + lastVerification: VerificationSnapshot? = nil, + updated: Date = Date() + ) { + self.schemaVersion = schemaVersion + self.name = name + self.notes = notes + self.basename = basename + self.cwd = cwd + self.profileBasename = profileBasename + self.printerID = printerID + self.printerDisplayName = printerDisplayName + self.mediaRecipeID = mediaRecipeID + self.presetID = presetID + self.calibrationURL = calibrationURL + self.lastVerification = lastVerification + self.updated = updated + } + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case name, notes, basename, cwd + case profileBasename = "profile_basename" + case printerID = "printer_id" + case printerDisplayName = "printer_display_name" + case mediaRecipeID = "media_recipe_id" + case presetID = "preset_id" + case calibrationURL = "calibration_url" + case lastVerification = "last_verification" + case updated + } + + /// Strict decode: `schema_version` is required and must equal 1 — + /// anything else throws before a single v2 field is read. Required + /// strings (`basename`, `cwd`) must be present; optionals default. + /// Unknown keys are ignored. + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + let version = try c.decode(Int.self, forKey: .schemaVersion) + guard version == ICCeryProject.currentSchemaVersion else { + throw ValidationError.unsupportedSchema(version) + } + schemaVersion = version + name = try c.decodeIfPresent(String.self, forKey: .name) ?? "" + notes = try c.decodeIfPresent(String.self, forKey: .notes) ?? "" + basename = try c.decode(String.self, forKey: .basename) + cwd = try c.decode(String.self, forKey: .cwd) + profileBasename = try c.decodeIfPresent(String.self, forKey: .profileBasename) + printerID = try c.decodeIfPresent(String.self, forKey: .printerID) + printerDisplayName = try c.decodeIfPresent(String.self, forKey: .printerDisplayName) + mediaRecipeID = try c.decodeIfPresent(String.self, forKey: .mediaRecipeID) + presetID = try c.decodeIfPresent(String.self, forKey: .presetID) + calibrationURL = try c.decodeIfPresent(String.self, forKey: .calibrationURL) + lastVerification = try c.decodeIfPresent(VerificationSnapshot.self, forKey: .lastVerification) + updated = try c.decodeIfPresent(Date.self, forKey: .updated) ?? Date() + } + + public enum ValidationError: LocalizedError, Equatable { + case unsupportedSchema(Int) + case emptyBasename + case invalidBasename(String) + case emptyCwd + case unsafeCwd(String) + case invalidCalibrationURL(String) + + public var errorDescription: String? { + switch self { + case .unsupportedSchema: + return "This project file is not schema 1." + case .emptyBasename: + return "A project needs a target basename." + case .invalidBasename(let v): + return "Illegal project basename \"\(v)\"." + case .emptyCwd: + return "A project needs a working folder." + case .unsafeCwd(let v): + return "cwd must be an absolute path, got \"\(v)\"." + case .invalidCalibrationURL(let v): + return "calibration_url must be an absolute path without \"..\" or NUL, got \"\(v)\"." + } + } + } + + /// Validates the index fields. Empty basename/cwd refuse (#59/#60); + /// basename still rejects `/`, `\`, `..`; cwd must be absolute. An + /// empty `name` falls back to the basename. + @discardableResult + public func validated() throws -> ICCeryProject { + var p = self + p.basename = basename.trimmingCharacters(in: .whitespacesAndNewlines) + p.name = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !p.basename.isEmpty else { throw ValidationError.emptyBasename } + guard PathSecurity.isValidBasename(p.basename) else { + throw ValidationError.invalidBasename(p.basename) + } + let trimmedCwd = p.cwd.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedCwd.isEmpty else { throw ValidationError.emptyCwd } + guard trimmedCwd.hasPrefix("/"), !trimmedCwd.contains("\0") else { + throw ValidationError.unsafeCwd(p.cwd) + } + p.cwd = trimmedCwd + if p.name.isEmpty { p.name = p.basename } + if let cal = p.calibrationURL, !cal.isEmpty { + guard cal.hasPrefix("/"), !cal.contains(".."), !cal.contains("\0") else { + throw ValidationError.invalidCalibrationURL(cal) + } + } + return p + } + + /// Reads a `.icceryproj` file. Throws `unsupportedSchema` for + /// `schema_version != 1` and the decode/validation error otherwise; + /// callers must leave live state untouched on failure. + public static func load(from url: URL) throws -> ICCeryProject { + let data = try Data(contentsOf: url) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(ICCeryProject.self, from: data).validated() + } + + /// Atomic `.tmp` + rename write via `AtomicFileWriter` (#213). + /// Validation runs first — a refused project never touches disk. + public func save(to url: URL) throws { + let encoder = JSONEncoder.icceryPretty(dateEncoding: .iso8601) + try AtomicFileWriter.write(try encoder.encode(validated()), to: url) + } +} + +/// The last `VerificationRecord` frozen into the project file — notes +/// only, never gating truth. +public struct VerificationSnapshot: Codable, Equatable, Sendable { + public var date: Date + public var avgDE00: Double + public var maxDE00: Double + /// `VerificationStatus.rawValue`. + public var status: String + public var profileFilename: String + + public init( + date: Date, + avgDE00: Double, + maxDE00: Double, + status: String, + profileFilename: String + ) { + self.date = date + self.avgDE00 = avgDE00 + self.maxDE00 = maxDE00 + self.status = status + self.profileFilename = profileFilename + } + + public init(record: VerificationRecord) { + self.init( + date: record.timestamp, + avgDE00: record.avgDE, + maxDE00: record.maxDE, + status: record.status.rawValue, + profileFilename: record.profileName + ) + } + + enum CodingKeys: String, CodingKey { + case date + case avgDE00 = "avg_de00" + case maxDE00 = "max_de00" + case status + case profileFilename = "profile_filename" + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Library/MediaLibraryStore.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Library/MediaLibraryStore.swift new file mode 100644 index 0000000..d84165c --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Library/MediaLibraryStore.swift @@ -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." + } + } + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Library/MediaRecipe.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Library/MediaRecipe.swift new file mode 100644 index 0000000..bd29a40 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Library/MediaRecipe.swift @@ -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-"`, 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 + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Library/ProjectReport.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Library/ProjectReport.swift new file mode 100644 index 0000000..d5a94ab --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Library/ProjectReport.swift @@ -0,0 +1,95 @@ +import Foundation + +/// `Save Report…` — a one-page UTF-8 Markdown summary written next to +/// the artefacts as `{basename}-report.md` (issue #149). Generated +/// output, safe to overwrite. Filenames only — never absolute paths — +/// and no HTML. +public enum ProjectReport { + + /// `{cwd}/{basename}-report.md`. + public static func url(for project: ICCeryProject) -> URL { + URL(fileURLWithPath: project.cwd, isDirectory: true) + .appendingPathComponent("\(project.basename)-report.md") + } + + /// Renders the report. `artefacts` is the live probe result so the + /// checklist shows what is actually on disk, not what the project + /// JSON claims (R18). + public static func markdown( + project: ICCeryProject, + recipeName: String?, + artefacts: StageArtefacts + ) -> String { + var lines: [String] = [] + lines.append("# \(project.name)") + lines.append("") + if let printer = project.printerDisplayName ?? project.printerID, + !printer.isEmpty { + lines.append("- **Printer:** \(printer)") + } + if let recipeName, !recipeName.isEmpty { + lines.append("- **Media recipe:** \(recipeName)") + } + if let preset = project.presetID, !preset.isEmpty { + lines.append("- **Preset:** \(preset)") + } + lines.append("") + + lines.append("## Artefacts") + lines.append("") + lines.append("| File | Status |") + lines.append("|------|--------|") + lines.append(row("\(project.basename).ti1", exists: artefacts.stage1Complete)) + lines.append(row("\(project.basename).ti2", exists: artefacts.stage2Complete)) + lines.append(row("\(project.basename).ti3", exists: artefacts.stage3Complete)) + let profileName = artefacts.profilePath?.lastPathComponent + ?? "\(project.basename).\(ArtefactProbe.defaultProfileExtension)" + lines.append(row(profileName, exists: artefacts.stage4Complete)) + if let gam = artefacts.gamPath { + lines.append(row(gam.lastPathComponent, exists: true)) + } + lines.append("") + + if let verification = project.lastVerification { + lines.append("## Last verification") + lines.append("") + lines.append( + "- avg ΔE₀₀ \(f(verification.avgDE00)), max ΔE₀₀ \(f(verification.maxDE00))" + + " (\(verification.status))") + lines.append("- Profile: \(verification.profileFilename)") + lines.append("- Date: \(ISO8601DateFormatter().string(from: verification.date))") + lines.append("") + } + + if !project.notes.isEmpty { + lines.append("## Notes") + lines.append("") + lines.append(project.notes) + lines.append("") + } + return lines.joined(separator: "\n") + } + + /// Writes `{cwd}/{basename}-report.md` atomically, overwriting an + /// existing generated report. + @discardableResult + public static func write( + project: ICCeryProject, + recipeName: String?, + artefacts: StageArtefacts + ) throws -> URL { + let destination = url(for: project) + try AtomicFileWriter.write( + markdown(project: project, recipeName: recipeName, artefacts: artefacts), + to: destination) + return destination + } + + private static func row(_ filename: String, exists: Bool) -> String { + "| \(filename) | \(exists ? "exists" : "missing") |" + } + + private static func f(_ value: Double) -> String { + String(format: "%.2f", value) + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Library/RecentProjectsStore.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Library/RecentProjectsStore.swift new file mode 100644 index 0000000..630e81f --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Library/RecentProjectsStore.swift @@ -0,0 +1,162 @@ +import Foundation + +/// One row in `recent_projects.json` — bookmark `Data` *and* the plain +/// absolute path so the entry still resolves when the bookmark can't. +public struct RecentProjectEntry: Codable, Equatable, Sendable, Identifiable { + public var name: String + /// Absolute path to the `.icceryproj` file. + public var path: String + /// File bookmark; optional — path is the fallback. + public var bookmark: Data? + public var updated: Date + + public var id: String { path } + + /// Stable digest of `path` for `projectRecent-{hash}` menu ids — + /// FNV-1a 64, stable across launches unlike `hashValue`. + public var bookmarkHash: String { + var hash: UInt64 = 0xcbf29ce484222325 + for byte in path.utf8 { + hash ^= UInt64(byte) + hash &*= 0x100000001b3 + } + return String(hash, radix: 16) + } + + public init(name: String, path: String, bookmark: Data? = nil, updated: Date = Date()) { + self.name = name + self.path = path + self.bookmark = bookmark + self.updated = updated + } + + enum CodingKeys: String, CodingKey { + case name, path, bookmark, updated + } +} + +/// Recent-projects list (issue #149). Lives in +/// `AppPaths.appDataDir/recent_projects.json` — app data, never inside +/// the project file (R12). +/// +/// Cap 20, newest first, deduplicated by path. Entries whose file is +/// gone are dropped by `pruneMissing()` (called when the Open Recent +/// submenu builds). A corrupt file throws on load and is never +/// overwritten — the view model shows an empty list and keeps the +/// bytes (`.throwCorrupt`, R12). +public actor RecentProjectsStore { + + /// Default cap. + public static let defaultCapacity = 20 + + /// Path to the JSON store. + public let url: URL + + /// In-memory cache, kept in sync with disk. + private var entries: [RecentProjectEntry] = [] + + /// Explicit load flag — an empty file is still "loaded". + private var loaded = false + + private let capacity: Int + private let fileStore: JSONFileStore<[RecentProjectEntry]> + private let fileManager: FileManager + + public init( + url: URL = AppPaths.appDataDir.appendingPathComponent("recent_projects.json"), + capacity: Int = defaultCapacity, + fileManager: FileManager = .default + ) { + self.url = url + self.capacity = capacity + self.fileManager = fileManager + self.fileStore = JSONFileStore( + fileURL: url, + corrupt: .throwCorrupt, + defaultValue: { [] }, + dateEncoding: .iso8601, + dateDecoding: .iso8601 + ) + } + + /// Loads entries from disk. Returns the existing cache if already + /// loaded. Throws when the file exists but cannot be parsed; the + /// existing file is never overwritten in that case. + public func load() throws -> [RecentProjectEntry] { + guard !loaded else { return entries } + guard fileManager.fileExists(atPath: url.path) else { + loaded = true + return [] + } + entries = try fileStore.load() + loaded = true + return entries + } + + /// Returns all cached entries, newest first. + public func all() -> [RecentProjectEntry] { + entries + } + + /// Pushes a project to the front, deduplicating by path and + /// trimming to `capacity`. Writes atomically. + /// + /// Loads the existing list first and propagates any load error so + /// an unparseable file is never overwritten. + @discardableResult + public func add(url fileURL: URL, name: String) throws -> [RecentProjectEntry] { + try load() + let bookmark = try? fileURL.bookmarkData() + var updated = entries.filter { $0.path != fileURL.path } + updated.insert( + RecentProjectEntry( + name: name, + path: fileURL.path, + bookmark: bookmark, + updated: Date()), + at: 0) + if updated.count > capacity { + updated = Array(updated.prefix(capacity)) + } + try fileStore.save(updated) + entries = updated + return updated + } + + /// Removes an entry by path and writes atomically. Returns false + /// when no entry with that path exists. + @discardableResult + public func remove(path: String) throws -> Bool { + try load() + let before = entries.count + let updated = entries.filter { $0.path != path } + guard updated.count != before else { return false } + try fileStore.save(updated) + entries = updated + return true + } + + /// `Clear Menu` — wipes the recents file only; `.icceryproj` files + /// are never deleted (R12). + public func clear() throws { + try load() + try fileStore.save([]) + entries = [] + } + + /// Drops entries whose file is gone and rewrites the store. + /// Called when the Open Recent submenu builds — missing files are + /// dropped there, not at launch (issue #149). + @discardableResult + public func pruneMissing() throws -> [RecentProjectEntry] { + try load() + let kept = entries.filter { + fileManager.fileExists(atPath: $0.path) + } + if kept.count != entries.count { + try fileStore.save(kept) + entries = kept + } + return kept + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Logging/AppLogger.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Logging/AppLogger.swift new file mode 100644 index 0000000..c3cfdc7 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Logging/AppLogger.swift @@ -0,0 +1,62 @@ +import Foundation +import OSLog + +/// Severity levels, matching the v1 `log_level` setting values. +public enum LogLevel: String, Codable, Sendable, CaseIterable { + case error, warn, info, debug, trace + + var osType: OSLogType { + switch self { + case .error: return .error + case .warn: return .default + case .info: return .info + case .debug: return .debug + case .trace: return .debug + } + } + + /// Lower rank = more severe. `shouldLog` keeps `rank <= min`. + var rank: Int { + switch self { + case .error: return 0 + case .warn: return 1 + case .info: return 2 + case .debug: return 3 + case .trace: return 4 + } + } +} + +/// Central logger: `os.Logger` + rolling file sink (`LogSink`), level +/// gated at write time so a settings save takes effect immediately +/// (#158). +public struct AppLogger: Sendable { + public static let shared = AppLogger(category: "app") + + private let osLog: Logger + private let sink: LogSink + public let category: String + + public init(category: String, sink: LogSink = .shared) { + self.category = category + self.sink = sink + self.osLog = Logger( + subsystem: AppPaths.bundleIdentifier, + category: category + ) + } + + public func log(_ level: LogLevel, _ message: @autoclosure () -> String) { + let text = LogSanitizer.sanitize(message()) + if level.rank <= sink.level.rank { + osLog.log(level: level.osType, "\(text, privacy: .public)") + } + sink.write(level: level, category: category, message: text) + } + + public func error(_ message: @autoclosure () -> String) { log(.error, message()) } + public func warn(_ message: @autoclosure () -> String) { log(.warn, message()) } + public func info(_ message: @autoclosure () -> String) { log(.info, message()) } + public func debug(_ message: @autoclosure () -> String) { log(.debug, message()) } + public func trace(_ message: @autoclosure () -> String) { log(.trace, message()) } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Logging/LogSanitizer.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Logging/LogSanitizer.swift new file mode 100644 index 0000000..a5d54e0 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Logging/LogSanitizer.swift @@ -0,0 +1,17 @@ +import Foundation + +/// Rewrites the user's home directory to `~` in log output +/// (docs/03 §Logging hygiene — `sanitize_arg_for_logging`). +public enum LogSanitizer { + /// Replaces every occurrence of the current user's home path with `~`. + public static func sanitize(_ text: String) -> String { + let home = NSHomeDirectory() + guard !home.isEmpty else { return text } + return text.replacingOccurrences(of: home, with: "~") + } + + /// Sanitizes an argv list for display. + public static func sanitizeArgs(_ args: [String]) -> String { + args.map(sanitize).joined(separator: " ") + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Logging/RollingFileLogger.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Logging/RollingFileLogger.swift new file mode 100644 index 0000000..231c30a --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Logging/RollingFileLogger.swift @@ -0,0 +1,127 @@ +import Foundation +import OSLog + +/// Rolling file sink for `AppLogger` — `~/Library/Logs// +/// iccery.log`, rotated at 5 MiB, keeping 5 historical segments +/// (`iccery.log.1` … `iccery.log.5`). +/// +/// The minimum level is **runtime state** (#158): `setLevel` takes +/// effect immediately — at startup and on every settings save. +public final class LogSink: @unchecked Sendable { + + public static let shared = LogSink(fileURL: AppPaths.logFile) + + private let lock = NSLock() + private let fileURL: URL + private var minimumLevel: LogLevel + private var handle: FileHandle? + + /// 5 MiB per segment, 5 historical segments kept. + public static let maxSegmentBytes: UInt64 = 5 * 1024 * 1024 + public static let keptSegments = 5 + + public init( + fileURL: URL = AppPaths.logFile, + minimumLevel: LogLevel? = nil + ) { + self.fileURL = fileURL + #if DEBUG + self.minimumLevel = minimumLevel ?? .debug + #else + self.minimumLevel = minimumLevel ?? .info + #endif + } + + public var level: LogLevel { + lock.lock() + defer { lock.unlock() } + return minimumLevel + } + + /// Applied at startup AND on every settings save (issue #5, #158). + public func setLevel(_ level: LogLevel) { + lock.lock() + minimumLevel = level + lock.unlock() + } + + /// `nil` → DEBUG-build default (.debug) / release (.info). + public func applySettings(_ settings: AppSettings) { + setLevel(settings.effectiveLogLevel) + } + + public func shouldLog(_ level: LogLevel) -> Bool { + level.rank <= { lock.lock(); defer { lock.unlock() }; return minimumLevel }().rank + } + + // MARK: - Writing + + /// Appends a `YYYY-MM-DD HH:mm:ss.SSS [LEVEL] category: msg` line, + /// rotating first when the active segment exceeds 5 MiB. + public func write(level: LogLevel, category: String, message: String) { + guard shouldLog(level) else { return } + lock.lock() + defer { lock.unlock() } + rotateIfNeeded() + openIfNeeded() + let stamp = Self.timestamp() + let line = "\(stamp) [\(level.rawValue.uppercased())] \(category): \(message)\n" + if let data = line.data(using: .utf8) { + handle?.write(data) + } + } + + private static let formatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS" + f.locale = Locale(identifier: "en_US_POSIX") + return f + }() + + private static func timestamp() -> String { + formatter.string(from: Date()) + } + + private func openIfNeeded() { + guard handle == nil else { return } + try? FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true + ) + if !FileManager.default.fileExists(atPath: fileURL.path) { + FileManager.default.createFile(atPath: fileURL.path, contents: nil) + } + handle = try? FileHandle(forWritingTo: fileURL) + try? handle?.seekToEnd() + } + + /// Shifts `iccery.log.4→.5`, `.3→.4`, …, `.log→.1` and resets the + /// writer. Oldest segment is deleted. + private func rotateIfNeeded() { + guard FileManager.default.fileExists(atPath: fileURL.path), + let attrs = try? FileManager.default.attributesOfItem(atPath: fileURL.path), + let size = attrs[.size] as? UInt64, + size >= Self.maxSegmentBytes + else { return } + + try? handle?.close() + handle = nil + let fm = FileManager.default + let oldest = fileURL.appendingPathExtension("\(Self.keptSegments)") + try? fm.removeItem(at: oldest) + for i in stride(from: Self.keptSegments - 1, through: 1, by: -1) { + let src = fileURL.appendingPathExtension("\(i)") + let dst = fileURL.appendingPathExtension("\(i + 1)") + if fm.fileExists(atPath: src.path) { + try? fm.moveItem(at: src, to: dst) + } + } + try? fm.moveItem(at: fileURL, to: fileURL.appendingPathExtension("1")) + } + + /// Tail of the active log for the settings dialog's "copy excerpt". + public func tailExcerpt(maxBytes: Int = 32 * 1024) -> String { + guard let data = try? Data(contentsOf: fileURL) else { return "" } + let slice = data.suffix(maxBytes) + return String(decoding: slice, as: UTF8.self) + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/AverageArgs.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/AverageArgs.swift new file mode 100644 index 0000000..1d7f1ca --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/AverageArgs.swift @@ -0,0 +1,78 @@ +import Foundation + +/// Errors during `average` argv construction. +public enum AverageArgError: LocalizedError, Equatable, Sendable { + case invalidBasename(String) + case invalidPassCount(Int) + case outputCollidesWithInput + case pathOutsideCwd(URL) + + public var errorDescription: String? { + switch self { + case .invalidBasename(let name): + return "Invalid basename for average: \(name)" + case .invalidPassCount(let count): + return "Average requires at least 2 pass files, got \(count)" + case .outputCollidesWithInput: + return "Average output filename collides with one of the inputs" + case .pathOutsideCwd(let url): + return "Pass or output file is outside the working directory: \(url.path)" + } + } +} + +/// Configuration for an `average` run. +public struct AverageConfig: Sendable, Equatable { + public let workingDirectory: URL + public let basename: String + public let passFiles: [URL] + + public init( + workingDirectory: URL, + basename: String, + passFiles: [URL] + ) { + self.workingDirectory = workingDirectory + self.basename = basename + self.passFiles = passFiles + } +} + +/// Pure argv builder for Argyll's `average` tool. +public enum AverageArgs { + + /// Builds `average -v pass1 pass2 ... basename.ti3` with relative names. + public static func build(config: AverageConfig) throws -> [String] { + let cleanBasename = try PathSecurity.sanitizeBasename(config.basename) + + guard config.passFiles.count >= 2 else { + throw AverageArgError.invalidPassCount(config.passFiles.count) + } + + let output = config.workingDirectory + .appendingPathComponent("\(cleanBasename).ti3") + + var inputNames: [String] = [] + for url in config.passFiles { + try validate(url, isIn: config.workingDirectory) + inputNames.append(url.lastPathComponent) + } + + try validate(output, isIn: config.workingDirectory) + let outputName = output.lastPathComponent + + guard !inputNames.contains(outputName) else { + throw AverageArgError.outputCollidesWithInput + } + + return ["-v"] + inputNames + [outputName] + } + + private static func validate(_ url: URL, isIn cwd: URL) throws { + let cwdPath = cwd.standardizedFileURL.path + let urlPath = url.deletingLastPathComponent().standardizedFileURL.path + guard urlPath == cwdPath else { + throw AverageArgError.pathOutsideCwd(url) + } + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/ChartreadArgs.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/ChartreadArgs.swift new file mode 100644 index 0000000..8d076c4 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/ChartreadArgs.swift @@ -0,0 +1,28 @@ +import Foundation + +/// Pure argv builder for Argyll's `chartread` tool. +public enum ChartreadArgs { + + /// Builds `chartread` argv per the Gronod fork protocol. + /// + /// - Always `-v -u`. + /// - `-c N` is emitted only for `selectedPort != nil` and `N > 1`. + /// - `-Y l` is emitted only when `enableLEDs` is `true`. + /// - Basename is the last positional argument and is sanitized. + public static func build(config: ChartreadConfig) throws -> [String] { + let cleanBasename = try PathSecurity.sanitizeBasename(config.basename) + + var args: [String] = ["-v", "-u"] + + if let port = config.selectedPort, port > 1 { + args.append(contentsOf: ["-c", "\(port)"]) + } + + if config.enableLEDs { + args.append(contentsOf: ["-Y", "l"]) + } + + args.append(cleanBasename) + return args + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/ChartreadClassifier.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/ChartreadClassifier.swift new file mode 100644 index 0000000..097c8d7 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/ChartreadClassifier.swift @@ -0,0 +1,302 @@ +import Foundation + +/// Discrete states for the `chartread` interaction. +public enum ChartreadState: String, Codable, Sendable, Equatable, CaseIterable { + case idle + case calibrating + case awaitingStrip + case reading + case allStripsRead + case warning + case promptContinue + case tablePlaceSheet + case tableAlign + case error + case finished +} + +/// Extra metadata produced by classifying a single `chartread` stdout line. +public struct ChartreadClassifyResult: Sendable, Equatable { + public let state: ChartreadState + /// Whether this line is an informational "remove last sheet" notice. + public let isRemoveSheetNotice: Bool + /// Parsed sheet index and total from "sheet N of M read ok" or "place sheet N of M". + public let sheetNumber: Int? + public let sheetTotal: Int? + /// Fiducial patch name from XY "locate patch X with the sight". + public let alignmentPatch: String? + /// When a warning asks for a specific key (e.g. `y` or `n`), the caller should send that key. + public let requestedWarningKey: String? + /// Whether the line is a continuation of a multi-line XY prompt. + public let isTableContinuation: Bool + + public init( + state: ChartreadState, + isRemoveSheetNotice: Bool = false, + sheetNumber: Int? = nil, + sheetTotal: Int? = nil, + alignmentPatch: String? = nil, + requestedWarningKey: String? = nil, + isTableContinuation: Bool = false + ) { + self.state = state + self.isRemoveSheetNotice = isRemoveSheetNotice + self.sheetNumber = sheetNumber + self.sheetTotal = sheetTotal + self.alignmentPatch = alignmentPatch + self.requestedWarningKey = requestedWarningKey + self.isTableContinuation = isTableContinuation + } +} + +/// Pure line classifier for `chartread` stdout. +/// +/// Matchers are evaluated in strict priority order (docs/04 §3.5, docs/05 §12.6). +/// XY table continuation lines stay sticky in `TABLE_PLACE_SHEET` / `TABLE_ALIGN`. +public enum ChartreadClassifier { + + private typealias Matcher = (String, ChartreadState) -> ChartreadClassifyResult? + + public static func classify( + line: String, + previousState: ChartreadState + ) -> ChartreadClassifyResult { + let text = line.lowercased() + + for matcher in matchers(previousState) { + if let result = matcher(text, previousState) { + return result + } + } + + return ChartreadClassifyResult(state: previousState) + } + + private static func matchers(_ previous: ChartreadState) -> [Matcher] { + [ + removeSheetNotice, + sheetReadOk, + locatePatch, + placeSheet, + continuation(previous), + done, + warning, + calibration, + awaitingStrip, + reading, + error + ] + } + + // 1. "Please remove last sheet from table" — info only. + private static func removeSheetNotice(text: String, previous: ChartreadState) -> ChartreadClassifyResult? { + guard text.contains("remove") && text.contains("last") && text.contains("sheet") else { return nil } + return ChartreadClassifyResult(state: previous, isRemoveSheetNotice: true) + } + + // 2. "Sheet N of M read OK". + private static func sheetReadOk(text: String, previous: ChartreadState) -> ChartreadClassifyResult? { + guard let match = text.firstMatch(pattern: #"sheet\s+(\d+)\s+of\s+(\d+)\s+read\s+ok"#) else { return nil } + return ChartreadClassifyResult( + state: previous, + sheetNumber: match.1, + sheetTotal: match.2 + ) + } + + // 3. "locate patch X with the sight". + private static func locatePatch(text: String, previous: ChartreadState) -> ChartreadClassifyResult? { + guard let match = text.firstMatch(pattern: #"locate\s+patch\s+([a-z0-9_]+)\s+with"#), + !match.0.isEmpty else { return nil } + return ChartreadClassifyResult( + state: .tableAlign, + alignmentPatch: match.0.uppercased() + ) + } + + // 4. "place sheet N of M" or "remove previous sheet". + private static func placeSheet(text: String, previous: ChartreadState) -> ChartreadClassifyResult? { + if let match = text.firstMatch(pattern: #"place\s+sheet\s+(\d+)\s+of\s+(\d+)"#) { + return ChartreadClassifyResult( + state: .tablePlaceSheet, + sheetNumber: match.1, + sheetTotal: match.2 + ) + } + if text.contains("remove previous sheet") || text.contains("place sheet") { + return ChartreadClassifyResult(state: .tablePlaceSheet) + } + return nil + } + + // 5. "hit return to continue" — sticky if already in a table state. + private static func continuation(_ previous: ChartreadState) -> Matcher { + return { text, _ in + guard text.contains("hit return to continue") + || text.contains("hit any key to continue") + || text.contains("hit space to continue") + else { return nil } + + if case .tablePlaceSheet = previous { + return ChartreadClassifyResult(state: .tablePlaceSheet, isTableContinuation: true) + } + if case .tableAlign = previous { + return ChartreadClassifyResult(state: .tableAlign, isTableContinuation: true) + } + + return ChartreadClassifyResult(state: .promptContinue, isTableContinuation: true) + } + } + + // 6. Done / all read. + private static func done(text: String, previous: ChartreadState) -> ChartreadClassifyResult? { + let phrases = [ + "'d' if/when done", "d to finish/save", "all strips/patches read", + "all strips read", "all patches read", "done reading", + "'d' to save", "press d to", "hit 'd'", "d to finish", "d to save" + ] + if phrases.contains(where: { text.contains($0) }) { + return ChartreadClassifyResult(state: .allStripsRead) + } + return nil + } + + // 7. Warnings / prompts needing a key. + private static func warning(text: String, previous: ChartreadState) -> ChartreadClassifyResult? { + let lower = text + let warningSignals = [ + "(warning)", "use it anyway", "seem to have read strip", + "unexpected response", "try again", "do you want to", + "abort ? - are you sure", "are you sure" + ] + + let isWarningPrompt = + warningSignals.contains(where: { lower.contains($0) }) + || lower.contains("(y/n)") + || lower.contains("'y' or 'n'") + || lower.contains("?") + + guard isWarningPrompt else { return nil } + + var key: String? + if lower.contains("(y/n)") || lower.contains("'y' or 'n'") { + // Default to asking the user; no automatic key. + key = nil + } else if lower.contains("'y'") || lower.contains("press y") || lower.contains("hit 'y'") { + key = "y" + } else if lower.contains("'n'") || lower.contains("press n") || lower.contains("hit 'n'") { + key = "n" + } + + return ChartreadClassifyResult(state: .warning, requestedWarningKey: key) + } + + // 8. Calibration / place reference / white / standard tile. + private static func calibration(text: String, previous: ChartreadState) -> ChartreadClassifyResult? { + let lowercased = text.lowercased() + let placeTokens = ["place", "reference", "white", "calibrat", "standard"] + let hasPlaceSheet = lowercased.contains("place sheet") || lowercased.contains("remove previous sheet") + let hasLocate = lowercased.contains("locate patch") + + guard placeTokens.contains(where: { lowercased.contains($0) }), + !hasPlaceSheet, + !hasLocate + else { return nil } + + if lowercased.contains("calibrat") + || lowercased.contains("white reference") + || lowercased.contains("white tile") + || lowercased.contains("standard tile") + || lowercased.contains("reference") + || lowercased.contains("tile") + || lowercased.contains("hit any key to continue") + || lowercased.contains("hit space to continue") { + return ChartreadClassifyResult(state: .calibrating) + } + return nil + } + + // 9. Awaiting strip. + private static func awaitingStrip(text: String, previous: ChartreadState) -> ChartreadClassifyResult? { + let lowercased = text.lowercased() + + // These are explicit, multi-word prompts; we deliberately do NOT + // match bare "read strip" so that error lines like + // "failed to read strip" or "error reading strip" fall through to + // the error matcher. + let phrases = [ + "ready to read", + "hit any key to read", + "hit a key to read", + "hit space to read", + "hit [space] to read", + "press any key to read", + "press space to read", + "trigger instrument", + "start reading", + "read next strip" + ] + + // Also permit "hit X to read strip Y" or "ready to read strip Z". + if lowercased.range(of: #"(hit|press).+to\s+read\s+strip"#, options: .regularExpression) != nil { + return ChartreadClassifyResult(state: .awaitingStrip) + } + + guard phrases.contains(where: { lowercased.contains($0) }) else { return nil } + return ChartreadClassifyResult(state: .awaitingStrip) + } + + // 10. Reading. + private static func reading(text: String, previous: ChartreadState) -> ChartreadClassifyResult? { + let lowercased = text.lowercased() + let phrases = ["reading strip", "reading sheet", "processing", "scanning", "reading..."] + guard phrases.contains(where: { lowercased.contains($0) }) else { return nil } + return ChartreadClassifyResult(state: .reading) + } + + // 11. Error. + private static func error(text: String, previous: ChartreadState) -> ChartreadClassifyResult? { + let lower = text.lowercased() + + // Avoid false positives from confirmation prompts and "no error" status. + guard !lower.contains("no error") else { return nil } + guard !lower.contains("(y/n)") + && !lower.contains("'y' or 'n'") + && !lower.contains("?") + else { return nil } + + let phraseMatches = ["failed to read", "error reading", "too fast", "too slow", "misread"] + for phrase in phraseMatches { + if lower.contains(phrase) { + return ChartreadClassifyResult(state: .error) + } + } + + // Whole-word "error" only — bare "failed" alone is not enough. + if lower.range(of: #"\berror\b"#, options: .regularExpression) != nil { + return ChartreadClassifyResult(state: .error) + } + + return nil + } +} + +private extension String { + func firstMatch(pattern: String) -> (String, Int, Int)? { + guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive), + let match = regex.firstMatch(in: self, options: [], range: NSRange(self.startIndex..., in: self)) + else { return nil } + + let groups: [String] = (1.. 0 ? ints[0] : 0 + let b = ints.count > 1 ? ints[1] : 0 + return (first, a, b) + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/ChartreadConfig.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/ChartreadConfig.swift new file mode 100644 index 0000000..4908e89 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/ChartreadConfig.swift @@ -0,0 +1,45 @@ +import Foundation + +/// Errors during `ChartreadArgs` validation. +public enum ChartreadArgError: LocalizedError, Equatable { + case invalidBasename(String) + case invalidPort(Int) + + public var errorDescription: String? { + switch self { + case .invalidBasename(let name): + return "Invalid chart basename: \(name)" + case .invalidPort(let port): + return "Invalid chartread port: \(port)" + } + } +} + +/// Configuration for a `chartread` invocation. +public struct ChartreadConfig: Codable, Equatable, Sendable { + public var basename: String + public var workingDirectory: URL? + /// Communication port to pass to `chartread -c`. + /// `nil` means omit `-c` (Auto or port 1). + public var selectedPort: Int? + /// Enable i1Pro 2 visual LEDs (`-Y l`). + public var enableLEDs: Bool + + public init( + basename: String, + workingDirectory: URL? = nil, + selectedPort: Int? = nil, + enableLEDs: Bool = false, + isXY: Bool = false + ) { + self.basename = basename + self.workingDirectory = workingDirectory + self.selectedPort = selectedPort + self.enableLEDs = enableLEDs + self.isXY = isXY + } + + /// Whether the current config implies an XY-table workflow. + /// This is normally supplied by the view model from the selected instrument. + public var isXY: Bool = false +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/ChartreadRow.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/ChartreadRow.swift new file mode 100644 index 0000000..6bfd766 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/ChartreadRow.swift @@ -0,0 +1,91 @@ +import Foundation + +/// A single patch read by `chartread`. +public struct ChartreadPatch: Codable, Sendable, Equatable { + public let id: String + public let loc: String + public let isPad: Bool + public let device: [Double] + public let expected: PatchColor? + public let measured: PatchColor + + public init( + id: String, + loc: String, + isPad: Bool, + device: [Double], + expected: PatchColor?, + measured: PatchColor + ) { + self.id = id + self.loc = loc + self.isPad = isPad + self.device = device + self.expected = expected + self.measured = measured + } + + enum CodingKeys: String, CodingKey { + case id, loc + case isPad = "is_pad" + case device, expected, measured + } +} + +/// Colour payload carried by `expected` or `measured`. +public struct PatchColor: Codable, Sendable, Equatable { + public let xyz: CIEXYZ? + public let lab: CIELab? + public let spectral: SpectralData? + + public init(xyz: CIEXYZ? = nil, lab: CIELab? = nil, spectral: SpectralData? = nil) { + self.xyz = xyz + self.lab = lab + self.spectral = spectral + } + + enum CodingKeys: String, CodingKey { + case xyz = "XYZ" + case lab = "Lab" + case spectral = "spectral" + } +} + +public struct SpectralData: Codable, Sendable, Equatable { + public let bands: Int + public let startNM: Double + public let endNM: Double + public let norm: Double + public let values: [Double] + + enum CodingKeys: String, CodingKey { + case bands + case startNM = "start_nm" + case endNM = "end_nm" + case norm + case values + } +} + +/// A complete row emitted by `chartread -u`. +public struct ChartreadRow: Codable, Sendable, Equatable { + public let event: String + public let rowId: String + public let rowIndex: Int + public let totalRows: Int + public let patchCount: Int + public let patches: [ChartreadPatch] + + enum CodingKeys: String, CodingKey { + case event + case rowId = "row_id" + case rowIndex = "row_index" + case totalRows = "total_rows" + case patchCount = "patch_count" + case patches + } + + public var isFinalRow: Bool { + rowIndex + 1 >= totalRows + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/ColorDifference.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/ColorDifference.swift new file mode 100644 index 0000000..4ac782b --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/ColorDifference.swift @@ -0,0 +1,172 @@ +import Foundation + +/// Result of evaluating one measured patch. +public struct SwatchEvaluation: Sendable, Equatable { + public let intended: DisplayRGB + public let measured: DisplayRGB + public let deltaE: Double? + public let classification: SwatchClassification + + public init( + intended: DisplayRGB, + measured: DisplayRGB, + deltaE: Double?, + classification: SwatchClassification + ) { + self.intended = intended + self.measured = measured + self.deltaE = deltaE + self.classification = classification + } +} + +public enum SwatchClassification: String, Sendable, Equatable, CaseIterable { + case good + case warning + case bad +} + +/// CIEDE2000 ΔE₀₀ between two D50 Lab values. +public enum ColorDifference { + + /// Compute ΔE₀₀ using the full CIEDE2000 formula. + public static func deltaE00(_ lab1: LabColor, _ lab2: LabColor) -> Double { + let kL: Double = 1 + let kC: Double = 1 + let kH: Double = 1 + + let c1 = sqrt(lab1.a * lab1.a + lab1.b * lab1.b) + let c2 = sqrt(lab2.a * lab2.a + lab2.b * lab2.b) + + let cBar = (c1 + c2) / 2.0 + let cBar7 = pow(cBar, 7) + let g = 0.5 * (1 - sqrt(cBar7 / (cBar7 + pow(25, 7)))) + + let a1p = (1 + g) * lab1.a + let a2p = (1 + g) * lab2.a + + let c1p = sqrt(a1p * a1p + lab1.b * lab1.b) + let c2p = sqrt(a2p * a2p + lab2.b * lab2.b) + + let h1p = atan2ToDegrees(lab1.b, a1p) + let h2p = atan2ToDegrees(lab2.b, a2p) + + let deltaLp = lab2.l - lab1.l + let deltaCp = c2p - c1p + + var deltaHp: Double = 0 + if c1p * c2p == 0 { + deltaHp = 0 + } else { + let diff = h2p - h1p + if abs(diff) <= 180 { + deltaHp = diff + } else if diff > 180 { + deltaHp = diff - 360 + } else { + deltaHp = diff + 360 + } + } + + let deltaHp2 = 2 * sqrt(c1p * c2p) * sin(deltaHp * .pi / 360.0) + + let lBarp = (lab1.l + lab2.l) / 2.0 + let cBarp = (c1p + c2p) / 2.0 + + var hBarp: Double + if c1p * c2p == 0 { + hBarp = h1p + h2p + } else { + if abs(h1p - h2p) <= 180 { + hBarp = (h1p + h2p) / 2.0 + } else if h1p + h2p < 360 { + hBarp = (h1p + h2p + 360) / 2.0 + } else { + hBarp = (h1p + h2p - 360) / 2.0 + } + } + + let t = 1 + - 0.17 * cos(deg2rad(hBarp - 30)) + + 0.24 * cos(deg2rad(2 * hBarp)) + + 0.32 * cos(deg2rad(3 * hBarp + 6)) + - 0.20 * cos(deg2rad(4 * hBarp - 63)) + + let dTheta = 30 * exp(-pow((hBarp - 275) / 25, 2)) + let cBarp7 = pow(cBarp, 7) + let rc = 2 * sqrt(cBarp7 / (cBarp7 + pow(25, 7))) + + let sl = 1 + (0.015 * pow(lBarp - 50, 2)) / sqrt(20 + pow(lBarp - 50, 2)) + let sc = 1 + 0.045 * cBarp + let sh = 1 + 0.015 * cBarp * t + + let rt = -sin(deg2rad(2 * dTheta)) * rc + + let lTerm = deltaLp / (kL * sl) + let cTerm = deltaCp / (kC * sc) + let hTerm = deltaHp2 / (kH * sh) + + return sqrt( + lTerm * lTerm + + cTerm * cTerm + + hTerm * hTerm + + rt * cTerm * hTerm + ) + } + + /// Classify a ΔE value against user thresholds. + public static func classify(deltaE: Double, goodMax: Double, warningMax: Double) -> SwatchClassification { + if deltaE < goodMax { return .good } + if deltaE < warningMax { return .warning } + return .bad + } + + /// Evaluate a patch: compute intended/measured sRGB and ΔE if both Lab values are present. + public static func evaluate( + patch: ChartreadPatch, + goodMax: Double, + warningMax: Double + ) -> SwatchEvaluation? { + guard let measured = resolveLab(patch.measured) else { return nil } + + let measuredRGB = LabColorMath.labToSRGB(measured) + + if let expectedColor = patch.expected, + let expectedLab = resolveLab(expectedColor) { + let de = deltaE00(expectedLab, measured) + let intendedRGB = LabColorMath.labToSRGB(expectedLab) + return SwatchEvaluation( + intended: intendedRGB, + measured: measuredRGB, + deltaE: de, + classification: classify(deltaE: de, goodMax: goodMax, warningMax: warningMax) + ) + } else { + // No reference: still render measured colour, no ΔE. + return SwatchEvaluation( + intended: measuredRGB, + measured: measuredRGB, + deltaE: nil, + classification: .good + ) + } + } + + /// Resolve a Lab from a `PatchColor`, computing it from XYZ when Lab is absent. + public static func resolveLab(_ color: PatchColor) -> LabColor? { + if let lab = color.lab { return lab } + guard let xyz = color.xyz else { return nil } + return LabColorMath.xyzToLab(xyz) + } + + private static func atan2ToDegrees(_ y: Double, _ x: Double) -> Double { + let radians = atan2(y, x) + var degrees = radians * 180.0 / .pi + if degrees < 0 { degrees += 360 } + return degrees + } + + private static func deg2rad(_ degrees: Double) -> Double { + degrees * .pi / 180.0 + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/InstrumentModels.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/InstrumentModels.swift new file mode 100644 index 0000000..547c138 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/InstrumentModels.swift @@ -0,0 +1,58 @@ +import Foundation + +/// A device discovered by the Argyll `instlist` fork. +public struct InstrumentDevice: Codable, Sendable, Equatable, Identifiable { + public let port: Int + public let name: String + public let type: String + + public init(port: Int, name: String, type: String) { + self.port = port + self.name = name + self.type = type + } + + public var id: Int { port } + + /// XY tables are identified by name or type matching the fork pattern. + public var isXY: Bool { + let combined = "\(name) \(type)".lowercased() + let pattern = #"/spectro\s?scan|i1io/"# + return combined.range(of: pattern, options: .regularExpression) != nil + } + + /// Human-readable label shown in the picker. + public var displayName: String { + let xyTag = isXY ? " · XY Table" : "" + return "\(name) [\(type)]\(xyTag)" + } +} + +/// The user’s choice for a chartread session. +public enum InstrumentSelection: Sendable, Equatable { + /// Auto / first available port — `chartread` omits `-c`. + case auto + /// A concrete instrument. + case device(InstrumentDevice) + + /// The value to pass to `chartread -c`. + /// `nil` means omit `-c` (port 1 and Auto both map to no flag). + public var chartreadPort: Int? { + switch self { + case .auto: + return nil + case .device(let device): + return device.port == 1 ? nil : device.port + } + } + + /// Whether the current selection implies an XY table workflow. + public var isXY: Bool { + switch self { + case .auto: + return false + case .device(let device): + return device.isXY + } + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/InstrumentParser.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/InstrumentParser.swift new file mode 100644 index 0000000..b3f0c79 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/InstrumentParser.swift @@ -0,0 +1,72 @@ +import Foundation + +/// Errors from `instlist` output parsing. +public enum InstrumentParserError: Error, Sendable, Equatable { + case malformedJSON + case missingDevices + case invalidPort +} + +/// Parses the Argyll `instlist` stdout document. +/// +/// Fork `instlist` emits pretty-printed JSON with the shape +/// `{ "event": "instruments", "devices": [ { "port": 1, "name": "...", "type": "..." } ] }`. +/// If JSON decoding fails, a constrained regex fallback is used. +/// Only lines accepted by the fallback must also match known instrument tokens. +public enum InstrumentParser { + + /// Known instrument tokens used by the regex fallback. + public static let knownInstrumentPattern = + #"i1|ColorMunki|Spyder|spectro|Display|Huey|DTP|SpectroScan|Smile|Klein"# + + /// Parse the complete `instlist` output. + public static func parse(_ output: String) throws -> [InstrumentDevice] { + let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return [] } + + if let data = trimmed.data(using: .utf8), + let decoded = try? decodeJSON(data) { + return decoded + } + + let regex = try? NSRegularExpression( + pattern: #"^(\d+)[\s:=]+'?([^'\n]+)'?(?:\s+on\s+'?([^'\n]+)'?)?"#, + options: [.caseInsensitive, .anchorsMatchLines] + ) + var devices: [InstrumentDevice] = [] + let range = NSRange(trimmed.startIndex..., in: trimmed) + let matches = regex?.matches(in: trimmed, options: [], range: range) ?? [] + + for match in matches { + guard let portString = substring(trimmed, range: match.range(at: 1)), + let port = Int(portString), port > 0 else { continue } + + let name = substring(trimmed, range: match.range(at: 2))?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let type = substring(trimmed, range: match.range(at: 3))?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + + let combined = "\(name) \(type)".lowercased() + guard combined.range(of: knownInstrumentPattern, + options: [.regularExpression, .caseInsensitive]) != nil, + !name.isEmpty else { continue } + + devices.append(InstrumentDevice(port: port, name: name, type: type)) + } + + return devices + } + + private static func decodeJSON(_ data: Data) throws -> [InstrumentDevice] { + let output = try JSONDecoder().decode(InstlistOutput.self, from: data) + return output.devices + } + + private static func substring(_ source: String, range: NSRange) -> String? { + guard range.location != NSNotFound, let r = Range(range, in: source) else { return nil } + return String(source[r]) + } +} + +private struct InstlistOutput: Decodable { + let event: String + let devices: [InstrumentDevice] +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/LabColor.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/LabColor.swift new file mode 100644 index 0000000..c795b30 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/LabColor.swift @@ -0,0 +1,217 @@ +import Foundation + +/// XYZ tristimulus values, stored in the 0–100 scale used by the Argyll fork. +/// Unkeyed Codable matches `ROW_COLORS_JSON` `[x, y, z]`. +public struct XYZColor: Codable, Sendable, Equatable { + public let x: Double + public let y: Double + public let z: Double + + public init(x: Double, y: Double, z: Double) { + self.x = x + self.y = y + self.z = z + } + + public init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + self.x = try container.decode(Double.self) + self.y = try container.decode(Double.self) + self.z = try container.decode(Double.self) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.unkeyedContainer() + try container.encode(x) + try container.encode(y) + try container.encode(z) + } +} + +/// CIELab value (D50). Unkeyed Codable matches `ROW_COLORS_JSON` `[L, a, b]`. +public struct LabColor: Codable, Sendable, Equatable { + public let l: Double + public let a: Double + public let b: Double + + public init(l: Double, a: Double, b: Double) { + self.l = l + self.a = a + self.b = b + } + + public init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + self.l = try container.decode(Double.self) + self.a = try container.decode(Double.self) + self.b = try container.decode(Double.self) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.unkeyedContainer() + try container.encode(l) + try container.encode(a) + try container.encode(b) + } +} + +/// JSON aliases used by `chartread` row payloads. +public typealias CIEXYZ = XYZColor +public typealias CIELab = LabColor + +/// sRGB colour in 0–1 display space. +public struct DisplayRGB: Sendable, Equatable { + public let r: Double + public let g: Double + public let b: Double + + public init(r: Double, g: Double, b: Double) { + self.r = r + self.g = g + self.b = b + } + + public var clamped: DisplayRGB { + DisplayRGB(r: min(1, max(0, r)), g: min(1, max(0, g)), b: min(1, max(0, b))) + } +} + +/// Colour-space conversions used by the swatch grid. +/// +/// All numeric paths are deterministic and avoid platform colour-management APIs. +public enum LabColorMath { + + // Reference white for D50 (0–100 scale). + static let d50White = (X: 96.4212, Y: 100.0, Z: 82.5188) + + // Reference white for D65 (0–100 scale). + static let d65White = (X: 95.0489, Y: 100.0, Z: 108.8840) + + // Bradford cone-response matrix and its inverse (XYZ -> LMS). + static let bradford = [ + [ 0.8951, 0.2664, -0.1614], + [-0.7502, 1.7135, 0.0367], + [ 0.0389, -0.0685, 1.0296] + ] + + static let bradfordInv = [ + [ 0.9869929, -0.1470543, 0.1599627], + [ 0.4323053, 0.5183603, 0.0492912], + [-0.0085287, 0.0400428, 0.9684866] + ] + + // sRGB D65 matrix (XYZ -> linear sRGB, using 0–100 inputs). + static let srgbMatrix = [ + [ 3.2406, -1.5372, -0.4986], + [-0.9689, 1.8758, 0.0415], + [ 0.0557, -0.2040, 1.0570] + ] + + /// Convert XYZ (0–100) to CIELab D50. + public static func xyzToLab(_ xyz: XYZColor) -> LabColor { + let f: (Double) -> Double = { t in + let delta = 6.0 / 29.0 + if t > delta * delta * delta { + return pow(t, 1.0 / 3.0) + } else { + return t / (3 * delta * delta) + 4.0 / 29.0 + } + } + + let x = f(xyz.x / d50White.X) + let y = f(xyz.y / d50White.Y) + let zr = f(xyz.z / d50White.Z) + + return LabColor( + l: 116.0 * y - 16.0, + a: 500.0 * (x - y), + b: 200.0 * (y - zr) + ) + } + + /// Convert CIELab D50 to XYZ (0–100). + public static func labToXYZ(_ lab: LabColor) -> XYZColor { + let finv: (Double) -> Double = { t in + let delta = 6.0 / 29.0 + if t > delta { + return t * t * t + } else { + return 3 * delta * delta * (t - 4.0 / 29.0) + } + } + + let yr = (lab.l + 16.0) / 116.0 + let xr = yr + lab.a / 500.0 + let zr = yr - lab.b / 200.0 + + return XYZColor( + x: finv(xr) * d50White.X, + y: finv(yr) * d50White.Y, + z: finv(zr) * d50White.Z + ) + } + + /// Convert XYZ D50 to XYZ D65 using the Bradford chromatic adaptation. + public static func adaptD50ToD65(_ xyz: XYZColor) -> XYZColor { + let source = matrixMultiply(bradford, [xyz.x, xyz.y, xyz.z]) + let srcWhite = matrixMultiply(bradford, [d50White.X, d50White.Y, d50White.Z]) + let dstWhite = matrixMultiply(bradford, [d65White.X, d65White.Y, d65White.Z]) + + let scaled = [ + source[0] * (dstWhite[0] / srcWhite[0]), + source[1] * (dstWhite[1] / srcWhite[1]), + source[2] * (dstWhite[2] / srcWhite[2]) + ] + + return XYZColor( + x: scaled[0] * bradfordInv[0][0] + scaled[1] * bradfordInv[0][1] + scaled[2] * bradfordInv[0][2], + y: scaled[0] * bradfordInv[1][0] + scaled[1] * bradfordInv[1][1] + scaled[2] * bradfordInv[1][2], + z: scaled[0] * bradfordInv[2][0] + scaled[1] * bradfordInv[2][1] + scaled[2] * bradfordInv[2][2] + ) + } + + /// Convert XYZ D65 (0–100) to linear sRGB, apply gamma, and clamp. + public static func xyzToSRGB(_ xyz: XYZColor) -> DisplayRGB { + // sRGB matrix is defined for XYZ with D65 white at Y = 1.0. + // The input is 0–100, so scale by 100 first. + let scaled = [xyz.x / 100.0, xyz.y / 100.0, xyz.z / 100.0] + let linear = matrixMultiply(srgbMatrix, scaled) + + func gamma(_ c: Double) -> Double { + if c <= 0.0031308 { + return 12.92 * c + } else { + return 1.055 * pow(c, 1.0 / 2.4) - 0.055 + } + } + + return DisplayRGB( + r: gamma(linear[0]), + g: gamma(linear[1]), + b: gamma(linear[2]) + ).clamped + } + + /// Complete D50 Lab -> display sRGB conversion. + public static func labToSRGB(_ lab: LabColor) -> DisplayRGB { + let xyz50 = labToXYZ(lab) + let xyz65 = adaptD50ToD65(xyz50) + return xyzToSRGB(xyz65) + } + + /// Convert an Argyll XYZ array (0–100) to Lab and then to sRGB. + public static func xyzArrayToSRGB(_ xyz: [Double]) -> DisplayRGB? { + guard xyz.count >= 3 else { return nil } + return labToSRGB(xyzToLab(XYZColor(x: xyz[0], y: xyz[1], z: xyz[2]))) + } + + private static func matrixMultiply(_ m: [[Double]], _ v: [Double]) -> [Double] { + var result = [Double](repeating: 0, count: m.count) + for i in 0.._passN.ti3`. Canonical `.ti3` only appears after +/// Finish / Average (docs/24 #109, #110). +public enum MeasurementArtefacts { + + /// Find all existing pass snapshots in `cwd`, sorted numerically. + public static func passSnapshots( + basename: String, + cwd: URL, + fileManager: FileManager = .default + ) -> [URL] { + guard let entries = try? fileManager.contentsOfDirectory( + at: cwd, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) else { return [] } + + let prefix = "\(basename)_pass" + let suffix = "ti3" + + let passes: [(Int, URL)] = entries.compactMap { url in + let name = url.lastPathComponent + guard url.pathExtension.lowercased() == suffix, + name.hasPrefix(prefix) + else { return nil } + + let numberPart = String(name.dropFirst(prefix.count).dropLast(4)) + guard let number = Int(numberPart), number > 0 else { return nil } + return (number, url) + } + + return passes + .sorted { $0.0 < $1.0 } + .map { $0.1 } + } + + /// The next 1-based pass number. + public static func nextPassNumber( + basename: String, + cwd: URL, + fileManager: FileManager = .default + ) -> Int { + let existing = passSnapshots(basename: basename, cwd: cwd, fileManager: fileManager) + guard let last = existing.last else { return 1 } + let name = last.lastPathComponent + let prefix = "\(basename)_pass" + let numberPart = String(name.dropFirst(prefix.count).dropLast(4)) + return (Int(numberPart) ?? 0) + 1 + } + + /// Snapshot canonical `.ti3` to `_passN.ti3` and remove canonical. + /// + /// The copy is written to a temp sibling and atomically renamed before canonical is deleted. + public static func snapshotPass( + basename: String, + cwd: URL, + fileManager: FileManager = .default + ) throws -> URL { + let cleanBasename = try PathSecurity.sanitizeBasename(basename) + let canonical = cwd.appendingPathComponent("\(cleanBasename).ti3") + guard fileManager.fileExists(atPath: canonical.path) else { + throw MeasurementArtefactError.canonicalMissing(canonical) + } + + let passNumber = nextPassNumber(basename: cleanBasename, cwd: cwd, fileManager: fileManager) + let pass = cwd.appendingPathComponent("\(cleanBasename)_pass\(passNumber).ti3") + let temp = cwd.appendingPathComponent(".\(cleanBasename)_pass\(passNumber).ti3.iccery-snap.tmp") + + if fileManager.fileExists(atPath: temp.path) { + try? fileManager.removeItem(at: temp) + } + + do { + try fileManager.copyItem(at: canonical, to: temp) + } catch { + throw MeasurementArtefactError.snapshotFailed(error.localizedDescription) + } + + if fileManager.fileExists(atPath: pass.path) { + try? fileManager.removeItem(at: pass) + } + + do { + try fileManager.moveItem(at: temp, to: pass) + } catch { + try? fileManager.removeItem(at: temp) + throw MeasurementArtefactError.snapshotFailed(error.localizedDescription) + } + + do { + try fileManager.removeItem(at: canonical) + } catch { + // The pass file is durable; canonical removal failure is logged but not fatal. + throw MeasurementArtefactError.snapshotFailed(error.localizedDescription) + } + + return pass + } + + /// Promote a single pass snapshot to canonical `.ti3`. + public static func promotePass( + pass: URL, + basename: String, + cwd: URL, + fileManager: FileManager = .default + ) throws -> URL { + let cleanBasename = try PathSecurity.sanitizeBasename(basename) + let canonical = cwd.appendingPathComponent("\(cleanBasename).ti3") + let temp = cwd.appendingPathComponent(".\(cleanBasename).ti3.iccery-promo.tmp") + + guard fileManager.fileExists(atPath: pass.path) else { + throw MeasurementArtefactError.noPassFiles + } + + if fileManager.fileExists(atPath: temp.path) { + try? fileManager.removeItem(at: temp) + } + + do { + try fileManager.copyItem(at: pass, to: temp) + } catch { + throw MeasurementArtefactError.promoteFailed(error.localizedDescription) + } + + if fileManager.fileExists(atPath: canonical.path) { + try? fileManager.removeItem(at: canonical) + } + + do { + try fileManager.moveItem(at: temp, to: canonical) + } catch { + try? fileManager.removeItem(at: temp) + throw MeasurementArtefactError.promoteFailed(error.localizedDescription) + } + + return canonical + } + + /// Canonical `.ti3` URL, regardless of existence. + public static func canonicalURL(basename: String, cwd: URL) throws -> URL { + let cleanBasename = try PathSecurity.sanitizeBasename(basename) + return cwd.appendingPathComponent("\(cleanBasename).ti3") + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/SpotReadClassifier.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/SpotReadClassifier.swift new file mode 100644 index 0000000..8a99e02 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/SpotReadClassifier.swift @@ -0,0 +1,48 @@ +import Foundation + +/// Line classifier for `spotread` stdout (issue #148). +/// +/// `spotread` shares `chartread`'s white-tile calibration phrasing, so +/// this wraps `ChartreadClassifier` and only intercepts the lines that +/// would otherwise misclassify: +/// +/// - `… and then hit any key to continue,` / `or hit Esc or Q to abort:` +/// continuation lines that trail the calibration and spot prompts — +/// sticky to the current prompt state instead of `PROMPT_CONTINUE`. +/// - `Place instrument on a spot to be measured,` / +/// `and hit a key to take a reading,` → `AWAITING_STRIP` (the Read +/// prompt; the generic chartread matcher does not know "take a +/// reading"). +/// +/// Sample lines (`Result is XYZ: …, D50 Lab: …`) are parsed by +/// `SpotReadParser`, not classified here. +public enum SpotReadClassifier { + + public static func classify( + line: String, + previousState: ChartreadState + ) -> ChartreadClassifyResult { + let text = line.lowercased() + + // Spot-read prompt continuations keep the current prompt state. + if previousState == .calibrating || previousState == .awaitingStrip { + if text.contains("hit any key") + || text.contains("hit space") + || text.contains("esc or") + || text.contains("abort") + || text.contains("to abort") { + return ChartreadClassifyResult(state: previousState) + } + } + + // "Place instrument on a spot to be measured," / + // " and hit a key to take a reading," — the Read trigger prompt. + if text.contains("spot to be measured") + || text.contains("take a reading") + || text.contains("measure the spot") { + return ChartreadClassifyResult(state: .awaitingStrip) + } + + return ChartreadClassifier.classify(line: line, previousState: previousState) + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/SpotReadSample.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/SpotReadSample.swift new file mode 100644 index 0000000..836221c --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/SpotReadSample.swift @@ -0,0 +1,80 @@ +import Foundation + +/// One patch measurement emitted by a running `spotread` child (#148). +public struct SpotReadSample: Codable, Sendable, Equatable, Identifiable { + public var id: UUID + public var timestamp: Date + /// D50 Lab (always present — derived from XYZ when needed). + public var lab: LabColor + /// XYZ on the 0–100 scale used by the fork, when the line carried it. + public var xyz: XYZColor? + public var instrumentName: String + public var port: Int? + /// Diagnostics only — never rendered as HTML or shown in the table. + public var rawLine: String + + public init( + id: UUID = UUID(), + timestamp: Date = Date(), + lab: LabColor, + xyz: XYZColor? = nil, + instrumentName: String = "", + port: Int? = nil, + rawLine: String = "" + ) { + self.id = id + self.timestamp = timestamp + self.lab = lab + self.xyz = xyz + self.instrumentName = instrumentName + self.port = port + self.rawLine = rawLine + } +} + +/// Parses `spotread` result lines into Lab / XYZ triples. +/// +/// The fork's line shape is the upstream +/// `Result is XYZ: , D50 Lab: `; a Lab-only line +/// also parses, and an XYZ-only line derives Lab via +/// `LabColorMath.xyzToLab` (D50). +public enum SpotReadParser { + + public static func parse(line: String) -> (xyz: XYZColor?, lab: LabColor)? { + guard line.range(of: "result is", options: .caseInsensitive) != nil + || line.range(of: #"\bLab\b"#, options: .regularExpression) != nil + || line.range(of: #"\bXYZ\b"#, options: .regularExpression) != nil + else { return nil } + + var xyz: XYZColor? + var lab: LabColor? + + if let m = triple(#"\bXYZ\b[:\s]"#, in: line) { + xyz = XYZColor(x: m.0, y: m.1, z: m.2) + } + if let m = triple(#"\bLab\b[:\s]"#, in: line) { + lab = LabColor(l: m.0, a: m.1, b: m.2) + } + if lab == nil, let xyz { + lab = LabColorMath.xyzToLab(xyz) + } + guard let lab else { return nil } + return (xyz, lab) + } + + private static func triple(_ marker: String, in line: String) -> (Double, Double, Double)? { + let pattern = marker + #"\s*(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)"# + guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive), + let match = regex.firstMatch( + in: line, options: [], range: NSRange(line.startIndex..., in: line)), + match.numberOfRanges == 4, + let r1 = Range(match.range(at: 1), in: line), + let r2 = Range(match.range(at: 2), in: line), + let r3 = Range(match.range(at: 3), in: line), + let a = Double(line[r1]), + let b = Double(line[r2]), + let c = Double(line[r3]) + else { return nil } + return (a, b, c) + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Paths/AppPaths.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Paths/AppPaths.swift new file mode 100644 index 0000000..c1ee0d2 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Paths/AppPaths.swift @@ -0,0 +1,94 @@ +import Foundation + +/// Well-known filesystem locations for the ICCery host process. +/// +/// macOS paths (docs/02 §Persistence): +/// - App data: `~/Library/Application Support//` +/// - Log file: `~/Library/Logs//iccery.log` +/// - Bundled Argyll tools: `/Contents/Resources/Argyll/` +public enum AppPaths { + + /// `com.gronod.iccery2` — read from the main bundle so tests can override. + public static var bundleIdentifier: String { + Bundle.main.bundleIdentifier ?? "com.gronod.iccery2" + } + + /// `~/Library/Application Support/com.gronod.iccery2` + /// + /// DEBUG only: `ICCERY_TEST_ROOT` or `ICCERY_TEST_WORKDIR` redirect app + /// data so UI tests run against an isolated root and never touch the + /// developer's state. + public static var appDataDir: URL { + #if DEBUG + if let root = testRoot { + return root.appendingPathComponent("AppData", isDirectory: true) + } + #endif + return FileManager.default + .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent(bundleIdentifier, isDirectory: true) + } + + /// `~/Library/Logs/com.gronod.iccery2` + public static var logDir: URL { + #if DEBUG + if let root = testRoot { + return root.appendingPathComponent("Logs", isDirectory: true) + } + #endif + return FileManager.default + .urls(for: .libraryDirectory, in: .userDomainMask)[0] + .appendingPathComponent("Logs", isDirectory: true) + .appendingPathComponent(bundleIdentifier, isDirectory: true) + } + + #if DEBUG + /// DEBUG-only root override. Order: + /// 1. `ICCERY_TEST_ROOT` for an explicit test root. + /// 2. `ICCERY_TEST_WORKDIR` so the app data and log files live next to + /// the current UI test's working directory. + /// 3. `ICCERY_UI_TESTING=1` creates a per-process temp root so a UI test + /// that sets neither of the above still runs in isolation. + /// + /// Computed from `ProcessInfo` each call — no mutable static state. + private static var testRoot: URL? { + if let raw = ProcessInfo.processInfo.environment["ICCERY_TEST_ROOT"], + !raw.isEmpty { + return URL(fileURLWithPath: raw, isDirectory: true) + } + if let raw = ProcessInfo.processInfo.environment["ICCERY_TEST_WORKDIR"], + !raw.isEmpty { + return URL(fileURLWithPath: raw, isDirectory: true) + } + if ProcessInfo.processInfo.environment["ICCERY_UI_TESTING"] == "1" { + return FileManager.default.temporaryDirectory + .appendingPathComponent( + "iccery-ui-\(ProcessInfo.processInfo.processIdentifier)", + isDirectory: true + ) + } + return nil + } + #endif + + /// `~/Library/Logs/com.gronod.iccery2/iccery.log` + public static var logFile: URL { + logDir.appendingPathComponent("iccery.log", isDirectory: false) + } + + /// `/Contents/Resources/Argyll` — bundled sidecar root. + public static var bundledArgyllDir: URL { + Bundle.main.resourceURL? + .appendingPathComponent("Argyll", isDirectory: true) + ?? URL(fileURLWithPath: "/nonexistent") + } + + /// Creates the app data and log directories if missing. + @discardableResult + public static func ensureDirectories() throws -> (appData: URL, logs: URL) { + let fm = FileManager.default + try fm.createDirectory(at: appDataDir, withIntermediateDirectories: true) + try fm.createDirectory(at: logDir, withIntermediateDirectories: true) + return (appDataDir, logDir) + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/ColorMatchingAttempts.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/ColorMatchingAttempts.swift new file mode 100644 index 0000000..5e535a9 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/ColorMatchingAttempts.swift @@ -0,0 +1,49 @@ +import Foundation + +/// Ordered private-SPI attempt table for the ColorSync suppression +/// engine (issue 14 layer ②, docs/11). +/// +/// The ordering is data so the exact dlsym/mode sequence is unit- +/// testable without resolving any private symbols. The app layer walks +/// `attempts`, resolves each symbol via `dlsym(RTLD_DEFAULT,…)`, and +/// calls the first `(symbol, mode)` that returns `0` — verified on +/// macOS 14+ that all three symbols exist. +/// +/// The SPI signature is `(PMPrintSession, CFStringRef) -> OSStatus`. +/// The second argument is the **mode string**, never integer `1` +/// (#188 — a 3-arg call is a SIGSEGV). `AP_ColorSyncMatching` and +/// `AP_VendorColorMatching` are forbidden modes — they re-enable +/// ColorSync/driver colour management. +public enum ColorMatchingAttempts { + + /// dlsym order: `…Lock` first (holds the print-session lock while + /// setting), then the plain setter, then `…NoLock`. + public static let symbols: [String] = [ + "PMSessionSetColorMatchingModeLock", + "PMSessionSetColorMatchingMode", + "PMSessionSetColorMatchingModeNoLock", + ] + + /// Mode strings tried per symbol, in order. `AP_…` is the + /// documented mode; the unprefixed variant is the older alias. + public static let modes: [String] = [ + "AP_ApplicationColorMatching", + "ApplicationColorMatching", + ] + + /// Symbol-outer, mode-inner — the full attempt sequence; the app + /// stops at the first call that returns `0`. + public static var attempts: [(symbol: String, mode: String)] { + symbols.flatMap { symbol in + modes.map { (symbol: symbol, mode: $0) } + } + } + + /// Layer ③: both spellings of the print-settings key are written + /// with `locked = true`. Written as `CFString` values. + public static let applicationMatchingValue = "AP_ApplicationColorMatching" + public static let printSettingsKeys: [String] = [ + "AP_ColorMatchingMode", + "AP.ColorMatchingMode", + ] +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsOptionsFilter.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsOptionsFilter.swift new file mode 100644 index 0000000..149f4e5 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsOptionsFilter.swift @@ -0,0 +1,62 @@ +import Foundation + +/// CUPS option filtering for `PMPrintSettingsToOptions` capture +/// (issue 14 layer ⑥, docs/11 §filter). +/// +/// The captured `key=value` string is reduced to the options that +/// should be replayed on `lp`: `com.apple.*` ticket keys, job +/// bookkeeping (`collate`, `copies`, `pserrorhandler-requested`, +/// `job-sheets`), empty values, and **both** `AP_*ColorMatchingMode` +/// keys are dropped — `build_lp_args` always re-adds those itself +/// (issue 15). Unknown non-`com.*` keys are kept (permissive — vendor +/// driver keys survive). +public enum CupsOptionsFilter { + + /// Option keys forwarded from the panel to `lp` (docs/11 roster). + public static let relevantKeys: Set = [ + // Media + "MediaType", "CNIJMediaType", "EPIJ_Medi", "StpMediaType", + // Tray + "InputSlot", "AP_D_InputSlot", + // Size + "PageSize", + // Colour bypass + "CNIJIntent2", "CNIJIntent", "EPIJ_CMat", "EPIJ_CCor", + "EPIJ_OSColMat", "ColorCorrection", "StpColorCorrection", + "EpsonColorMode", "ColorModel", + // Quality + "Resolution", "cupsPrintQuality", "Quality", "EPIJ_Quality", + "CNIJQuality", "StpQuality", "OutputMode", + // Duplex + "Duplex", "sides", + ] + + /// Keys we always drop regardless of the relevant list. `raw` is + /// included — a captured `raw=…` would re-enable CUPS raw mode and + /// bypass the raster filter that honours `AP_ApplicationColorMatching` + /// (#92). + public static let alwaysDropped: Set = [ + "collate", "copies", "pserrorhandler-requested", "job-sheets", + "AP_ColorMatchingMode", "AP.ColorMatchingMode", "raw", + ] + + /// A `key=value` pair survives when the key is non-empty, the value + /// is non-empty, the key is not `com.apple.*`, not always-dropped, + /// and either relevant or an unknown non-`com.*` driver key. + public static func isRelevant(key: String, value: String) -> Bool { + guard !key.isEmpty, !value.isEmpty else { return false } + if key.hasPrefix("com.apple.") { return false } + if alwaysDropped.contains(key) { return false } + if relevantKeys.contains(key) { return true } + // Permissive: unknown vendor keys survive (non-com.*). + return !key.hasPrefix("com.") + } + + /// `key=value key=value …` → filtered string, order preserved. + public static func filter(_ options: String) -> String { + CupsParsers.lpoptions(options) + .filter { isRelevant(key: $0.key, value: $0.value) } + .map { "\($0.key)=\($0.value)" } + .joined(separator: " ") + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift new file mode 100644 index 0000000..22221f7 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift @@ -0,0 +1,264 @@ +import Foundation + +/// One `lpoptions -l` line: `Key/Human Label: *default choice choice`. +public struct CupsOptionListing: Equatable, Sendable { + /// Machine key before `/`, e.g. `InputSlot` or `CNIJMediaType`. + public var key: String + /// Human label after `/`, e.g. `Media Source`. + public var label: String + /// All choices, `*` stripped. + public var choices: [String] + /// The `*`-prefixed default choice, if any. + public var defaultChoice: String? + + public init(key: String, label: String, choices: [String], defaultChoice: String?) { + self.key = key + self.label = label + self.choices = choices + self.defaultChoice = defaultChoice + } +} + +/// Pure parsers for `lpstat` / `lpoptions` / PPD text (issue 12, +/// docs/10–11). Recorded fixtures drive the tests — no live CUPS. +public enum CupsParsers { + + // MARK: - lpstat + + /// `lpstat -e` — one CUPS destination name per line. + public static func lpstatDestinations(_ output: String) -> [String] { + output.split(separator: "\n") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + } + + /// `lpstat -p` — `printer NAME is idle. enabled since …`, + /// `printer NAME now printing NAME-1. …`, `printer NAME disabled + /// since …` → queue → status. + public static func lpstatStatuses(_ output: String) -> [String: PrinterStatus] { + var result: [String: PrinterStatus] = [:] + for line in output.split(separator: "\n") { + let text = line.trimmingCharacters(in: .whitespaces) + guard text.hasPrefix("printer ") else { continue } + let rest = text.dropFirst("printer ".count) + guard let sep = rest.firstIndex(of: " ") else { continue } + let name = String(rest[.. String? { + for line in output.split(separator: "\n") { + let text = line.trimmingCharacters(in: .whitespaces) + guard let colon = text.firstIndex(of: ":") else { continue } + let name = text[text.index(after: colon)...] + .trimmingCharacters(in: .whitespaces) + if text.lowercased().hasPrefix("system default destination"), + !name.isEmpty { + return name + } + } + return nil + } + + // MARK: - lpoptions -p + + /// `lpoptions -p` — `key=value` pairs, values may be + /// single-quoted (`printer-info='EPSON XP-55 Series'`); bare + /// flags (`printer-location`) parse as present-with-empty-value. + public static func lpoptions(_ output: String) -> [(key: String, value: String)] { + var pairs: [(String, String)] = [] + var index = output.startIndex + while index < output.endIndex { + while index < output.endIndex && output[index].isWhitespace { + index = output.index(after: index) + } + guard index < output.endIndex else { break } + let tokenStart = index + while index < output.endIndex && output[index] != "=" && !output[index].isWhitespace { + index = output.index(after: index) + } + let key = String(output[tokenStart.. String? { + guard let value = lpoptions(output) + .first(where: { $0.key == "printer-info" })?.value, + !value.isEmpty + else { return nil } + return value + } + + // MARK: - lpoptions -l + + /// `lpoptions -l` — `Key/Human Label: *Default choice2 choice3`. + /// A missing `/` label reuses the key. + public static func lpoptionsList(_ output: String) -> [CupsOptionListing] { + output.split(separator: "\n").compactMap { raw in + let line = raw.trimmingCharacters(in: .whitespaces) + guard let colon = line.firstIndex(of: ":") else { return nil } + let head = String(line[.. 1 + ? headParts[1].trimmingCharacters(in: .whitespaces) + : key + var choices: [String] = [] + var defaultChoice: String? + for token in body.split(separator: " ") { + if token.hasPrefix("*") { + let value = String(token.dropFirst()) + defaultChoice = value + choices.append(value) + } else { + choices.append(String(token)) + } + } + return CupsOptionListing( + key: key, label: label, + choices: choices, defaultChoice: defaultChoice) + } + } + + // MARK: - PPD enrichment + + /// PPD `* /:` lines → `id → label` map. + /// Language-qualified forms (`*en_US. id/Label:`) also match. + public static func ppdChoiceLabels(_ ppd: String, key: String) -> [String: String] { + var map: [String: String] = [:] + for rawLine in ppd.split(separator: "\n") { + var line = rawLine.trimmingCharacters(in: .whitespaces) + guard line.hasPrefix("*"), !line.hasPrefix("**") else { continue } + line = String(line.dropFirst()) + // Optional locale qualifier: `en_US.InputSlot` → `InputSlot`. + // Only strip when the part before the first `.` looks like + // a locale (short `xx`/`xx_YY`); real keys containing dots + // are left alone. + if let dot = line.firstIndex(of: ".") { + let prefix = line[../` — human label after the last `/`. + guard let slash = rest.firstIndex(of: "/") else { continue } + let id = String(rest[..=`. + public static let mediaTypeKeys = [ + "CNIJMediaType", "EPIJ_Medi", "StpMediaType", "MediaType" + ] + + public static func detectMediaTypeKey(optionKeys: Set) -> String? { + mediaTypeKeys.first { optionKeys.contains($0) } + } + + /// Media type from a captured `key=value key=value` options string. + /// Prefers `MediaType`, then `EPIJ_Medi` (docs/11 §tests). + public static func extractMediaType(fromOptionsString options: String) -> String? { + let pairs = lpoptions(options) + if let v = pairs.first(where: { $0.key == "MediaType" })?.value { + return v + } + return pairs.first(where: { $0.key == "EPIJ_Medi" })?.value + } + + /// Driver "no colour adjustment" key=value for `lpoptions -l` keys + /// (docs/11 layer ④): Canon `CNIJIntent2=4` else `CNIJIntent=4`; + /// Epson `EPIJ_CCor=0` when the key exists else `EPIJ_CMat=3`; + /// Gutenprint `StpColorCorrection=Uncorrected`; generic + /// `ColorCorrection=Uncorrected`; `EpsonColorMode=Off`. + public static func detectDriverColorBypass( + optionKeys: Set + ) -> (key: String, value: String)? { + if optionKeys.contains("CNIJIntent2") { return ("CNIJIntent2", "4") } + if optionKeys.contains("CNIJIntent") { return ("CNIJIntent", "4") } + if optionKeys.contains("EPIJ_CCor") { return ("EPIJ_CCor", "0") } + if optionKeys.contains("EPIJ_CMat") { return ("EPIJ_CMat", "3") } + if optionKeys.contains("StpColorCorrection") { + return ("StpColorCorrection", "Uncorrected") + } + if optionKeys.contains("ColorCorrection") { + return ("ColorCorrection", "Uncorrected") + } + if optionKeys.contains("EpsonColorMode") { return ("EpsonColorMode", "Off") } + return nil + } + + /// The key=value pairs of colour-bypass keys — used to detect + /// whether captured options already carry a bypass. + public static let bypassKeys: Set = [ + "CNIJIntent2", "CNIJIntent", "EPIJ_CMat", "EPIJ_CCor", + "EPIJ_OSColMat", "ColorCorrection", "StpColorCorrection", + "EpsonColorMode", + ] +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsService.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsService.swift new file mode 100644 index 0000000..63eeb60 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsService.swift @@ -0,0 +1,191 @@ +import Foundation + +/// Errors from CUPS tool invocations. +public enum CupsError: LocalizedError, Equatable { + case toolFailed(tool: String, code: Int32, stderr: String) + case tiffMissing(String) + case noPrinterSelected + + public var errorDescription: String? { + switch self { + case .toolFailed(let tool, let code, let stderr): + let detail = stderr.trimmingCharacters(in: .whitespacesAndNewlines) + return detail.isEmpty + ? "\(tool) failed with exit code \(code)" + : "\(tool) failed (\(code)): \(detail)" + case .tiffMissing(let path): + return "Target TIFF does not exist: \(path)" + case .noPrinterSelected: + return "No printer selected." + } + } +} + +/// CUPS command surface (issue 12): enumerates queues and reads +/// per-queue capabilities via `/usr/bin/lpstat` and +/// `/usr/bin/lpoptions`. Spawning goes through +/// `ProcessManager.runCaptured` so spawns are logged, get killAll +/// coverage, and share the dup-id discipline; `binaryDir`/`ppdDir` are +/// injectable so tests use fixture scripts and never touch real CUPS. +public struct CupsService: Sendable { + public let processManager: ProcessManager + /// Directory containing `lpstat`/`lpoptions`/`lp` — `/usr/bin` in + /// production, a fixture dir under test. + public let binaryDir: URL + /// `/etc/cups/ppd` in production. + public let ppdDir: URL + + public init( + processManager: ProcessManager = .shared, + binaryDir: URL = URL(fileURLWithPath: "/usr/bin"), + ppdDir: URL = URL(fileURLWithPath: "/etc/cups/ppd") + ) { + self.processManager = processManager + self.binaryDir = binaryDir + self.ppdDir = ppdDir + } + + // MARK: - Enumeration (lpstat -e/-p/-d) + + /// All CUPS destinations with status and default flag. An empty + /// list is a valid result, not an error. + public func listPrinters() async throws -> [Printer] { + // lpstat exits non-zero when no destinations exist — an empty + // queue list is a valid result, not a failure (issue 12). + let destinationsOut = try await run( + "lpstat", ["-e"], id: ProcessID.lpstat("e"), tolerateFailure: true) + let statusOut = try await run( + "lpstat", ["-p"], id: ProcessID.lpstat("p"), tolerateFailure: true) + let defaultOut = try await run( + "lpstat", ["-d"], id: ProcessID.lpstat("d"), tolerateFailure: true) + + let names = CupsParsers.lpstatDestinations(destinationsOut.stdout) + let statuses = CupsParsers.lpstatStatuses(statusOut.stdout) + let defaultName = CupsParsers.lpstatDefault(defaultOut.stdout) + + var printers: [Printer] = [] + for name in names { + let displayName = try? await displayName(for: name) + printers.append(Printer( + name: name, + status: statuses[name] ?? .unknown, + isDefault: name == defaultName, + displayName: displayName + )) + } + return printers + } + + /// `lpoptions -p ` → `printer-info` (the NSPrinter fallback + /// display name, docs/11 §binding). + public func displayName(for queue: String) async throws -> String? { + let result = try await run( + "lpoptions", ["-p", queue], id: ProcessID.lpoptions(queue)) + return CupsParsers.lpoptionsDisplayName(result.stdout) + } + + // MARK: - Capabilities (lpoptions -l + PPD) + + /// Raw `Key/Label: choices` listings for a queue — also the input + /// to media-key and colour-bypass detection (docs/11 layer ④). + public func optionListings(for queue: String) async throws -> [CupsOptionListing] { + let result = try await run( + "lpoptions", ["-p", queue, "-l"], id: ProcessID.lpoptions("\(queue)-l")) + return CupsParsers.lpoptionsList(result.stdout) + } + + /// Trays / paper sizes / media types for a queue, with PPD + /// `*Key id/Human:` enrichment when the queue's PPD is readable. + public func capabilities(for queue: String) async throws -> PrinterCapabilities { + let listings = try await optionListings(for: queue) + return capabilities(from: listings, ppd: loadPPD(for: queue)) + } + + /// Pure mapping — extracted so fixture tests need no process. + public func capabilities( + from listings: [CupsOptionListing], + ppd: String? + ) -> PrinterCapabilities { + var trays: [PrinterTray] = [] + var sizes: [PrinterPaperSize] = [] + var media: [PrinterMediaType] = [] + + for listing in listings { + switch listing.key { + case "InputSlot", "MediaSource": + trays = listing.choices.enumerated().map { + PrinterTray(id: $0.offset + 1, name: $0.element) + } + case "PageSize", "MediaSize": + sizes = listing.choices.enumerated().map { + PrinterPaperSize(id: $0.offset + 1, name: $0.element) + } + case let key where CupsParsers.mediaTypeKeys.contains(key): + guard media.isEmpty else { continue } + let labels = ppd.map { + CupsParsers.ppdChoiceLabels($0, key: key) + } ?? [:] + media = listing.choices.map { + PrinterMediaType(id: $0, name: labels[$0] ?? $0) + } + default: + continue + } + } + return PrinterCapabilities( + trays: trays, paperSizes: sizes, mediaTypes: media) + } + + /// The set of option keys a queue advertises — input to + /// `detectDriverColorBypass` / `detectMediaTypeKey`. + public func optionKeys(for queue: String) async throws -> Set { + Set(try await optionListings(for: queue).map(\.key)) + } + + // MARK: - Spool (issue 15) + + /// `lp -d ` — spool one target page unmanaged. + /// Never uses `-o raw` (#92). `page` disambiguates the process id + /// when several pages are spooled in sequence. + public func printTarget( + queue: String, + tiffPath: String, + options: PrintOptions, + page: Int = 0 + ) async throws { + guard FileManager.default.fileExists(atPath: tiffPath) else { + throw CupsError.tiffMissing(tiffPath) + } + let optionKeys = (try? await self.optionKeys(for: queue)) ?? [] + let argv = try LpArgs.build( + queue: queue, tiffPath: tiffPath, + options: options, optionKeys: optionKeys) + try await run("lp", argv, id: ProcessID.lp(queue, page: page)) + } + + // MARK: - PPD + + private func loadPPD(for queue: String) -> String? { + let url = ppdDir.appendingPathComponent("\(queue).ppd") + return try? String(contentsOf: url, encoding: .utf8) + } + + // MARK: - Spawn + + @discardableResult + func run( + _ tool: String, + _ arguments: [String], + id: String, + tolerateFailure: Bool = false + ) async throws -> CapturedResult { + let binary = binaryDir.appendingPathComponent(tool) + let result = try await processManager.runCaptured( + id: id, binary: binary, arguments: arguments) + if result.exitCode != 0, !tolerateFailure { + throw CupsError.toolFailed( + tool: tool, code: result.exitCode, stderr: result.stderr) + } + return result + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/LpArgs.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/LpArgs.swift new file mode 100644 index 0000000..3f0ed61 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/LpArgs.swift @@ -0,0 +1,126 @@ +import Foundation + +/// Errors from `buildLpArgs`. +public enum LpArgsError: LocalizedError, Equatable { + case unsanitisedOption(String) + + public var errorDescription: String? { + switch self { + case .unsanitisedOption(let option): + return "Captured CUPS option contains unsafe characters: \(option)" + } + } +} + +/// `lp` argv builder — issue 15, docs/11 `build_lp_args`. +/// +/// ``` +/// lp -d -t "ICCery Target - " +/// -o AP_ColorMatchingMode=AP_ApplicationColorMatching +/// -o AP.ColorMatchingMode=AP_ApplicationColorMatching +/// +/// +/// +/// +/// +/// +/// ``` +/// +/// - **Never `-o raw`** — `raw` skips the raster filter that honours +/// `AP_ApplicationColorMatching` (#92). +/// - Captured options **win** over explicit fields: any key already +/// present (case-insensitive) suppresses the derived `-o`. +/// - Captured keys/values are sanitised — `;`, newlines, or shell +/// metacharacters throw `unsanitisedOption`; args are passed as a +/// `Process` argv array, never through a shell. +/// - The TIFF path is always the **last** argument. +public enum LpArgs { + + /// `options` = the captured `PrintOptions`; `optionKeys` = the + /// queue's `lpoptions -l` key set (for media-key/bypass detection). + public static func build( + queue: String, + tiffPath: String, + options: PrintOptions, + optionKeys: Set + ) throws -> [String] { + var argv: [String] = [ + "-d", queue, + "-t", "ICCery Target - \((tiffPath as NSString).lastPathComponent)", + "-o", "AP_ColorMatchingMode=AP_ApplicationColorMatching", + "-o", "AP.ColorMatchingMode=AP_ApplicationColorMatching", + ] + var addedKeys: Set = [ + "ap_colormatchingmode", "ap.colormatchingmode", + ] + + // Captured CUPS options — sanitised, lowercased-key dedup. + if let captured = options.cupsOptions, !captured.isEmpty { + // Newlines can't survive the tokeniser — check the raw + // string so embedded line breaks are still rejected. + if captured.contains("\n") || captured.contains("\r") { + throw LpArgsError.unsanitisedOption(captured) + } + for pair in CupsParsers.lpoptions(captured) { + try sanitize(pair.key, pair.value) + let lowered = pair.key.lowercased() + // Defence in depth: never let a captured `raw` reach + // argv — `-o raw` skips the raster filter that honours + // AP_ApplicationColorMatching (#92). + if lowered == "raw" { continue } + guard !addedKeys.contains(lowered) else { continue } + addedKeys.insert(lowered) + argv += ["-o", "\(pair.key)=\(pair.value)"] + } + } + + // Media type — only when the captured options didn't carry one. + if let mediaType = options.mediaType, + let mediaKey = CupsParsers.detectMediaTypeKey(optionKeys: optionKeys), + !addedKeys.contains(mediaKey.lowercased()) { + addedKeys.insert(mediaKey.lowercased()) + argv += ["-o", "\(mediaKey)=\(mediaType)"] + } + + // Driver colour bypass — when no bypass key was captured. NOT + // gated on ppdUncorrectedPassthrough (macOS always bypasses). + let capturedKeys = Set( + CupsParsers.lpoptions(options.cupsOptions ?? "") + .map { $0.key }) + if capturedKeys.isDisjoint(with: CupsParsers.bypassKeys), + let bypass = CupsParsers.detectDriverColorBypass(optionKeys: optionKeys), + !addedKeys.contains(bypass.key.lowercased()) { + addedKeys.insert(bypass.key.lowercased()) + argv += ["-o", "\(bypass.key)=\(bypass.value)"] + } + + // Orientation — portrait=3, landscape=4. + if let orientation = options.orientation, + !addedKeys.contains("orientation-requested") { + let value = orientation == "landscape" ? "4" : "3" + addedKeys.insert("orientation-requested") + argv += ["-o", "orientation-requested=\(value)"] + } + + // PageSize — the printtarg layout page size. + if let paperSize = options.paperSize, !paperSize.isEmpty, + !addedKeys.contains("pagesize") { + argv += ["-o", "PageSize=\(paperSize)"] + } + + argv.append(tiffPath) + return argv + } + + /// Reject shell/metachar injection — args go to `Process` as an + /// argv array, but a hostile captured string must not smuggle a + /// second option or command. + static func sanitize(_ key: String, _ value: String) throws { + let forbidden = CharacterSet(charactersIn: ";\n\r`|$&<>\\\"'") + if key.rangeOfCharacter(from: forbidden) != nil + || value.rangeOfCharacter(from: forbidden) != nil + || key.isEmpty { + throw LpArgsError.unsanitisedOption("\(key)=\(value)") + } + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/PrinterModels.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/PrinterModels.swift new file mode 100644 index 0000000..2b95f8d --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/PrinterModels.swift @@ -0,0 +1,136 @@ +import Foundation + +/// Queue status reported by `lpstat -p` (docs/10 §Printer). +public enum PrinterStatus: String, Codable, Sendable, CaseIterable { + case idle = "Idle" + case printing = "Printing" + case stopped = "Stopped" + case unknown = "Unknown" +} + +/// A CUPS destination. `name` is the queue id sent back to every +/// subsequent print command; `displayName` is the human label from +/// `printer-info` (used as the `NSPrinter` fallback when PM binding +/// fails — #188). +public struct Printer: Codable, Equatable, Sendable { + public var name: String + public var status: PrinterStatus + public var isDefault: Bool + public var displayName: String? + + public init( + name: String, + status: PrinterStatus = .unknown, + isDefault: Bool = false, + displayName: String? = nil + ) { + self.name = name + self.status = status + self.isDefault = isDefault + self.displayName = displayName + } +} + +/// Paper source. `id` is the 1-based index of the `InputSlot` / +/// `MediaSource` choice (not a PPD code) — docs/10. +public struct PrinterTray: Codable, Equatable, Sendable { + public var id: Int + public var name: String + + public init(id: Int, name: String) { + self.id = id + self.name = name + } +} + +/// Media size from `PageSize` / `MediaSize` choices (1-based index). +public struct PrinterPaperSize: Codable, Equatable, Sendable { + public var id: Int + public var name: String + + public init(id: Int, name: String) { + self.id = id + self.name = name + } +} + +/// Media type: `id` is the PPD machine token, `name` the human label +/// after `/` when a readable PPD enriches it (docs/10 §PPD id/Human). +public struct PrinterMediaType: Codable, Equatable, Sendable { + public var id: String + public var name: String + + public init(id: String, name: String) { + self.id = id + self.name = name + } +} + +public struct PrinterCapabilities: Codable, Equatable, Sendable { + public var trays: [PrinterTray] + public var paperSizes: [PrinterPaperSize] + public var mediaTypes: [PrinterMediaType] + /// Always `true` on macOS (spec parity — CUPS honours + /// `orientation-requested`). + public var supportsOrientation: Bool + + public init( + trays: [PrinterTray] = [], + paperSizes: [PrinterPaperSize] = [], + mediaTypes: [PrinterMediaType] = [], + supportsOrientation: Bool = true + ) { + self.trays = trays + self.paperSizes = paperSizes + self.mediaTypes = mediaTypes + self.supportsOrientation = supportsOrientation + } +} + +/// Options carried into `lp` (docs/10 §PrintOptions). On macOS +/// `paperSource` is ignored unless already present inside captured +/// `cupsOptions`; `ppdUncorrectedPassthrough` is stored (the panel sets +/// it on OK) but never gates the argv — macOS always bypasses driver +/// colour management. +public struct PrintOptions: Codable, Equatable, Sendable { + public var paperSource: Int? + /// `"portrait"` / `"landscape"` → `orientation-requested=3|4`. + public var orientation: String? + /// printtarg layout page size → `PageSize=` (skipped if captured). + public var paperSize: String? + public var mediaType: String? + public var ppdUncorrectedPassthrough: Bool? + /// Space-separated `key=value` captured from + /// `PMPrintSettingsToOptions` and filtered (docs/11 layer ⑥). + public var cupsOptions: String? + + public init( + paperSource: Int? = nil, + orientation: String? = nil, + paperSize: String? = nil, + mediaType: String? = nil, + ppdUncorrectedPassthrough: Bool? = nil, + cupsOptions: String? = nil + ) { + self.paperSource = paperSource + self.orientation = orientation + self.paperSize = paperSize + self.mediaType = mediaType + self.ppdUncorrectedPassthrough = ppdUncorrectedPassthrough + self.cupsOptions = cupsOptions + } +} + +/// Returned by the printer-properties panel (docs/10 §PrintPropertiesResult). +/// `nil` from the service means the user cancelled — never an error. +public struct PrintPropertiesResult: Codable, Equatable, Sendable { + /// CUPS printer id the panel ended on (`PMPrinterGetID`), or `nil` + /// when the `NSPrinter` fallback ran. + public var selectedPrinter: String? + public var options: PrintOptions + + public init(selectedPrinter: String?, options: PrintOptions) { + self.selectedPrinter = selectedPrinter + self.options = options + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Process/JSONAccumulator.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Process/JSONAccumulator.swift new file mode 100644 index 0000000..40e2c23 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Process/JSONAccumulator.swift @@ -0,0 +1,59 @@ +import Foundation + +/// Accumulates stdout lines into a complete JSON document. +/// +/// Several Argyll tools (`instlist`, `profcheck`, `printtarg` manifest) +/// emit pretty-printed multi-line JSON on stdout. Individual lines are +/// *not* valid JSON — only the whole block is — so callers route stdout +/// lines here and get `Data` back once the buffer parses. +/// +/// `ROW_COLORS_JSON: ` lines never reach this type; ProcessManager +/// diverts them to `jsonRow` events first. +public struct JSONAccumulator: Sendable { + private var buffer = Data() + + public init() {} + + /// Appends one stdout line. Returns the complete document bytes when + /// the accumulated buffer forms valid JSON, otherwise `nil`. + public mutating func feed(line: String) -> Data? { + buffer.append(Data(line.utf8)) + buffer.append(0x0A) + return tryParse() + } + + /// Attempts to decode the accumulated buffer; clears it on success. + public mutating func decode(_ type: T.Type) -> T? { + guard let data = tryParse() else { return nil } + return try? JSONDecoder().decode(T.self, from: data) + } + + /// Raw buffer when it parses, `nil` while still incomplete. + public var completeData: Data? { + var copy = self + return copy.tryParse() + } + + public mutating func reset() { + buffer.removeAll(keepingCapacity: false) + } + + public var isEmpty: Bool { buffer.isEmpty } + + private mutating func tryParse() -> Data? { + // Cheap gate: JSON documents start with { or [. + guard let first = buffer.first(where: { !$0.isJSONWhitespace }), + first == UInt8(ascii: "{") || first == UInt8(ascii: "[") + else { return nil } + guard (try? JSONSerialization.jsonObject(with: buffer)) != nil else { return nil } + let out = buffer + buffer.removeAll(keepingCapacity: false) + return out + } +} + +private extension UInt8 { + var isJSONWhitespace: Bool { + self == 0x20 || self == 0x09 || self == 0x0A || self == 0x0D + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessEvent.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessEvent.swift new file mode 100644 index 0000000..ea5b54b --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessEvent.swift @@ -0,0 +1,51 @@ +import Foundation + +/// Events on the process bus — the v2 equivalent of the v1 Tauri events +/// `process:stdout|stderr|exit|error|json_row` (docs/02 §Event bus). +public enum ProcessEvent: Sendable, Equatable { + /// Non-JSON stdout line. (`process:stdout`) + case stdout(id: String, line: String) + /// stderr line. (`process:stderr`) + case stderr(id: String, line: String) + /// Child exited; 0 = success. (`process:exit`) + case exit(id: String, code: Int32) + /// Spawn failure. (`process:error`) + case error(id: String, message: String) + /// Stdout line began with `ROW_COLORS_JSON: ` — prefix stripped, + /// payload is the remaining raw bytes. (`process:json_row`) + case jsonRow(id: String, payload: Data) + + public var id: String { + switch self { + case .stdout(let id, _), .stderr(let id, _), .exit(let id, _), + .error(let id, _), .jsonRow(let id, _): + return id + } + } +} + +public enum ProcessError: Error, Equatable, Sendable { + /// A child with this id is still running (#116). + case duplicateID(String) + /// No child registered under this id. + case unknownID(String) + /// Process refused to launch. + case spawnFailed(String) + /// stdin write failed (pipe closed / process gone). + case stdinFailed(String) +} + +extension ProcessError: LocalizedError { + public var errorDescription: String? { + switch self { + case .duplicateID(let id): + return "Process already running: \(id)" + case .unknownID(let id): + return "Unknown process: \(id)" + case .spawnFailed(let detail): + return "Could not launch \(detail)" + case .stdinFailed(let detail): + return "stdin failed: \(detail)" + } + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessID.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessID.swift new file mode 100644 index 0000000..8c2d562 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessID.swift @@ -0,0 +1,25 @@ +import Foundation + +/// Deterministic process ids (docs/02 §Event bus). Listeners must always +/// filter events on `id` — historical bug #56 was an id mismatch. +public enum ProcessID { + public static let instlist = "instlist" + /// Spot-read console (issue #148) — single lease, like `instlist`. + public static let spotread = "spotread" + + public static func targen(_ basename: String) -> String { "targen_\(basename)" } + public static func printtarg(_ basename: String) -> String { "printtarg_\(basename)" } + public static func chartread(_ basename: String) -> String { "chartread_\(basename)" } + public static func average(_ basename: String) -> String { "average_\(basename)" } + public static func colprof(_ basename: String) -> String { "colprof_\(basename)" } + public static func profcheck(ti3Path: String) -> String { "profcheck_\(ti3Path)" } + public static func iccgamut(stem: String) -> String { "iccgamut_\(stem)" } + public static func printcal(_ stem: String) -> String { "printcal_\(stem)" } + public static func applycal(_ stem: String) -> String { "applycal_\(stem)" } + + /// CUPS system tools (`/usr/bin/…`) — captured one-shots, not + /// streaming Argyll children. + public static func lpstat(_ mode: String) -> String { "lpstat_\(mode)" } + public static func lpoptions(_ queue: String) -> String { "lpoptions_\(queue)" } + public static func lp(_ queue: String, page: Int) -> String { "lp_\(queue)_\(page)" } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessLineDecoder.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessLineDecoder.swift new file mode 100644 index 0000000..25f9c79 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessLineDecoder.swift @@ -0,0 +1,53 @@ +import Foundation + +/// Incremental byte→line decoder for process pipes. +/// +/// Splits raw `availableData` chunks at `0x0A`. A newline byte can never +/// appear inside a multi-byte UTF-8 sequence (continuation bytes are +/// ≥ 0x80), so splitting bytes at `\n` is always scalar-safe; each line +/// is then decoded with a lossy fallback for non-UTF-8 output. +public struct ProcessLineDecoder: Sendable { + public private(set) var pending = Data() + + public init() {} + + /// Feeds a chunk; returns every complete line found (without `\n`). + public mutating func feed(_ chunk: Data) -> [String] { + guard !chunk.isEmpty else { return [] } + pending.append(chunk) + var lines: [String] = [] + while let nl = pending.firstIndex(of: 0x0A) { + var slice = pending.prefix(upTo: nl) + pending = pending.suffix(from: pending.index(after: nl)) + // Tolerate CRLF output. + if slice.last == 0x0D { slice = slice.dropLast() } + lines.append(Self.decode(slice)) + } + return lines + } + + /// Flushes any unterminated remainder at EOF. Returns `nil` when empty. + public mutating func finish() -> String? { + guard !pending.isEmpty else { return nil } + var rest = pending + pending.removeAll(keepingCapacity: false) + if rest.last == 0x0D { rest = rest.dropLast() } + return rest.isEmpty ? nil : Self.decode(rest) + } + + /// Emits the current unterminated tail as a single line and clears it. + /// Used by `ProcessManager.flushPartialLine` for tools that emit + /// progress dots without newlines. + public mutating func flushPartial() -> String? { + guard !pending.isEmpty else { return nil } + var rest = pending + pending.removeAll(keepingCapacity: false) + if rest.last == 0x0D { rest = rest.dropLast() } + let text = Self.decode(rest) + return text.isEmpty ? nil : text + } + + private static func decode(_ bytes: Data.SubSequence) -> String { + String(decoding: bytes, as: UTF8.self) + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessManager.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessManager.swift new file mode 100644 index 0000000..6e0f4eb --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessManager.swift @@ -0,0 +1,614 @@ +import Foundation + +/// Captured output from `runCaptured` — one-shot tools whose results +/// arrive as buffered stdout/stderr (printcal/applycal, CUPS tools). +public struct CapturedResult: Sendable, Equatable { + public let stdout: String + public let stderr: String + public let exitCode: Int32 +} + +/// Spawn / stdin / kill / event bus for Argyll sidecar children +/// (docs/02 §Event bus, docs/03 §Process manager). +/// +/// Invariants: +/// - Duplicate `id` while a child runs is rejected (#116). +/// - The stdin handle lives in its own map, independent of wait, so +/// `sendStdin` never blocks on process exit (#84). +/// - `ARGYLL_NOT_INTERACTIVE=1` is set on every child. +/// - stdout lines beginning `ROW_COLORS_JSON: ` become `jsonRow` events +/// with the prefix stripped; all other stdout is `stdout` events. +/// - `exit` is emitted exactly once per child, and only after both +/// output pipes reach EOF — so no buffered output is lost on fast +/// exits or kills. If EOFs never arrive, a watchdog finalizes. +/// - `kill` runs a pre-kill hook (e.g. XY `q\n` + 500 ms park) before +/// terminating. Hooks are removed once the child finalizes. +/// - `killAll` on `NSApplication.willTerminate` and last-window close +/// runs all hooks and terminates every child (#147, #149). +public actor ProcessManager { + + public static let rowColorsPrefix = "ROW_COLORS_JSON: " + + public static let shared = ProcessManager() + + // MARK: - Event bus (multicast) + + /// Lock-protected subscriber table. Registration is *synchronous* + /// inside `events()` so a caller can subscribe, then spawn, without + /// racing the child's first output or exit event. + private final class SubscriberBox: @unchecked Sendable { + private let lock = NSLock() + private var map: [UUID: AsyncStream.Continuation] = [:] + + func add(_ continuation: AsyncStream.Continuation, token: UUID) { + lock.lock() + map[token] = continuation + lock.unlock() + } + + func remove(_ token: UUID) { + lock.lock() + map.removeValue(forKey: token) + lock.unlock() + } + + func yield(_ event: ProcessEvent) { + lock.lock() + let continuations = Array(map.values) + lock.unlock() + for continuation in continuations { + continuation.yield(event) + } + } + } + + private nonisolated let subscriberBox = SubscriberBox() + + /// Subscribe to the event bus. Each call returns an independent + /// stream; every event is delivered to every live subscriber. + /// The subscriber is registered before `events()` returns — callers + /// may spawn immediately after subscribing without losing events. + public nonisolated func events() -> AsyncStream { + let box = subscriberBox + let token = UUID() + return AsyncStream { continuation in + box.add(continuation, token: token) + continuation.onTermination = { _ in box.remove(token) } + } + } + + private nonisolated func emit(_ event: ProcessEvent) { + subscriberBox.yield(event) + } + + // MARK: - Child registry + + private struct RunningChild { + let process: Process + /// stdin lives in its own slot, independent of process wait (#84). + var stdin: FileHandle? + var stdoutDecoder: ProcessLineDecoder + var stderrDecoder: ProcessLineDecoder + var stdoutEOF = false + var stderrEOF = false + /// Set by the termination handler; `exit` is emitted once both + /// pipes have also reached EOF. + var pendingExitCode: Int32? + var finalized = false + /// Watchdog that forces finalization if EOFs never arrive. + var finalizeTask: Task? + } + + private var children: [String: RunningChild] = [:] + /// Processes owned by `runCaptured` (dup detection + kill support). + private var captured: [String: Process] = [:] + + /// Hooks run by `kill` before terminating the child. + /// Used by `chartread` to park an XY head with `q\n`. + private var preKillHooks: [String: @Sendable () async -> Void] = [:] + + /// Ids of currently-running children. + public var runningIDs: [String] { Array(children.keys) + captured.keys } + + public func isRunning(_ id: String) -> Bool { + children[id] != nil || captured[id] != nil + } + + // MARK: - Pre-kill hooks + + /// Register a hook to run before `kill(id:)` terminates the child. + /// The hook is removed once the child finalizes. + public func setPreKillHook(id: String, hook: @escaping @Sendable () async -> Void) { + preKillHooks[id] = hook + } + + // MARK: - Spawn (streaming) + + /// Spawns a streaming child. Returns after spawn; callers wait for + /// `exit(id:)` events — never assume the return means the tool + /// finished (docs/03). + public func runStreaming( + id: String, + binary: URL, + arguments: [String], + workingDirectory: URL? = nil, + environment: [String: String] = [:] + ) throws { + guard !isRunning(id) else { throw ProcessError.duplicateID(id) } + + let prepared = makeProcess( + binary: binary, + arguments: arguments, + workingDirectory: workingDirectory, + environment: environment, + includeStdin: true + ) + let process = prepared.process + + logSpawn(id: id, binary: binary, arguments: arguments, captured: false) + + children[id] = RunningChild( + process: process, + stdin: prepared.stdinPipe?.fileHandleForWriting, + stdoutDecoder: ProcessLineDecoder(), + stderrDecoder: ProcessLineDecoder() + ) + + let stdoutHandle = prepared.stdoutPipe.fileHandleForReading + let stderrHandle = prepared.stderrPipe.fileHandleForReading + stdoutHandle.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard let self else { return } + Task { await self.ingestOutput(data, id: id, isStderr: false, handle: handle) } + } + stderrHandle.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard let self else { return } + Task { await self.ingestOutput(data, id: id, isStderr: true, handle: handle) } + } + + attachTerminationHandler(process) { [weak self] code in + guard let self else { return } + Task { await self.didTerminate(id: id, code: code) } + } + + do { + try process.run() + } catch { + preKillHooks.removeValue(forKey: id) + children.removeValue(forKey: id) + emit(.error(id: id, message: error.localizedDescription)) + throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)") + } + startWaitUntilExitWatchdog(process) { [weak self] code in + guard let self else { return } + Task { await self.didTerminate(id: id, code: code) } + } + } + + // MARK: - Spawn (captured) + + /// Runs a child to completion and returns all output. Reads stdout + /// and stderr concurrently so a full pipe buffer can never deadlock + /// the child. Used by `printcal` / `applycal` (docs/03) and by + /// `CupsService` for `/usr/bin/lpstat`, `lpoptions`, `lp` (#12/#15). + public func runCaptured( + id: String, + binary: URL, + arguments: [String], + workingDirectory: URL? = nil, + environment: [String: String] = [:] + ) async throws -> CapturedResult { + guard !isRunning(id) else { throw ProcessError.duplicateID(id) } + + let prepared = makeProcess( + binary: binary, + arguments: arguments, + workingDirectory: workingDirectory, + environment: environment, + includeStdin: false + ) + let process = prepared.process + let stdoutPipe = prepared.stdoutPipe + let stderrPipe = prepared.stderrPipe + + logSpawn(id: id, binary: binary, arguments: arguments, captured: true) + + // Register and set up the termination hand-off before run() so + // a very fast exit is never missed (#50, #52). + captured[id] = process + + let capturedProcess = process + + // Box is local and synchronised with an NSLock; the @unchecked + // Sendable annotation is safe because all access is under the lock. + final class Box: @unchecked Sendable { + private let lock = NSLock() + private var status: Int32? + private var continuation: CheckedContinuation? + + /// Try to resume an already-stored continuation with the exit + /// status. Returns true if a continuation was resumed. + func resume(with status: Int32) -> Bool { + lock.lock() + if let cont = continuation { + continuation = nil + lock.unlock() + cont.resume(returning: status) + return true + } + self.status = status + lock.unlock() + return false + } + + /// Store a continuation, returning any status that arrived + /// before it. The caller must resume with the returned status. + func store(_ continuation: CheckedContinuation) -> Int32? { + lock.lock() + if let status = status { + self.status = nil + self.continuation = nil + lock.unlock() + return status + } + self.continuation = continuation + // A fast exit may have raced past the first nil-check. + if let status = status { + self.status = nil + self.continuation = nil + lock.unlock() + return status + } + lock.unlock() + return nil + } + } + let box = Box() + attachTerminationHandler(capturedProcess) { status in + _ = box.resume(with: status) + } + + do { + try process.run() + } catch { + _ = box.resume(with: -1) + captured.removeValue(forKey: id) + preKillHooks.removeValue(forKey: id) + emit(.error(id: id, message: error.localizedDescription)) + throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)") + } + startWaitUntilExitWatchdog(capturedProcess) { status in + _ = box.resume(with: status) + } + + // Close the parent write ends so readDataToEndOfFile() gets EOF + // as soon as the child exits; the child still has its own copies. + try? stdoutPipe.fileHandleForWriting.close() + try? stderrPipe.fileHandleForWriting.close() + + return await withTaskCancellationHandler { + async let outData = Task.detached { + stdoutPipe.fileHandleForReading.readDataToEndOfFile() + }.value + async let errData = Task.detached { + stderrPipe.fileHandleForReading.readDataToEndOfFile() + }.value + + let code = await withCheckedContinuation { continuation in + if let status = box.store(continuation) { + continuation.resume(returning: status) + } + } + + let (out, err) = await (outData, errData) + + // Emit the real exit code once, regardless of whether kill() + // already removed the id from `captured`. + _ = captured.removeValue(forKey: id) + preKillHooks.removeValue(forKey: id) + emit(.exit(id: id, code: code)) + + return CapturedResult( + stdout: String(decoding: out, as: UTF8.self), + stderr: String(decoding: err, as: UTF8.self), + exitCode: code + ) + } onCancel: { [weak self] in + // If the awaiting Task is cancelled, terminate the child so + // callers like runApplycal never replace a good profile with + // a truncated tmp. + if capturedProcess.isRunning { + capturedProcess.terminate() + } + Task { [weak self] in + await self?.kill(id: id) + } + } + } + + // MARK: - stdin + + /// Writes the exact bytes (caller includes `\n`) to a child's stdin + /// and flushes (docs/03 §stdin protocol). + public func sendStdin(id: String, bytes: Data) throws { + guard let child = children[id] else { throw ProcessError.unknownID(id) } + guard let handle = child.stdin else { + throw ProcessError.stdinFailed("stdin closed for \(id)") + } + do { + try handle.write(contentsOf: bytes) + } catch { + throw ProcessError.stdinFailed("\(id): \(error.localizedDescription)") + } + } + + public func sendStdin(id: String, text: String) throws { + try sendStdin(id: id, bytes: Data(text.utf8)) + } + + // MARK: - Partial-line flush + + /// Emits the current unterminated tail of a streaming child's stdout + /// and stderr as ordinary lines. Callers (e.g. `colprof`) use this + /// to flush progress dots without waiting for a newline. + public func flushPartialLine(id: String) { + guard var child = children[id], !child.finalized else { return } + + if let tail = child.stdoutDecoder.flushPartial() { + emitStdoutLine(id: id, line: tail) + } + if let tail = child.stderrDecoder.flushPartial() { + emit(.stderr(id: id, line: tail)) + } + + children[id] = child + } + + // MARK: - Kill + + /// Terminates a child. First runs any registered pre-kill hook, then + /// drops stdin and signals the process. For streaming children the + /// `exit` event is emitted once both stdout and stderr EOFs have been + /// seen (or the watchdog finalizes). For captured children the real + /// exit code is emitted by `runCaptured` itself. + public func kill(id: String) async { + if let hook = preKillHooks.removeValue(forKey: id) { + await hook() + } + + if var child = children[id] { + try? child.stdin?.close() + child.stdin = nil + children[id] = child + + if child.process.isRunning { + child.process.terminate() + } else if child.pendingExitCode == nil { + // The process already exited but `didTerminate` has not + // run; synthesize it so `maybeFinalize` can fire. + didTerminate(id: id, code: child.process.terminationStatus) + } + return + } + + if let process = captured[id] { + if process.isRunning { process.terminate() } + // Do not emit `.exit` here; `runCaptured` emits the real code + // after the process reaps. + return + } + } + + /// Terminates every running child; returns how many were signaled + /// (`kill_all_processes`, docs/03). Mandatory on app exit (#147/#149). + @discardableResult + public func killAll() async -> Int { + let ids = runningIDs + for id in ids { await kill(id: id) } + return ids.count + } + + // MARK: - Force kill (SIGKILL fallback) + + /// Sends `SIGKILL` to a streaming child if it is still running. + /// Used by the finalization watchdog when a graceful `terminate()` + /// does not cause the process to exit. + public func forceKill(id: String) { + guard let child = children[id], + !child.finalized, + child.process.isRunning + else { return } + + let pid = child.process.processIdentifier + guard pid > 0 else { return } + _ = Darwin.kill(pid, SIGKILL) + } + + // MARK: - Internals + + private struct PreparedProcess { + let process: Process + let stdinPipe: Pipe? + let stdoutPipe: Pipe + let stderrPipe: Pipe + } + + private func makeProcess( + binary: URL, + arguments: [String], + workingDirectory: URL?, + environment: [String: String], + includeStdin: Bool + ) -> PreparedProcess { + let process = Process() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + let stdinPipe: Pipe? = includeStdin ? Pipe() : nil + process.executableURL = binary + process.arguments = arguments + process.currentDirectoryURL = workingDirectory + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + if let stdinPipe { + process.standardInput = stdinPipe + } + process.environment = childEnvironment(extra: environment) + return PreparedProcess( + process: process, + stdinPipe: stdinPipe, + stdoutPipe: stdoutPipe, + stderrPipe: stderrPipe + ) + } + + private nonisolated func logSpawn( + id: String, + binary: URL, + arguments: [String], + captured: Bool + ) { + let prefix = captured ? "spawn(captured)" : "spawn" + AppLogger(category: "process").debug( + "\(prefix) \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))" + ) + } + + /// `terminationHandler` can lose a fast-exit race on a loaded host; + /// `waitUntilExit` on a detached thread is the fallback (#50, #52). + /// The handler is attached before `run()`; the wait thread starts + /// only after a successful launch — `terminationStatus` on an + /// unlaunched NSTask raises NSInvalidArgumentException. + private func attachTerminationHandler( + _ process: Process, + onExit: @escaping @Sendable (Int32) -> Void + ) { + process.terminationHandler = { proc in + onExit(proc.terminationStatus) + } + } + + private func startWaitUntilExitWatchdog( + _ process: Process, + onExit: @escaping @Sendable (Int32) -> Void + ) { + Task.detached { [process] in + process.waitUntilExit() + onExit(process.terminationStatus) + } + } + + private func emitStdoutLine(id: String, line: String) { + if line.hasPrefix(Self.rowColorsPrefix) { + let payload = Data(line.dropFirst(Self.rowColorsPrefix.count).utf8) + emit(.jsonRow(id: id, payload: payload)) + } else { + emit(.stdout(id: id, line: line)) + } + } + + private func childEnvironment(extra: [String: String]) -> [String: String] { + var env = ProcessInfo.processInfo.environment + env["ARGYLL_NOT_INTERACTIVE"] = "1" + for (key, value) in extra { env[key] = value } + return env + } + + private func ingestOutput( + _ data: Data, + id: String, + isStderr: Bool, + handle: FileHandle + ) { + guard var child = children[id] else { return } + + if data.isEmpty { + // EOF on this pipe. + handle.readabilityHandler = nil + if isStderr { child.stderrEOF = true } else { child.stdoutEOF = true } + children[id] = child + maybeFinalize(id: id) + return + } + + let lines: [String] = isStderr + ? child.stderrDecoder.feed(data) + : child.stdoutDecoder.feed(data) + children[id] = child + + let log = AppLogger(category: "subprocess") + for line in lines { + if !isStderr { + emitStdoutLine(id: id, line: line) + if !line.hasPrefix(Self.rowColorsPrefix) { + log.info("[\(id)] \(line)") + } + } else { + log.warn("[\(id)] \(line)") + emit(.stderr(id: id, line: line)) + } + } + } + + private func didTerminate(id: String, code: Int32) { + guard var child = children[id], !child.finalized else { return } + child.pendingExitCode = code + try? child.stdin?.close() + child.stdin = nil + + // Start a watchdog in case the `readabilityHandler` EOFs never + // arrive after the process exits (e.g. a hung pipe). + child.finalizeTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: 2_000_000_000) + guard let self else { return } + await self.forceKill(id: id) + await self.forceFinalize(id: id) + } + + children[id] = child + maybeFinalize(id: id) + } + + /// Emits `exit` once the child has terminated *and* both pipes have + /// drained to EOF, so no buffered output is lost. + private func maybeFinalize(id: String) { + guard var child = children[id], + let code = child.pendingExitCode, + child.stdoutEOF, child.stderrEOF, + !child.finalized + else { return } + + child.finalized = true + child.finalizeTask?.cancel() + child.finalizeTask = nil + children.removeValue(forKey: id) + preKillHooks.removeValue(forKey: id) + + // Flush unterminated tail lines. + if var decoder = Optional(child.stdoutDecoder), + let tail = decoder.finish() { + emitStdoutLine(id: id, line: tail) + } + if var decoder = Optional(child.stderrDecoder), + let tail = decoder.finish() { + emit(.stderr(id: id, line: tail)) + } + emit(.exit(id: id, code: code)) + } + + /// Forces finalization even when one or both EOFs are missing. + /// Used by the `didTerminate` watchdog. + private func forceFinalize(id: String) { + guard var child = children[id], !child.finalized else { return } + + if child.pendingExitCode == nil { + child.pendingExitCode = -9 + } + child.stdoutEOF = true + child.stderrEOF = true + child.finalizeTask?.cancel() + child.finalizeTask = nil + children[id] = child + maybeFinalize(id: id) + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Profile/ApplycalArgs.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/ApplycalArgs.swift new file mode 100644 index 0000000..dc9a5c1 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/ApplycalArgs.swift @@ -0,0 +1,50 @@ +import Foundation + +/// Errors during `applycal` argv construction. +public enum ApplycalArgError: LocalizedError, Equatable, Sendable { + case invalidCalibrationPath + case invalidInputProfileURL + + public var errorDescription: String? { + switch self { + case .invalidCalibrationPath: + return "Calibration path is invalid or empty" + case .invalidInputProfileURL: + return "Input profile path is invalid or empty" + } + } +} + +/// Pure argv builder for Argyll's `applycal` tool. +/// +/// `applycal` is always run captured, never streamed. +public enum ApplycalArgs { + + /// Builds `applycal -v -a {cal} {input} [{output}]`. + /// + /// `-u` (unapply) is rejected at the builder level — the UI never + /// sends it (docs/04 §7.2). + public static func build(config: ApplycalConfig) throws -> [String] { + let cal = config.calibrationPath.trimmingCharacters(in: .whitespaces) + guard !cal.isEmpty else { throw ApplycalArgError.invalidCalibrationPath } + + let input = config.inputProfileURL.path + guard !input.isEmpty else { throw ApplycalArgError.invalidInputProfileURL } + + var args: [String] = ["-v"] + if config.unapply { + // Defensive: should never be called from the UI. + args.append("-u") + } else { + args.append("-a") + } + + args.append(contentsOf: [cal, input]) + + if let output = config.outputProfileURL?.path, !output.isEmpty { + args.append(output) + } + + return args + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Profile/ApplycalConfig.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/ApplycalConfig.swift new file mode 100644 index 0000000..3f793cc --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/ApplycalConfig.swift @@ -0,0 +1,22 @@ +import Foundation + +/// Configuration for an Argyll `applycal` run. +public struct ApplycalConfig: Sendable, Equatable { + public var calibrationPath: String + public var inputProfileURL: URL + public var outputProfileURL: URL? + public var unapply: Bool + + /// In-place when `outputProfileURL` is `nil`. + public init( + calibrationPath: String, + inputProfileURL: URL, + outputProfileURL: URL? = nil, + unapply: Bool = false + ) { + self.calibrationPath = calibrationPath + self.inputProfileURL = inputProfileURL + self.outputProfileURL = outputProfileURL + self.unapply = unapply + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Profile/CalibrationIdentity.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/CalibrationIdentity.swift new file mode 100644 index 0000000..afcf06f --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/CalibrationIdentity.swift @@ -0,0 +1,54 @@ +import Foundation + +/// Canonical `CAL_` / original-stem pairing for Stage 0 (issue #29 / #83). +/// +/// The live wizard basename, the persisted `calibrationOriginalBasename`, +/// and the runner all derive identity from this type. Do not add a second +/// `hasPrefix("CAL_")` ternary elsewhere. +public struct CalibrationIdentity: Equatable, Sendable { + /// Never has a `CAL_` prefix. Empty only when the live basename is empty. + public var originalBasename: String + /// Always `CAL_{original}` when original is non-empty. + public var calibrationBasename: String + + public init(originalBasename: String, calibrationBasename: String) { + self.originalBasename = originalBasename + self.calibrationBasename = calibrationBasename + } + + public static func isCalibration(_ basename: String) -> Bool { + basename.hasPrefix("CAL_") + } + + /// The only place that adds a `CAL_` prefix. + public static func prefix(_ original: String) -> String { + if original.isEmpty { return original } + return original.hasPrefix("CAL_") ? original : "CAL_\(original)" + } + + /// Strip a single leading `CAL_` if present. + public static func strip(_ basename: String) -> String { + basename.hasPrefix("CAL_") ? String(basename.dropFirst(4)) : basename + } + + /// Derive identity from the live wizard basename and the persisted + /// original. A non-empty persisted original wins over a `CAL_` live + /// name (Force Quit mid-calibration). An empty live basename always + /// produces an empty identity — a persisted original must never + /// resurrect a target that no longer exists (#83). + public static func parse(liveBasename: String, persistedOriginal: String) -> CalibrationIdentity { + guard !liveBasename.isEmpty else { + return CalibrationIdentity(originalBasename: "", calibrationBasename: "") + } + let original: String + if liveBasename.hasPrefix("CAL_") { + original = persistedOriginal.isEmpty ? strip(liveBasename) : persistedOriginal + } else { + original = liveBasename + } + return CalibrationIdentity( + originalBasename: original, + calibrationBasename: prefix(original) + ) + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Profile/CalibrationStore.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/CalibrationStore.swift new file mode 100644 index 0000000..3b3904f --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/CalibrationStore.swift @@ -0,0 +1,203 @@ +import Foundation + +/// A single channel's calibration curve. +public struct CalibrationCurve: Sendable, Equatable { + public let channel: Character + public let input: [Double] + public let output: [Double] + + public init(channel: Character, input: [Double], output: [Double]) { + self.channel = channel + self.input = input + self.output = output + } +} + +/// Parsed Argyll `.cal` curve data. +public struct CalibrationData: Sendable, Equatable { + public var colorRep: String + public var descriptor: String? + public var created: Date? + public var maxTac: Double? + public var inkLimits: [Character: Double] + public var curves: [CalibrationCurve] + + public init( + colorRep: String = "", + descriptor: String? = nil, + created: Date? = nil, + maxTac: Double? = nil, + inkLimits: [Character: Double] = [:], + curves: [CalibrationCurve] = [] + ) { + self.colorRep = colorRep + self.descriptor = descriptor + self.created = created + self.maxTac = maxTac + self.inkLimits = inkLimits + self.curves = curves + } +} + +/// Errors from loading and parsing a `.cal` file. +public enum CalibrationStoreError: Error, Equatable { + case unreadableFile + case missingColorRep + case missingCurveData + case unsupportedFormat + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .unreadableFile: + return "Could not read the calibration file." + case .missingColorRep: + return "The .cal file is missing its COLOR_REP header." + case .missingCurveData: + return "The .cal file contains no calibration curve data." + case .unsupportedFormat: + return "The .cal file format is not supported." + case .parseFailed(let reason): + return "Calibration parse failed: \(reason)" + } + } +} + +/// Store for a calibration curve, its metadata, and staleness checks. +public actor CalibrationStore { + + public private(set) var data: CalibrationData? + public private(set) var sourceURL: URL? + public private(set) var storedPrinterName: String? + + /// Number of days after which a calibration is considered stale. + public var staleDays: Int + + public init(staleDays: Int = 30) { + self.staleDays = staleDays + } + + /// Load and parse a `.cal` file. + public func load(url: URL) async throws { + let dataset = try CGATSParser.parse(url: url) + + guard let colorRep = dataset.colorRep, !colorRep.isEmpty else { + throw CalibrationStoreError.missingColorRep + } + + var data = CalibrationData() + data.colorRep = colorRep + data.descriptor = dataset.keywords["DESCRIPTOR"] + + if let createdString = dataset.keywords["CREATED"] { + let formatter = ISO8601DateFormatter() + data.created = formatter.date(from: createdString) + ?? Date(timeIntervalSince1970: 0) + } else { + let attrs = try? FileManager.default.attributesOfItem(atPath: url.path) + data.created = attrs?[.modificationDate] as? Date + } + + let limitKeys = ["MAX_TAC", "TOTAL_INK_LIMIT", "INK_LIMIT"] + for key in limitKeys { + if let raw = dataset.keywords[key], let value = Double(raw) { + data.maxTac = value + break + } + } + + for (key, raw) in dataset.keywords where key.hasPrefix("INK_LIMIT_") { + let suffix = key.dropFirst("INK_LIMIT_".count) + guard let channel = suffix.first, let value = Double(raw) else { continue } + data.inkLimits[channel] = value + } + + data.curves = try Self.extractCurves(from: dataset) + guard !data.curves.isEmpty else { + throw CalibrationStoreError.missingCurveData + } + + self.data = data + self.sourceURL = url + + // Printer name may live in a sidecar JSON. For now, fall back to the + // descriptor so callers have something to compare. + self.storedPrinterName = data.descriptor + } + + /// Store an explicit printer name (e.g. from a sidecar). + public func setPrinterName(_ name: String?) { + self.storedPrinterName = name + } + + /// True if the loaded calibration is older than `staleDays` or the + /// printer name does not match. + public func isStale(comparedTo currentPrinter: String? = nil) -> Bool { + guard let data else { return true } + + if let created = data.created, + let threshold = Calendar.current.date(byAdding: .day, value: staleDays, to: created), + Date() > threshold { + return true + } + + if let stored = storedPrinterName, !stored.isEmpty, + let current = currentPrinter, !current.isEmpty, + stored != current { + return true + } + + return false + } + + // MARK: - Internals + + private static func extractCurves(from dataset: CGATSDataset) throws -> [CalibrationCurve] { + // Argyll .cal files contain an INPUT_VALUE column and one or more + // per-channel output columns. Field names vary by COLOR_REP. + let outputFields = dataset.fieldNames.filter { $0 != "SAMPLE_ID" && $0 != "SAMPLE_LOC" && $0 != "INPUT_VALUE" } + guard !outputFields.isEmpty else { + // Older .cal files may only have one output column named OUTPUT_VALUE. + if dataset.fieldNames.contains("OUTPUT_VALUE") { + return [try buildCurve(channel: "K", field: "OUTPUT_VALUE", dataset: dataset)] + } + throw CalibrationStoreError.missingCurveData + } + + var curves = [CalibrationCurve]() + for field in outputFields { + let channel = field.first ?? "?" + let curve = try buildCurve(channel: channel, field: field, dataset: dataset) + curves.append(curve) + } + return curves + } + + private static func buildCurve( + channel: Character, + field: String, + dataset: CGATSDataset + ) throws -> CalibrationCurve { + var input = [Double]() + var output = [Double]() + + for sample in dataset.samples { + guard let inRaw = sample.values["INPUT_VALUE"] ?? sample.values[field], + let inVal = parseNumber(inRaw), + let outRaw = sample.values[field], + let outVal = parseNumber(outRaw) else { + throw CalibrationStoreError.parseFailed("Non-numeric curve value in \(field)") + } + input.append(inVal) + output.append(outVal) + } + + return CalibrationCurve(channel: channel, input: input, output: output) + } + + private static func parseNumber(_ raw: String) -> Double? { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + return formatter.number(from: raw)?.doubleValue + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Profile/CalibrationTargenArgs.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/CalibrationTargenArgs.swift new file mode 100644 index 0000000..e5d4b4a --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/CalibrationTargenArgs.swift @@ -0,0 +1,93 @@ +import Foundation + +/// Errors during calibration `targen` argv construction. +public enum CalibrationTargenArgError: LocalizedError, Equatable, Sendable { + case invalidBasename(String) + case invalidSteps(Int) + case invalidInkLimit(Int) + case invalidWhitePatches(Int) + + public var errorDescription: String? { + switch self { + case .invalidBasename(let name): + return "Invalid calibration basename: \(name)" + case .invalidSteps(let steps): + return "Calibration steps must be 11–51, got: \(steps)" + case .invalidInkLimit(let limit): + return "Calibration ink limit must be 200–400, got: \(limit)" + case .invalidWhitePatches(let count): + return "Calibration white patches cannot be negative, got: \(count)" + } + } +} + +/// Configuration for a calibration wedge `targen` run. +public struct CalibrationTargenConfig: Sendable, Equatable { + public var colourSpace: ColourSpace + public var steps: Int + public var whitePatches: Int + public var includeNeutralEmphasis: Bool + public var inkLimit: Int? + public var basename: String + public var workingDirectory: URL? + + public init( + colourSpace: ColourSpace = .rgb, + steps: Int = 21, + whitePatches: Int = 4, + includeNeutralEmphasis: Bool = false, + inkLimit: Int? = nil, + basename: String = "", + workingDirectory: URL? = nil + ) { + self.colourSpace = colourSpace + self.steps = steps + self.whitePatches = whitePatches + self.includeNeutralEmphasis = includeNeutralEmphasis + self.inkLimit = inkLimit + self.basename = basename + self.workingDirectory = workingDirectory + } +} + +/// Pure argv builder for the Stage 0 calibration `targen` chart. +/// +/// Produces a per-channel wedge with `-f 0` (no full-spread patches). +public enum CalibrationTargenArgs { + + /// Builds `targen -v -d {2|4} -s N -g N [-n N] -e W [-l TAC] -f 0 CAL_basename`. + public static func build(config: CalibrationTargenConfig) throws -> [String] { + let cleanBasename = try PathSecurity.sanitizeBasename(config.basename) + + guard (11...51).contains(config.steps) else { + throw CalibrationTargenArgError.invalidSteps(config.steps) + } + guard config.whitePatches >= 0 else { + throw CalibrationTargenArgError.invalidWhitePatches(config.whitePatches) + } + + var args: [String] = [ + "-v", + "-d", config.colourSpace.dFlagValue, + "-s", "\(config.steps)", + "-g", "\(config.steps)", + "-e", "\(config.whitePatches)", + "-f", "0" + ] + + if config.includeNeutralEmphasis { + args.append(contentsOf: ["-n", "\(config.steps)"]) + } + + if config.colourSpace == .cmyk, let inkLimit = config.inkLimit { + guard (200...400).contains(inkLimit) else { + throw CalibrationTargenArgError.invalidInkLimit(inkLimit) + } + args.append(contentsOf: ["-l", "\(inkLimit)"]) + } + + let calBasename = CalibrationIdentity.prefix(cleanBasename) + args.append(calBasename) + return args + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Profile/ColprofArgs.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/ColprofArgs.swift new file mode 100644 index 0000000..ba01450 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/ColprofArgs.swift @@ -0,0 +1,62 @@ +import Foundation + +/// Errors during `colprof` argv construction. +public enum ColprofArgError: LocalizedError, Equatable, Sendable { + case invalidBasename(String) + + public var errorDescription: String? { + switch self { + case .invalidBasename(let name): + return "Invalid colprof basename: \(name)" + } + } +} + +/// Pure argv builder for Argyll's `colprof` tool. +public enum ColprofArgs { + + /// Builds `colprof` argv per the Gronod fork protocol. + /// + /// Always `-v -a {algorithm} -q {quality}`. Optional flags are added + /// only when their fields are non-empty and meaningful. `-f` has + /// special handling for "none" (omit), "" (bare flag), and a custom + /// `.sp` path (passed through). `-c`/`-d` viewing conditions are + /// skipped when set to "none". + public static func build(config: ColprofConfig) throws -> [String] { + let cleanBasename = try PathSecurity.sanitizeBasename(config.basename) + + var args: [String] = ["-v"] + args.append(contentsOf: ["-a", config.algorithm]) + args.append(contentsOf: ["-q", config.quality]) + args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-t", config.intent)) + + if let fwa = config.fwa?.trimmingCharacters(in: .whitespaces) { + switch fwa.lowercased() { + case "none", "": + if fwa.isEmpty { + args.append("-f") + } + default: + args.append(contentsOf: ["-f", fwa]) + } + } + + args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-i", config.illuminant)) + args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-o", config.observer)) + + if let inputCond = config.inputViewingCond?.trimmingCharacters(in: .whitespaces), + !inputCond.isEmpty, inputCond.lowercased() != "none" { + args.append(contentsOf: ["-c", inputCond]) + } + if let outputCond = config.outputViewingCond?.trimmingCharacters(in: .whitespaces), + !outputCond.isEmpty, outputCond.lowercased() != "none" { + args.append(contentsOf: ["-d", outputCond]) + } + + args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-D", config.description)) + args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-C", config.copyright)) + + args.append(cleanBasename) + return args + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Profile/ColprofConfig.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/ColprofConfig.swift new file mode 100644 index 0000000..3cf0ec3 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/ColprofConfig.swift @@ -0,0 +1,45 @@ +import Foundation + +/// Configuration for an Argyll `colprof` run (issue #23, docs/16). +public struct ColprofConfig: Sendable, Equatable { + public var algorithm: String + public var quality: String + public var intent: String? + public var fwa: String? + public var illuminant: String? + public var observer: String? + public var inputViewingCond: String? + public var outputViewingCond: String? + public var description: String? + public var copyright: String? + public var basename: String + public var workingDirectory: URL? + + public init( + algorithm: String = "l", + quality: String = "m", + intent: String? = nil, + fwa: String? = nil, + illuminant: String? = nil, + observer: String? = nil, + inputViewingCond: String? = nil, + outputViewingCond: String? = nil, + description: String? = nil, + copyright: String? = nil, + basename: String, + workingDirectory: URL? = nil + ) { + self.algorithm = algorithm + self.quality = quality + self.intent = intent + self.fwa = fwa + self.illuminant = illuminant + self.observer = observer + self.inputViewingCond = inputViewingCond + self.outputViewingCond = outputViewingCond + self.description = description + self.copyright = copyright + self.basename = basename + self.workingDirectory = workingDirectory + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Profile/ColprofProgress.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/ColprofProgress.swift new file mode 100644 index 0000000..18ff27d --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/ColprofProgress.swift @@ -0,0 +1,30 @@ +import Foundation + +/// Classified `colprof` stdout progress milestone. +public enum ColprofProgress: Sendable, Equatable { + case gamutMapping + case fittingClut + case writingIcc + case unknown +} + +/// Parses `colprof` plaintext progress (docs/16 §6.3). +/// +/// The Gronod fork supports `-u` JSON, but ICCery v2.0 does not pass it. +/// Progress is therefore inferred from case-insensitive substring matches. +public enum ColprofProgressClassifier { + + public static func classify(line: String) -> ColprofProgress { + let lower = line.lowercased() + if lower.contains("gamut mapping") { + return .gamutMapping + } + if lower.contains("fitting") || lower.contains("clut") { + return .fittingClut + } + if lower.contains("writing") || lower.contains("icc profile") { + return .writingIcc + } + return .unknown + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Profile/DriftAlert.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/DriftAlert.swift new file mode 100644 index 0000000..382f053 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/DriftAlert.swift @@ -0,0 +1,49 @@ +import Foundation + +/// Computes a consecutive-breach warning from verification history. +/// +/// A drift alert triggers when the most recent chronologically consecutive +/// poor records form a run of at least two, and the first and last of that +/// run are on distinct UTC days or at least one hour apart. +public enum DriftAlert { + + /// Returns an alert message, or `nil` when no consecutive breach exists. + public static func compute(from records: [VerificationRecord]) -> String? { + // Work in chronological order. + let chronological = records.sorted { $0.timestamp < $1.timestamp } + + // Build the longest suffix of consecutive `.poor` records. + // Non-poor records break the run, so we stop at the first non-poor + // encountered from the end. + var run: [VerificationRecord] = [] + for record in chronological.reversed() { + if record.status == .poor { + run.insert(record, at: 0) + } else { + break + } + } + + guard run.count >= 2 else { return nil } + + let first = run.first! + let last = run.last! + + let sameDay = Calendar.utc.isDate(first.timestamp, inSameDayAs: last.timestamp) + let oneHour = last.timestamp.timeIntervalSince(first.timestamp) >= 3600 + + if !sameDay || oneHour { + return "Drift alert: poor results between \(first.id) and \(last.id)." + } + + return nil + } +} + +private extension Calendar { + static let utc: Calendar = { + var c = Calendar(identifier: .iso8601) + c.timeZone = TimeZone(identifier: "UTC")! + return c + }() +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Profile/IccgamutArgs.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/IccgamutArgs.swift new file mode 100644 index 0000000..a647039 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/IccgamutArgs.swift @@ -0,0 +1,29 @@ +import Foundation + +/// Errors during `iccgamut` argv construction. +public enum IccgamutArgError: LocalizedError, Equatable, Sendable { + case invalidProfileURL + + public var errorDescription: String? { + switch self { + case .invalidProfileURL: + return "iccgamut requires a valid profile path" + } + } +} + +/// Pure argv builder for Argyll's `iccgamut` tool. +public enum IccgamutArgs { + + /// Builds `iccgamut -v -d {density} {profilePath}`. + /// + /// The caller is responsible for ensuring `density` is a positive + /// integer. `-d` here is surface **density**, not a directory. + public static func build(config: IccgamutConfig) throws -> [String] { + let path = config.profileURL.path + guard !path.isEmpty else { throw IccgamutArgError.invalidProfileURL } + + let density = max(1, config.density) + return ["-v", "-d", "\(density)", path] + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Profile/IccgamutConfig.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/IccgamutConfig.swift new file mode 100644 index 0000000..069f004 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/IccgamutConfig.swift @@ -0,0 +1,12 @@ +import Foundation + +/// Configuration for an Argyll `iccgamut` run. +public struct IccgamutConfig: Sendable, Equatable { + public var profileURL: URL + public var density: Int + + public init(profileURL: URL, density: Int = 10) { + self.profileURL = profileURL + self.density = density + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Profile/InstallProfileConfig.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/InstallProfileConfig.swift new file mode 100644 index 0000000..c3d8859 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/InstallProfileConfig.swift @@ -0,0 +1,12 @@ +import Foundation + +/// Configuration for a profile installation. +public struct InstallProfileConfig: Sendable, Equatable { + public var sourceURL: URL + public var options: InstallProfileOptions + + public init(sourceURL: URL, options: InstallProfileOptions = InstallProfileOptions()) { + self.sourceURL = sourceURL + self.options = options + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Profile/InstallProfileOptions.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/InstallProfileOptions.swift new file mode 100644 index 0000000..81826c8 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/InstallProfileOptions.swift @@ -0,0 +1,31 @@ +import Foundation + +/// Collision policy for profile installation. +public enum ProfileCollisionPolicy: String, Sendable, Equatable, Codable, CaseIterable { + case overwrite + case rename + case cancel +} + +/// Options for installing a finished profile into the OS colour store. +public struct InstallProfileOptions: Sendable, Equatable, Codable { + public var forceOverwrite: Bool + public var preferSystem: Bool + public var collisionPolicy: ProfileCollisionPolicy + public var openColorPanel: Bool + public var calibrationNote: String? + + public init( + forceOverwrite: Bool = false, + preferSystem: Bool = false, + collisionPolicy: ProfileCollisionPolicy = .cancel, + openColorPanel: Bool = false, + calibrationNote: String? = nil + ) { + self.forceOverwrite = forceOverwrite + self.preferSystem = preferSystem + self.collisionPolicy = collisionPolicy + self.openColorPanel = openColorPanel + self.calibrationNote = calibrationNote + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Profile/InstallProfileResult.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/InstallProfileResult.swift new file mode 100644 index 0000000..31e78ff --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/InstallProfileResult.swift @@ -0,0 +1,30 @@ +import Foundation + +/// Result of installing a profile into the OS colour store. +public struct InstallProfileResult: Sendable, Equatable, Codable { + public var destPath: String + public var registered: Bool + public var overwritten: Bool + public var renamed: Bool + public var openedPanel: Bool + public var message: String + public var calibrationNote: String? + + public init( + destPath: String, + registered: Bool, + overwritten: Bool, + renamed: Bool, + openedPanel: Bool, + message: String, + calibrationNote: String? = nil + ) { + self.destPath = destPath + self.registered = registered + self.overwritten = overwritten + self.renamed = renamed + self.openedPanel = openedPanel + self.message = message + self.calibrationNote = calibrationNote + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Profile/PrintcalArgs.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/PrintcalArgs.swift new file mode 100644 index 0000000..8ac938b --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/PrintcalArgs.swift @@ -0,0 +1,107 @@ +import Foundation + +/// Errors during `printcal` argv construction. +public enum PrintcalArgError: LocalizedError, Equatable, Sendable { + case invalidBasename(String) + case invalidTotalInkLimit(Double) + case invalidPerChannelLimit(Character, Double) + case invalidOutputPath + + public var errorDescription: String? { + switch self { + case .invalidBasename(let name): + return "Invalid calibration basename: \(name)" + case .invalidTotalInkLimit(let limit): + return "Total ink limit must be positive, got: \(limit)" + case .invalidPerChannelLimit(let channel, let limit): + return "\(channel) channel limit must be 0–100, got: \(limit)" + case .invalidOutputPath: + return "Invalid .cal output path" + } + } +} + +/// Per-channel ink limit for `printcal -x{C|M|Y|K} pct`. +public struct PrintcalChannelLimit: Sendable, Equatable { + public let channel: Character + public let percent: Double + + public init(channel: Character, percent: Double) { + self.channel = channel + self.percent = percent + } +} + +/// Configuration for an Argyll `printcal` run. +public struct PrintcalConfig: Sendable, Equatable { + public var ti3Basename: String + public var workingDirectory: URL? + public var outputURL: URL + public var noInkLimit: Bool + public var verify: Bool + public var previousCalPath: String? + public var totalInkLimit: Double? + public var channelLimits: [PrintcalChannelLimit] + + public init( + ti3Basename: String, + workingDirectory: URL? = nil, + outputURL: URL, + noInkLimit: Bool = false, + verify: Bool = false, + previousCalPath: String? = nil, + totalInkLimit: Double? = nil, + channelLimits: [PrintcalChannelLimit] = [] + ) { + self.ti3Basename = ti3Basename + self.workingDirectory = workingDirectory + self.outputURL = outputURL + self.noInkLimit = noInkLimit + self.verify = verify + self.previousCalPath = previousCalPath + self.totalInkLimit = totalInkLimit + self.channelLimits = channelLimits + } +} + +/// Pure argv builder for Argyll's `printcal` tool. +/// +/// `printcal` is captured, not streamed. JS never sends `-u` (unapply). +public enum PrintcalArgs { + + /// Builds `printcal -v -e [-I] [-z] [-a previous.cal] [-m TAC] + /// [-xC pct]... -o out.cal CAL_basename`. + public static func build(config: PrintcalConfig) throws -> [String] { + let cleanBasename = try PathSecurity.sanitizeBasename(config.ti3Basename) + guard !cleanBasename.isEmpty else { + throw PrintcalArgError.invalidBasename(config.ti3Basename) + } + + var args: [String] = ["-v", "-e"] + + args.append(contentsOf: ArgsBuilder.flag("-I", when: config.noInkLimit)) + args.append(contentsOf: ArgsBuilder.flag("-z", when: config.verify)) + args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-a", config.previousCalPath)) + if let tac = config.totalInkLimit, tac > 0 { + args.append(contentsOf: ["-m", String(format: "%.1f", tac)]) + } else if let tac = config.totalInkLimit { + throw PrintcalArgError.invalidTotalInkLimit(tac) + } + + for limit in config.channelLimits { + guard (0...100).contains(limit.percent) else { + throw PrintcalArgError.invalidPerChannelLimit(limit.channel, limit.percent) + } + args.append(contentsOf: ["-x\(limit.channel)", String(format: "%.1f", limit.percent)]) + } + + guard !config.outputURL.path.isEmpty else { + throw PrintcalArgError.invalidOutputPath + } + args.append(contentsOf: ["-o", config.outputURL.path]) + + let calBasename = CalibrationIdentity.prefix(cleanBasename) + args.append(calBasename) + return args + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Profile/ProfcheckArgs.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/ProfcheckArgs.swift new file mode 100644 index 0000000..77771c8 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/ProfcheckArgs.swift @@ -0,0 +1,37 @@ +import Foundation + +/// Errors during `profcheck` argv construction. +public enum ProfcheckArgError: LocalizedError, Equatable, Sendable { + case missingTi3 + case missingIcc + case invalidTi3Path + + public var errorDescription: String? { + switch self { + case .missingTi3: + return "profcheck requires a .ti3 file" + case .missingIcc: + return "profcheck requires a profile (.icc/.icm)" + case .invalidTi3Path: + return "profcheck .ti3 path is invalid" + } + } +} + +/// Pure argv builder for Argyll's `profcheck` tool. +public enum ProfcheckArgs { + + /// Builds `profcheck -v -k -s -u {ti3Path} {iccPath}`. + /// + /// The `-u` here is the Gronod fork JSON report flag, not the + /// generic `-u` auto-fix that some Argyll builds use. + public static func build(config: ProfcheckConfig) throws -> [String] { + let ti3Path = config.ti3URL.path + let iccPath = config.iccURL.path + + guard !ti3Path.isEmpty else { throw ProfcheckArgError.missingTi3 } + guard !iccPath.isEmpty else { throw ProfcheckArgError.missingIcc } + + return ["-v", "-k", "-s", "-u", ti3Path, iccPath] + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Profile/ProfcheckConfig.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/ProfcheckConfig.swift new file mode 100644 index 0000000..eda6054 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/ProfcheckConfig.swift @@ -0,0 +1,12 @@ +import Foundation + +/// Configuration for an Argyll `profcheck` run. +public struct ProfcheckConfig: Sendable, Equatable { + public var ti3URL: URL + public var iccURL: URL + + public init(ti3URL: URL, iccURL: URL) { + self.ti3URL = ti3URL + self.iccURL = iccURL + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Profile/ProfcheckParser.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/ProfcheckParser.swift new file mode 100644 index 0000000..b8245bd --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/ProfcheckParser.swift @@ -0,0 +1,217 @@ +import Foundation + +/// Errors returned when `profcheck` output cannot be parsed. +public enum ProfcheckParserError: LocalizedError, Equatable, Sendable { + case unparseable + case jsonDecodingFailed + + public var errorDescription: String? { + switch self { + case .unparseable: + return "Could not parse profcheck report" + case .jsonDecodingFailed: + return "profcheck JSON report could not be decoded" + } + } +} + +/// Parses the mixed JSON/text output from `profcheck -v -k -s -u`. +public enum ProfcheckParser { + + /// Parsing order (issue #25): + /// 1. Patch count from `No of test patches = N`. + /// 2. JSON objects; prefer one with `event == "report"` or `*_de2000` keys. + /// 3. Legacy text: `Profile check complete, errors(CIEDE2000): max. = X, avg. = Y, RMS = Z`. + /// 4. Broad regex fallback. + /// 5. If no metrics found, return a report whose `warning` is set. + public static func parse(_ output: String) -> ProfcheckReport { + var report = ProfcheckReport() + + // 1. Patch count. + let patchRegex = try? NSRegularExpression( + pattern: #"No of test patches\s*=\s*(\d+)"#, + options: [.caseInsensitive] + ) + if let match = patchRegex?.firstMatch( + in: output, + options: [], + range: NSRange(output.startIndex..., in: output) + ), let range = Range(match.range(at: 1), in: output) { + let count = Int(output[range]) + report.patchCount = count + } + + // 2. JSON objects. + let jsonObjects = extractJSONObjects(from: output) + for object in jsonObjects { + if let event = object["event"] as? String, event == "report" { + if let parsed = metrics(from: object) { + apply(metrics: parsed, to: &report) + return report + } + } + if hasMetricKeys(object) { + if let parsed = metrics(from: object) { + apply(metrics: parsed, to: &report) + return report + } + } + } + + // 3. Legacy text. + let textRegex = try? NSRegularExpression( + pattern: #"Profile check complete, errors\(CIEDE2000\): max\.\s*=\s*([0-9.]+),\s*avg\.\s*=\s*([0-9.]+),\s*RMS\s*=\s*([0-9.]+)"#, + options: [.caseInsensitive] + ) + if let match = textRegex?.firstMatch( + in: output, + options: [], + range: NSRange(output.startIndex..., in: output) + ) { + let numbers = (1...3).compactMap { i -> Double? in + guard let range = Range(match.range(at: i), in: output) else { return nil } + return Double(output[range]) + } + if numbers.count == 3 { + report.maxDE = numbers[0] + report.avgDE = numbers[1] + report.rmsDE = numbers[2] + report.status = report.avgDE.map { VerificationStatus.from(avgDE: $0) } + return report + } + } + + // 4. Broad regex fallback. + if let fallback = parseRegexFallback(output) { + var merged = fallback + merged.patchCount = report.patchCount + return merged + } + + // 5. Unparseable. + report.warning = "profcheck output did not contain a recognisable report." + return report + } + + // MARK: - JSON extraction + + private static func extractJSONObjects(from output: String) -> [[String: Any]] { + var objects: [[String: Any]] = [] + var start: String.Index? + var depth = 0 + + for index in output.indices { + let char = output[index] + if char == "{" { + if depth == 0 { + start = index + } + depth += 1 + } else if char == "}" { + if depth > 0 { + depth -= 1 + if depth == 0, let start = start { + let jsonString = String(output[start...index]) + if let data = jsonString.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + objects.append(object) + } + } + } + } + } + + return objects + } + + private static func hasMetricKeys(_ object: [String: Any]) -> Bool { + let keys = [ + "avg_de", "avg_de2000", + "peak_de", "peak_de2000", "max_de", + "rms", "rms_de" + ] + return keys.contains { object[$0] != nil } + } + + private struct Metrics { + var avg: Double? + var max: Double? + var rms: Double? + } + + private static func metrics(from object: [String: Any]) -> Metrics? { + var m = Metrics() + m.avg = doubleValue(for: "avg_de2000", in: object) + ?? doubleValue(for: "avg_de", in: object) + m.max = doubleValue(for: "peak_de2000", in: object) + ?? doubleValue(for: "peak_de", in: object) + ?? doubleValue(for: "max_de", in: object) + ?? doubleValue(for: "max_de2000", in: object) + m.rms = doubleValue(for: "rms", in: object) + ?? doubleValue(for: "rms_de", in: object) + ?? doubleValue(for: "rms_de2000", in: object) + + guard m.avg != nil || m.max != nil || m.rms != nil else { return nil } + return m + } + + private static func doubleValue(for key: String, in object: [String: Any]) -> Double? { + if let number = object[key] as? Double { return number } + if let number = object[key] as? NSNumber { return number.doubleValue } + if let string = object[key] as? String { return Double(string) } + return nil + } + + private static func apply(metrics: Metrics, to report: inout ProfcheckReport) { + report.avgDE = metrics.avg + report.maxDE = metrics.max + report.rmsDE = metrics.rms + if let avg = metrics.avg { + report.status = VerificationStatus.from(avgDE: avg) + } + } + + // MARK: - Regex fallback + + private static func parseRegexFallback(_ output: String) -> ProfcheckReport? { + var report = ProfcheckReport() + + let avgRegex = try? NSRegularExpression( + pattern: #"(?:avg\.?|average)\s*(?:=|:)\s*([0-9.]+)"#, + options: [.caseInsensitive] + ) + let maxRegex = try? NSRegularExpression( + pattern: #"(?:max\.?|peak|maximum)\s*(?:=|:)\s*([0-9.]+)"#, + options: [.caseInsensitive] + ) + let rmsRegex = try? NSRegularExpression( + pattern: #"(?:rms)\s*(?:=|:)\s*([0-9.]+)"#, + options: [.caseInsensitive] + ) + + report.avgDE = firstDouble(from: output, regex: avgRegex) + report.maxDE = firstDouble(from: output, regex: maxRegex) + report.rmsDE = firstDouble(from: output, regex: rmsRegex) + + guard report.avgDE != nil || report.maxDE != nil || report.rmsDE != nil else { + return nil + } + + if let avg = report.avgDE { + report.status = VerificationStatus.from(avgDE: avg) + } + + return report + } + + private static func firstDouble(from output: String, regex: NSRegularExpression?) -> Double? { + guard let regex = regex, + let match = regex.firstMatch( + in: output, + options: [], + range: NSRange(output.startIndex..., in: output) + ), + let range = Range(match.range(at: 1), in: output) else { return nil } + return Double(output[range]) + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Profile/ProfcheckReport.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/ProfcheckReport.swift new file mode 100644 index 0000000..7e14370 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/ProfcheckReport.swift @@ -0,0 +1,31 @@ +import Foundation + +/// Parsed result from a `profcheck -u` run. +public struct ProfcheckReport: Sendable, Equatable, Codable { + public var patchCount: Int? + public var avgDE: Double? + public var maxDE: Double? + public var rmsDE: Double? + public var status: VerificationStatus? + public var warning: String? + + public var isValid: Bool { + avgDE != nil && maxDE != nil && rmsDE != nil + } + + public init( + patchCount: Int? = nil, + avgDE: Double? = nil, + maxDE: Double? = nil, + rmsDE: Double? = nil, + status: VerificationStatus? = nil, + warning: String? = nil + ) { + self.patchCount = patchCount + self.avgDE = avgDE + self.maxDE = maxDE + self.rmsDE = rmsDE + self.status = status + self.warning = warning + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Profile/ProfileInstaller.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/ProfileInstaller.swift new file mode 100644 index 0000000..7cd22bc --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/ProfileInstaller.swift @@ -0,0 +1,238 @@ +import Foundation + +/// Errors thrown by `ProfileInstaller`. +public enum ProfileInstallError: LocalizedError, Equatable, Sendable { + case unsafeStem(String) + case sourceMissing + case sourceNotProfile + case sourceTooSmall + case systemRequiresAdminRights + case copyFailed(String) + case cancelled + + public var errorDescription: String? { + switch self { + case .unsafeStem(let stem): + return "Profile name contains unsafe characters: \(stem)" + case .sourceMissing: + return "Source profile does not exist" + case .sourceNotProfile: + return "Source must be a .icc or .icm file" + case .sourceTooSmall: + return "Source file is too small to be a valid profile" + case .systemRequiresAdminRights: + return "Installing to /Library/ColorSync/Profiles requires administrator rights" + case .copyFailed(let reason): + return "Could not install profile: \(reason)" + case .cancelled: + return "Install cancelled" + } + } +} + +/// Installs an ICC/ICM profile into the OS colour store. +public enum ProfileInstaller { + + /// Resolves the destination URL that `install` would write to for the + /// given source and options, without copying anything. Useful for + /// collision previews in the UI. + public static func resolveDestinationURL( + for config: InstallProfileConfig, + fileManager: FileManager = .default + ) throws -> URL { + let sourceURL = config.sourceURL + let ext = sourceURL.pathExtension.lowercased() + guard ext == "icc" || ext == "icm" else { + throw ProfileInstallError.sourceNotProfile + } + + try validateSourceURL(sourceURL) + + let destDir = destinationDirectory(for: config.options, fileManager: fileManager) + return destDir.appendingPathComponent(sourceURL.lastPathComponent) + } + + /// Installs `sourceURL` into `~/Library/ColorSync/Profiles` or + /// `/Library/ColorSync/Profiles`. Always copies, never moves. + public static func install( + config: InstallProfileConfig, + fileManager: FileManager = .default + ) throws -> InstallProfileResult { + let fm = fileManager + + // Source validation. + let sourceURL = config.sourceURL + guard fm.fileExists(atPath: sourceURL.path) else { + throw ProfileInstallError.sourceMissing + } + + let ext = sourceURL.pathExtension.lowercased() + guard ext == "icc" || ext == "icm" else { + throw ProfileInstallError.sourceNotProfile + } + + let attrs = try? fm.attributesOfItem(atPath: sourceURL.path) + let size = attrs?[.size] as? UInt64 ?? 0 + guard size >= 128 else { + throw ProfileInstallError.sourceTooSmall + } + + try validateSourceURL(sourceURL) + + // Destination directory. + let destURL = try resolveDestinationURL(for: config, fileManager: fm) + try? fm.createDirectory( + at: destURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + + // Collision resolution. + let destExists = fm.fileExists(atPath: destURL.path) + if destExists { + if config.options.forceOverwrite { + return try performInstall( + from: sourceURL, + to: destURL, + options: config.options, + fileManager: fm, + overwritten: true, + renamed: false + ) + } else if config.options.collisionPolicy == .rename { + let epoch = Int(Date().timeIntervalSince1970) + let stem = sourceURL.deletingPathExtension().lastPathComponent + let renamedURL = destURL.deletingLastPathComponent() + .appendingPathComponent("\(stem)-\(epoch).\(ext)") + return try performInstall( + from: sourceURL, + to: renamedURL, + options: config.options, + fileManager: fm, + overwritten: false, + renamed: true + ) + } else if config.options.collisionPolicy == .cancel { + throw ProfileInstallError.cancelled + } else { + // Default with askBeforeOverwrite — the app must decide. + throw ProfileInstallError.copyFailed("destination already exists") + } + } + + return try performInstall( + from: sourceURL, + to: destURL, + options: config.options, + fileManager: fm, + overwritten: false, + renamed: false + ) + } + + // MARK: - Private helpers + + private static func validateSourceURL(_ sourceURL: URL) throws { + let path = sourceURL.path + let stem = sourceURL.deletingPathExtension().lastPathComponent + + // Reject backslashes anywhere in the path. + guard !path.contains("\\") else { + throw ProfileInstallError.unsafeStem(stem) + } + + // Reject any path component that is literally "." or "..". + // This allows names like "foo..bar" while blocking real traversal. + for component in sourceURL.pathComponents { + if component == "." || component == ".." { + throw ProfileInstallError.unsafeStem(stem) + } + } + } + + private static func destinationDirectory( + for options: InstallProfileOptions, + fileManager: FileManager + ) -> URL { + if options.preferSystem { + return URL(fileURLWithPath: "/Library/ColorSync/Profiles") + } else { + return fileManager.homeDirectoryForCurrentUser + .appendingPathComponent("Library/ColorSync/Profiles") + } + } + + private static func performInstall( + from sourceURL: URL, + to destURL: URL, + options: InstallProfileOptions, + fileManager: FileManager, + overwritten: Bool, + renamed: Bool + ) throws -> InstallProfileResult { + let fm = fileManager + let tmpURL = destURL.appendingPathExtension("iccery-install.tmp") + + // Remove stale tmp. + try? fm.removeItem(at: tmpURL) + + do { + try fm.copyItem(at: sourceURL, to: tmpURL) + + let attrs = try? fm.attributesOfItem(atPath: tmpURL.path) + let tmpSize = attrs?[.size] as? UInt64 ?? 0 + guard tmpSize >= 128 else { + try? fm.removeItem(at: tmpURL) + throw ProfileInstallError.sourceTooSmall + } + + if fm.fileExists(atPath: destURL.path) { + _ = try fm.replaceItemAt(destURL, withItemAt: tmpURL) + } else { + try fm.moveItem(at: tmpURL, to: destURL) + } + } catch { + try? fm.removeItem(at: tmpURL) + + // Surface a clear admin-rights hint when writing to system. + if destURL.path.hasPrefix("/Library/") && !fm.fileExists(atPath: destURL.path) { + throw ProfileInstallError.systemRequiresAdminRights + } + + if let installError = error as? ProfileInstallError { + throw installError + } + throw ProfileInstallError.copyFailed(error.localizedDescription) + } + + let registered = fm.fileExists(atPath: destURL.path) + + var openedPanel = false + if options.openColorPanel { + openedPanel = openColorSyncUtility() + } + + return InstallProfileResult( + destPath: destURL.path, + registered: registered, + overwritten: overwritten, + renamed: renamed, + openedPanel: openedPanel, + message: "Profile installed to \(destURL.path)", + calibrationNote: options.calibrationNote + ) + } + + private static func openColorSyncUtility() -> Bool { + let task = Process() + task.launchPath = "/usr/bin/open" + task.arguments = ["-a", "ColorSync Utility"] + task.environment = ["ARGYLL_NOT_INTERACTIVE": "1"] + do { + try task.run() + task.waitUntilExit() + } catch { + return false + } + return true + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Profile/VerificationHistoryStore.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/VerificationHistoryStore.swift new file mode 100644 index 0000000..707ee7d --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/VerificationHistoryStore.swift @@ -0,0 +1,122 @@ +import Foundation + +/// Persistence for `VerificationRecord` entries. +public actor VerificationHistoryStore { + + /// Default cap. + public static let defaultCapacity = 1000 + + /// Path to the JSON store. + public let url: URL + + /// In-memory cache, kept in sync with disk. + private var records: [VerificationRecord] = [] + + private let capacity: Int + private let fileStore: JSONFileStore<[VerificationRecord]> + + public init( + url: URL = AppPaths.appDataDir.appendingPathComponent("verification_history.json"), + capacity: Int = defaultCapacity + ) { + self.url = url + self.capacity = capacity + self.fileStore = JSONFileStore( + fileURL: url, + corrupt: .throwCorrupt, + defaultValue: { [] }, + dateEncoding: .iso8601, + dateDecoding: .iso8601 + ) + } + + /// Loads records from disk. Returns the existing cache if already loaded. + /// + /// Throws when the file exists but cannot be parsed; the existing file + /// is never overwritten in that case. + public func load() throws -> [VerificationRecord] { + guard records.isEmpty else { return records } + guard FileManager.default.fileExists(atPath: url.path) else { return [] } + records = try fileStore.load() + return records + } + + /// Returns all records. + public func all() -> [VerificationRecord] { + records + } + + /// Records matching the optional printer filter. + public func filtered(by printer: String?) -> [VerificationRecord] { + guard let printer = printer, !printer.isEmpty else { return records } + return records.filter { $0.printerName == printer } + } + + /// Appends a record, trims to capacity, and writes atomically. + /// + /// Loads the existing history first and propagates any load error so an + /// unparseable file is never overwritten. + @discardableResult + public func append(_ record: VerificationRecord) throws -> [VerificationRecord] { + try load() + + var updated = records + updated.append(record) + if updated.count > capacity { + updated.sort { $0.timestamp < $1.timestamp } + updated = Array(updated.suffix(capacity)) + } + + try write(updated) + records = updated + return updated + } + + /// Removes all history and updates disk. + /// + /// Loads the existing history first and propagates any load error so an + /// unparseable file is never overwritten. + public func clear() throws { + try load() + try write([]) + records = [] + } + + /// RFC-4180 CSV export. + public func exportCSV() -> String { + var lines: [String] = [ + csvRow(["id", "profile_name", "printer_name", "avg_de", "max_de", "rms_de", "patch_count", "status", "timestamp"]) + ] + + for record in records { + lines.append(csvRow([ + record.id, + record.profileName, + record.printerName, + String(record.avgDE), + String(record.maxDE), + String(record.rmsDE), + String(record.patchCount), + record.status.rawValue, + ISO8601DateFormatter().string(from: record.timestamp) + ])) + } + + return lines.joined(separator: "\n") + "\n" + } + + /// Writes `records` through a temp file and rename. + private func write(_ records: [VerificationRecord]) throws { + try fileStore.save(records) + } + + private func csvRow(_ fields: [String]) -> String { + fields.map { field in + let escaped = field.replacingOccurrences(of: "\"", with: "\"\"") + if field.contains(",") || field.contains("\"") || field.contains("\n") || field.contains("\r") { + return "\"\(escaped)\"" + } + return escaped + }.joined(separator: ",") + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Profile/VerificationRecord.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/VerificationRecord.swift new file mode 100644 index 0000000..238991d --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/VerificationRecord.swift @@ -0,0 +1,36 @@ +import Foundation + +/// A single entry in the verification history store. +public struct VerificationRecord: Sendable, Equatable, Codable, Identifiable { + public var id: String + public var profileName: String + public var printerName: String + public var avgDE: Double + public var maxDE: Double + public var rmsDE: Double + public var patchCount: Int + public var status: VerificationStatus + public var timestamp: Date + + public init( + id: String, + profileName: String, + printerName: String, + avgDE: Double, + maxDE: Double, + rmsDE: Double, + patchCount: Int, + status: VerificationStatus, + timestamp: Date + ) { + self.id = id + self.profileName = profileName + self.printerName = printerName + self.avgDE = avgDE + self.maxDE = maxDE + self.rmsDE = rmsDE + self.patchCount = patchCount + self.status = status + self.timestamp = timestamp + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Profile/VerificationStatus.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/VerificationStatus.swift new file mode 100644 index 0000000..7791e46 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/VerificationStatus.swift @@ -0,0 +1,32 @@ +import Foundation + +/// ICCery quality band for a verification run. +/// +/// Bands are on the **average** ΔE₀₀: +/// - < 1.0 → Excellent +/// - < 2.0 → Good +/// - < 3.5 → Acceptable +/// - ≥ 3.5 → Warning +public enum VerificationStatus: String, Sendable, Equatable, Codable, CaseIterable { + case excellent + case good + case acceptable + case poor + + public var displayName: String { + switch self { + case .excellent: return "Excellent" + case .good: return "Good" + case .acceptable: return "Acceptable" + case .poor: return "Warning" + } + } + + /// Returns the quality band for the given average ΔE₀₀. + public static func from(avgDE: Double) -> VerificationStatus { + if avgDE < 1.0 { return .excellent } + if avgDE < 2.0 { return .good } + if avgDE < 3.5 { return .acceptable } + return .poor + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Settings/AppSettings.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Settings/AppSettings.swift new file mode 100644 index 0000000..6e81bf9 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Settings/AppSettings.swift @@ -0,0 +1,251 @@ +import Foundation + +/// Legacy M1 preset shape (`name` + opaque string dictionary). Retained +/// solely to decode and migrate pre-M2 `settings.json`; new code uses +/// `ProfilingPreset` (docs/22 §ProfilingPreset). +public struct CustomPreset: Codable, Equatable, Sendable { + public var name: String + /// Opaque per-stage form values — keyed by field id. + public var values: [String: String] + + public init(name: String, values: [String: String] = [:]) { + self.name = name + self.values = values + } +} + +/// Where `install_profile` drops finished profiles (docs/22). +public enum InstallLocation: String, Codable, Sendable, CaseIterable { + case user + case system +} + +/// `settings.json` model (docs/22). snake_case keys match the v1 file +/// so field names stay identical across rewrites. +/// +/// Decoding is tolerant: missing keys take documented defaults and each +/// `custom_presets` element is tried as a typed `ProfilingPreset` first +/// and as a legacy M1 `CustomPreset` second — a malformed entry never +/// drops the rest of the array (preset migration, issue #11). +public struct AppSettings: Codable, Equatable, Sendable { + + /// User override for Argyll binaries; `nil` → bundled sidecars. + public var argyllBinaryDir: String? + + /// Stored but **never applied to argv** — Stage 2's own instrument + /// select is the live source (docs/04 §0.1). + public var defaultInstrument: String? + + /// `nil` → `.debug` in debug builds, `.info` in release (#158). + public var logLevel: LogLevel? + + public var deltaEGoodMax: Double + public var deltaEWarningMax: Double + public var customPresets: [ProfilingPreset] + public var enableI1Pro2Leds: Bool + public var calibrationStaleDays: Int + public var defaultInstallLocation: InstallLocation + public var askBeforeOverwriteProfile: Bool + public var openColorPanelAfterInstall: Bool + + public init( + argyllBinaryDir: String? = nil, + defaultInstrument: String? = nil, + logLevel: LogLevel? = nil, + deltaEGoodMax: Double = 2.0, + deltaEWarningMax: Double = 5.0, + customPresets: [ProfilingPreset] = [], + enableI1Pro2Leds: Bool = false, + calibrationStaleDays: Int = 30, + defaultInstallLocation: InstallLocation = .user, + askBeforeOverwriteProfile: Bool = true, + openColorPanelAfterInstall: Bool = false + ) { + self.argyllBinaryDir = argyllBinaryDir + self.defaultInstrument = defaultInstrument + self.logLevel = logLevel + self.deltaEGoodMax = deltaEGoodMax + self.deltaEWarningMax = deltaEWarningMax + self.customPresets = customPresets + self.enableI1Pro2Leds = enableI1Pro2Leds + self.calibrationStaleDays = calibrationStaleDays + self.defaultInstallLocation = defaultInstallLocation + self.askBeforeOverwriteProfile = askBeforeOverwriteProfile + self.openColorPanelAfterInstall = openColorPanelAfterInstall + } + + public static let `default` = AppSettings() + + /// Effective log level — runtime state, not just persistence (#158). + public var effectiveLogLevel: LogLevel { + if let logLevel { return logLevel } + #if DEBUG + return .debug + #else + return .info + #endif + } + + enum CodingKeys: String, CodingKey { + case argyllBinaryDir = "argyll_binary_dir" + case defaultInstrument = "default_instrument" + case logLevel = "log_level" + case deltaEGoodMax = "delta_e_good_max" + case deltaEWarningMax = "delta_e_warning_max" + case customPresets = "custom_presets" + case enableI1Pro2Leds = "enable_i1pro2_leds" + case calibrationStaleDays = "calibration_stale_days" + case defaultInstallLocation = "default_install_location" + case askBeforeOverwriteProfile = "ask_before_overwrite_profile" + case openColorPanelAfterInstall = "open_color_panel_after_install" + } + + /// One element of `custom_presets`: typed first, legacy M1 second. + private enum AnyPreset: Decodable { + case typed(ProfilingPreset) + case legacy(CustomPreset) + + init(from decoder: Decoder) throws { + if let p = try? ProfilingPreset(from: decoder) { + self = .typed(p) + return + } + self = .legacy(try CustomPreset(from: decoder)) + } + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + let d = AppSettings.default + argyllBinaryDir = try c.decodeIfPresent(String.self, forKey: .argyllBinaryDir) ?? d.argyllBinaryDir + defaultInstrument = try c.decodeIfPresent(String.self, forKey: .defaultInstrument) ?? d.defaultInstrument + logLevel = try c.decodeIfPresent(LogLevel.self, forKey: .logLevel) ?? d.logLevel + deltaEGoodMax = try c.decodeIfPresent(Double.self, forKey: .deltaEGoodMax) ?? d.deltaEGoodMax + deltaEWarningMax = try c.decodeIfPresent(Double.self, forKey: .deltaEWarningMax) ?? d.deltaEWarningMax + enableI1Pro2Leds = try c.decodeIfPresent(Bool.self, forKey: .enableI1Pro2Leds) ?? d.enableI1Pro2Leds + calibrationStaleDays = try c.decodeIfPresent(Int.self, forKey: .calibrationStaleDays) ?? d.calibrationStaleDays + defaultInstallLocation = try c.decodeIfPresent(InstallLocation.self, forKey: .defaultInstallLocation) ?? d.defaultInstallLocation + askBeforeOverwriteProfile = try c.decodeIfPresent(Bool.self, forKey: .askBeforeOverwriteProfile) ?? d.askBeforeOverwriteProfile + openColorPanelAfterInstall = try c.decodeIfPresent(Bool.self, forKey: .openColorPanelAfterInstall) ?? d.openColorPanelAfterInstall + + // Per-element decode: typed presets win; a legacy M1 shape + // ({"name","values"}) migrates; unconvertible entries are + // skipped so one bad record never drops the array. + let elements = (try? c.decodeIfPresent( + [FailableDecodable].self, forKey: .customPresets + )) ?? nil + var migrated: [ProfilingPreset] = [] + for (index, element) in (elements ?? []).enumerated() { + switch element.value { + case .typed(let preset): + migrated.append(preset) + case .legacy(let legacy): + if let converted = ProfilingPreset(migrating: legacy, index: index) { + migrated.append(converted) + } else { + AppLogger(category: "settings").warn( + "Skipped unmigratable legacy preset: \(legacy.name)" + ) + } + case .none: + AppLogger(category: "settings").warn( + "Skipped malformed preset entry at index \(index)" + ) + } + } + customPresets = migrated + } + + /// UI-facing validation. Strings are part of the contract (issue #5). + public static let errorNegativeDeltaE = "ΔE thresholds cannot be negative." + public static let errorThresholdOrder = + "Good ΔE threshold must be strictly less than the warning threshold." + + /// All validation errors, in declaration order. Empty = valid. + public func validate() -> [String] { + var errors: [String] = [] + if deltaEGoodMax < 0 || deltaEWarningMax < 0 { + errors.append(Self.errorNegativeDeltaE) + } + if deltaEGoodMax >= deltaEWarningMax { + errors.append(Self.errorThresholdOrder) + } + return errors + } + + public var isValid: Bool { validate().isEmpty } +} + +extension ProfilingPreset { + + /// Migrates a legacy M1 `CustomPreset` (`name` + string values) to + /// the typed schema. Known keys are coerced; anything else is + /// ignored. Returns `nil` only when the name is unusable — a + /// deterministic `custom-{index}-{slug}` id is always produced. + init?(migrating legacy: CustomPreset, index: Int) { + let trimmedName = legacy.name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedName.isEmpty else { return nil } + + let v = legacy.values + func int(_ key: String) -> Int? { + v[key].flatMap { Int($0.trimmingCharacters(in: .whitespaces)) } + } + func double(_ key: String) -> Double? { + v[key].flatMap { Double($0.trimmingCharacters(in: .whitespaces)) } + } + func bool(_ key: String) -> Bool? { + v[key].flatMap { s in + switch s.trimmingCharacters(in: .whitespaces).lowercased() { + case "true", "1", "yes": return true + case "false", "0", "no": return false + default: return nil + } + } + } + func string(_ key: String) -> String? { + v[key].map { $0.trimmingCharacters(in: .whitespaces) } + .flatMap { $0.isEmpty ? nil : $0 } + } + + let slug = trimmedName.lowercased() + .map { $0.isLetter || $0.isNumber ? $0 : "-" } + .reduce(into: "") { $0.append($1) } + + self.init( + id: "custom-\(index)-\(slug)", + name: trimmedName, + description: string("description") ?? "", + colourSpace: string("colour_space")?.lowercased() ?? "rgb", + patchCount: int("patch_count") ?? 800, + whitePatches: int("white_patches") ?? 4, + blackPatches: int("black_patches") ?? 4, + greySteps: int("grey_steps"), + singleChannelSteps: int("single_channel_steps"), + neutralSteps: int("neutral_steps"), + neutralConcentration: double("neutral_concentration"), + preconditioningProfile: string("preconditioning_profile"), + ofpsHighQuality: bool("ofps_high_quality"), + ofpsAdaptation: double("ofps_adaptation"), + fullSpreadAlgorithm: string("full_spread_algorithm"), + totalInkLimit: int("total_ink_limit"), + darkEmphasis: double("dark_emphasis"), + devicePower: double("device_power"), + instrument: string("instrument") ?? "i1", + pageSize: string("page_size") ?? "A4", + bitDepth: int("bit_depth") ?? 8, + dpi: int("dpi") ?? 300, + randomSeed: int("random_seed"), + noRandomize: bool("no_randomize"), + calibrationFile: string("calibration_file"), + applyCalibration: bool("apply_calibration"), + colprofAlgorithm: string("colprof_algorithm"), + colprofQuality: string("colprof_quality"), + colprofIntent: string("colprof_intent"), + colprofFwa: string("colprof_fwa"), + colprofIlluminant: string("colprof_illuminant"), + colprofObserver: string("colprof_observer"), + colprofInputViewingCond: string("colprof_input_viewing_cond"), + colprofOutputViewingCond: string("colprof_output_viewing_cond") + ) + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Settings/PresetCatalog.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Settings/PresetCatalog.swift new file mode 100644 index 0000000..f404331 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Settings/PresetCatalog.swift @@ -0,0 +1,118 @@ +import Foundation + +/// Built-in presets shipped with the app (docs/22 §Built-in presets). +/// All four: instrument `i1`, FWA `D50`, random seed `1`, +/// `no_randomize == false`, colprof algorithm `l`. +/// +/// Built-ins cannot be deleted; custom presets overlay by `id`. +public enum PresetCatalog { + + /// `preset-std-rgb` — Standard RGB Photo (800 patches). + public static let standardRGB = ProfilingPreset( + id: "preset-std-rgb", + name: "Standard RGB Photo (800 patches)", + description: "Everyday RGB driver printing — 800 patches on A4 at 300 dpi.", + colourSpace: "rgb", + patchCount: 800, + whitePatches: 4, + blackPatches: 4, + instrument: "i1", + pageSize: "A4", + bitDepth: 8, + dpi: 300, + randomSeed: 1, + noRandomize: false, + colprofAlgorithm: "l", + colprofQuality: "m", + colprofFwa: "D50" + ) + + /// `preset-hq-cmyk` — High-Gamut CMYK Proofing (1500 patches). + public static let highQualityCMYK = ProfilingPreset( + id: "preset-hq-cmyk", + name: "High-Gamut CMYK Proofing (1500 patches)", + description: "RIP-driven CMYK output — 1500 patches on A3, 16-bit, 320% ink limit.", + colourSpace: "cmyk", + patchCount: 1500, + whitePatches: 4, + blackPatches: 8, + totalInkLimit: 320, + instrument: "i1", + pageSize: "A3", + bitDepth: 16, + dpi: 300, + randomSeed: 1, + noRandomize: false, + colprofAlgorithm: "l", + colprofQuality: "h", + colprofFwa: "D50" + ) + + /// `preset-draft-rgb` — Fast RGB Draft (400 patches, **150 dpi**). + public static let draftRGB = ProfilingPreset( + id: "preset-draft-rgb", + name: "Fast RGB Draft (400 patches)", + description: "Quick sanity check — 400 patches on A4 at 150 dpi.", + colourSpace: "rgb", + patchCount: 400, + whitePatches: 4, + blackPatches: 4, + instrument: "i1", + pageSize: "A4", + bitDepth: 8, + dpi: 150, + randomSeed: 1, + noRandomize: false, + colprofAlgorithm: "l", + colprofQuality: "l", + colprofFwa: "D50" + ) + + /// `preset-ultra-rgb` — Ultra Precision RGB (2500 patches, `-G`). + public static let ultraRGB = ProfilingPreset( + id: "preset-ultra-rgb", + name: "Ultra Precision RGB (2500 patches)", + description: "Maximum coverage — 2500 patches on A3, 16-bit, OFPS high quality.", + colourSpace: "rgb", + patchCount: 2500, + whitePatches: 6, + blackPatches: 6, + ofpsHighQuality: true, + instrument: "i1", + pageSize: "A3", + bitDepth: 16, + dpi: 300, + randomSeed: 1, + noRandomize: false, + colprofAlgorithm: "l", + colprofQuality: "u", + colprofFwa: "D50" + ) + + public static let builtIns: [ProfilingPreset] = [ + standardRGB, highQualityCMYK, draftRGB, ultraRGB, + ] + + public static let builtInIDs: Set = Set(builtIns.map(\.id)) + + public static func isBuiltIn(_ id: String) -> Bool { + builtInIDs.contains(id) + } + + /// Built-ins plus custom presets, with custom entries overlaying by + /// `id` (a custom preset with a built-in id replaces that entry in + /// place — the built-in is still not deletable). + public static func all(custom: [ProfilingPreset]) -> [ProfilingPreset] { + var result = builtIns + var seen = builtInIDs + for custom in custom { + if let idx = result.firstIndex(where: { $0.id == custom.id }) { + result[idx] = custom + } else if !seen.contains(custom.id) { + result.append(custom) + seen.insert(custom.id) + } + } + return result + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Settings/PresetMapping.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Settings/PresetMapping.swift new file mode 100644 index 0000000..f963ea6 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Settings/PresetMapping.swift @@ -0,0 +1,225 @@ +import Foundation + +extension TargenConfig { + /// Stage 1 fields of a profiling preset. Optional advanced flags stay + /// `nil` when the preset omitted them so argv builders skip the flag. + public init(preset: ProfilingPreset, basename: String, workingDirectory: URL?) { + self.init( + colourSpace: preset.colourSpace.lowercased() == "cmyk" ? .cmyk : .rgb, + patchCount: preset.patchCount, + whitePatches: preset.whitePatches, + blackPatches: preset.blackPatches, + greySteps: preset.greySteps, + singleChannelSteps: preset.singleChannelSteps, + neutralSteps: preset.neutralSteps, + neutralConcentration: preset.neutralConcentration, + preconditioningProfile: preset.preconditioningProfile, + // An explicit `false` is preserved — distinguishable from a + // missing key; `-G` is only emitted for `true` (#82). + ofpsHighQuality: preset.ofpsHighQuality, + ofpsAdaptation: preset.ofpsAdaptation, + fullSpreadAlgorithm: preset.fullSpreadAlgorithm.flatMap { FullSpreadAlgorithm(presetValue: $0) }.flatMap { $0 == .ofps ? nil : $0 }, + totalInkLimit: preset.totalInkLimit, + darkEmphasis: preset.darkEmphasis, + devicePower: preset.devicePower, + basename: basename, + workingDirectory: workingDirectory + ) + } +} + +extension PrinttargConfig { + public init( + preset: ProfilingPreset, + basename: String, + workingDirectory: URL?, + calibrationFile: String?, + label: String? = nil + ) { + let page: PageSize + let customW: Double + let customH: Double + if let size = PageSize(rawValue: preset.pageSize) { + page = size + customW = 210 + customH = 297 + } else if let (w, h) = PageSize.parseCustom(preset.pageSize) { + page = .custom + customW = w + customH = h + } else { + page = .a4 + customW = 210 + customH = 297 + } + + let layout: LayoutOrder + let seed = preset.randomSeed ?? 1 + if preset.noRandomize == true { + layout = .raster + } else if seed == 1 { + layout = .deterministic + } else { + layout = .customSeed + } + + self.init( + instrument: PrintInstrument(rawValue: preset.instrument) ?? .i1, + pageSize: page, + customPageWidth: customW, + customPageHeight: customH, + bitDepth: preset.bitDepth == 16 ? .sixteen : .eight, + dpi: preset.dpi, + layoutOrder: layout, + customSeed: seed, + label: label, + calibrationFile: calibrationFile, + calibrationEmbedOnly: false, + basename: basename, + workingDirectory: workingDirectory + ) + } +} + +extension ColprofConfig { + public init( + preset: ProfilingPreset, + basename: String, + workingDirectory: URL?, + description: String? = nil, + copyright: String? = nil + ) { + self.init( + algorithm: preset.colprofAlgorithm ?? "l", + quality: preset.colprofQuality ?? "m", + intent: Self.nilIfEmpty(preset.colprofIntent), + fwa: preset.colprofFwa, + illuminant: Self.nilIfEmpty(preset.colprofIlluminant), + observer: Self.nilIfEmpty(preset.colprofObserver), + inputViewingCond: Self.nilIfEmpty(preset.colprofInputViewingCond), + outputViewingCond: Self.nilIfEmpty(preset.colprofOutputViewingCond), + description: description, + copyright: copyright, + basename: basename, + workingDirectory: workingDirectory + ) + } + + private static func nilIfEmpty(_ value: String?) -> String? { + guard let value, !value.isEmpty else { return nil } + return value + } +} + +/// User-facing FWA selection for the Stage 4 form, plus the two +/// directions of `colprof_fwa` conversion centralised here so the view +/// models carry no mapping switches of their own (#82). +public enum ColprofFwaSelection: String, CaseIterable, Sendable, Equatable { + case none = "none" + case empty = "" + case D50 = "D50" + case D65 = "D65" + case custom = "custom" + + public var displayName: String { + switch self { + case .none: return "None" + case .empty: return "Bare (-f)" + case .D50: return "D50" + case .D65: return "D65" + case .custom: return "Custom .sp" + } + } + + /// Preset `colprof_fwa` → selection. `nil`/`"none"` map to `.none`, + /// `""` to `.empty`, `D50`/`D65` case-insensitively, and any other + /// string is a custom `.sp` path. + public init(presetValue: String?) { + switch presetValue?.lowercased() { + case nil, "none": self = .none + case "": self = .empty + case "d50": self = .D50 + case "d65": self = .D65 + default: self = .custom + } + } + + /// Selection → `colprof_fwa` value. `.custom` returns `customPath`. + public func presetValue(customPath: String) -> String? { + switch self { + case .none: return nil + case .empty: return "" + case .D50: return "D50" + case .D65: return "D65" + case .custom: return customPath + } + } +} + +extension PageSize { + /// `"210x297"` custom page parse used by presets (issue #82). + public static func parseCustom(_ raw: String) -> (Double, Double)? { + let parts = raw.lowercased().split(separator: "x") + guard parts.count == 2, + let w = Double(parts[0]), let h = Double(parts[1]), + w >= 50, h >= 50 else { return nil } + return (w, h) + } +} + +extension ProfilingPreset { + /// Snapshot of the three live configs plus calibration toggles. + public init( + id: String, + name: String, + description: String, + targen: TargenConfig, + printtarg: PrinttargConfig, + colprof: ColprofConfig, + calibrationFile: String?, + applyCalibration: Bool? + ) { + let pageSize: String + if printtarg.pageSize == .custom { + pageSize = "\(Int(printtarg.customPageWidth))x\(Int(printtarg.customPageHeight))" + } else { + pageSize = printtarg.pageSize.rawValue + } + self.init( + id: id, + name: name, + description: description, + colourSpace: targen.colourSpace == .cmyk ? "cmyk" : "rgb", + patchCount: targen.patchCount, + whitePatches: targen.whitePatches, + blackPatches: targen.blackPatches, + greySteps: targen.greySteps, + singleChannelSteps: targen.singleChannelSteps, + neutralSteps: targen.neutralSteps, + neutralConcentration: targen.neutralConcentration, + preconditioningProfile: targen.preconditioningProfile, + ofpsHighQuality: targen.ofpsHighQuality, + ofpsAdaptation: targen.ofpsAdaptation, + fullSpreadAlgorithm: (targen.fullSpreadAlgorithm ?? .ofps).presetValue, + totalInkLimit: targen.totalInkLimit, + darkEmphasis: targen.darkEmphasis, + devicePower: targen.devicePower, + instrument: printtarg.instrument.rawValue, + pageSize: pageSize, + bitDepth: printtarg.bitDepth.rawValue, + dpi: printtarg.dpi, + randomSeed: printtarg.layoutOrder == .deterministic ? 1 : printtarg.customSeed, + noRandomize: printtarg.layoutOrder == .raster, + calibrationFile: calibrationFile, + applyCalibration: applyCalibration, + colprofAlgorithm: colprof.algorithm, + colprofQuality: colprof.quality, + colprofIntent: colprof.intent, + colprofFwa: colprof.fwa, + colprofIlluminant: colprof.illuminant, + colprofObserver: colprof.observer, + colprofInputViewingCond: colprof.inputViewingCond, + colprofOutputViewingCond: colprof.outputViewingCond + ) + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Settings/PresetStore.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Settings/PresetStore.swift new file mode 100644 index 0000000..b47d0b3 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Settings/PresetStore.swift @@ -0,0 +1,96 @@ +import Foundation + +/// CRUD + import/export for profiling presets on top of `SettingsStore` +/// (docs/22 §Built-in presets, issue #11). +/// +/// - `all()` = built-ins overlaid by custom presets (by `id`). +/// - Built-ins are never written to `settings.json` and cannot be +/// deleted or overwritten by `saveCustom` (a custom id that collides +/// with a built-in still overlays at read time, per spec). +/// - Import/export is single-preset JSON with schema validation. +/// - Imported names/descriptions are untrusted: callers must render +/// them with `Text`, never HTML (#114). +public final class PresetStore: Sendable { + + public let settingsStore: SettingsStore + + public init(settingsStore: SettingsStore = SettingsStore()) { + self.settingsStore = settingsStore + } + + /// All presets: built-ins overlaid by customs, catalog order. + public func all() -> [ProfilingPreset] { + PresetCatalog.all(custom: settingsStore.load().customPresets) + } + + /// Custom presets only, as persisted. + public func customs() -> [ProfilingPreset] { + settingsStore.load().customPresets + } + + /// Insert or replace a custom preset (matched by `id`). Throws + /// `PresetStoreError.builtIn` when the id belongs to a built-in — + /// built-ins are immutable. Validates before persisting. + public func saveCustom(_ preset: ProfilingPreset) throws { + let validated = try preset.validated() + guard !PresetCatalog.isBuiltIn(validated.id) else { + throw PresetStoreError.builtInImmutable(validated.id) + } + var settings = settingsStore.load() + if let idx = settings.customPresets.firstIndex(where: { $0.id == validated.id }) { + settings.customPresets[idx] = validated + } else { + settings.customPresets.append(validated) + } + try settingsStore.save(settings) + } + + /// Deletes a custom preset by id. Returns false when the id is a + /// built-in (undeletable) or no custom preset with that id exists. + @discardableResult + public func deleteCustom(id: String) throws -> Bool { + guard !PresetCatalog.isBuiltIn(id) else { return false } + var settings = settingsStore.load() + let before = settings.customPresets.count + settings.customPresets.removeAll { $0.id == id } + guard settings.customPresets.count != before else { return false } + try settingsStore.save(settings) + return true + } + + /// Single-preset pretty JSON export. + public func export(_ preset: ProfilingPreset) throws -> Data { + return try JSONEncoder.icceryPretty().encode(preset) + } + + /// Parses + validates a preset from JSON. The preset is assigned a + /// fresh custom id when its id is empty or collides with a built-in. + /// Does **not** persist — call `saveCustom` to keep it. + public func `import`(_ data: Data) throws -> ProfilingPreset { + let decoded: ProfilingPreset + do { + decoded = try JSONDecoder().decode(ProfilingPreset.self, from: data) + } catch { + throw PresetStoreError.invalidJSON(error.localizedDescription) + } + var preset = try decoded.validated() + if preset.id.isEmpty || PresetCatalog.isBuiltIn(preset.id) { + preset.id = "custom-\(UUID().uuidString.lowercased())" + } + return preset + } + + public enum PresetStoreError: LocalizedError, Equatable { + case builtInImmutable(String) + case invalidJSON(String) + + public var errorDescription: String? { + switch self { + case .builtInImmutable(let id): + return "Built-in preset \"\(id)\" cannot be modified or deleted." + case .invalidJSON(let reason): + return "Not a valid preset file: \(reason)" + } + } + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Settings/ProfilingPreset.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Settings/ProfilingPreset.swift new file mode 100644 index 0000000..c0aad66 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Settings/ProfilingPreset.swift @@ -0,0 +1,272 @@ +import Foundation + +/// A profiling preset: a complete snapshot of the Stage 1/2 form plus +/// the Stage 4 fields that are stored now and applied in issue 23 +/// (docs/22 §ProfilingPreset). +/// +/// snake_case keys match the v1 JSON schema so import/export stays +/// compatible. Identity + Stage 1/2 core fields are required; every +/// other field is optional-defaulted. Unknown keys are ignored on +/// decode; missing required fields fail. +public struct ProfilingPreset: Codable, Equatable, Sendable, Identifiable { + + // Identity + public var id: String + public var name: String + public var description: String + + // Stage 1 (required core) + public var colourSpace: String // "rgb" | "cmyk" + public var patchCount: Int + public var whitePatches: Int + public var blackPatches: Int + + // Stage 1 advanced (optional) + public var greySteps: Int? + public var singleChannelSteps: Int? + public var neutralSteps: Int? + public var neutralConcentration: Double? + public var preconditioningProfile: String? + public var ofpsHighQuality: Bool? + public var ofpsAdaptation: Double? + /// Stored as the flag letter: "ofps" or "t","r","R","q","Q","i","I". + public var fullSpreadAlgorithm: String? + public var totalInkLimit: Int? + public var darkEmphasis: Double? + public var devicePower: Double? + + // Stage 2 (required core) + public var instrument: String + public var pageSize: String + public var bitDepth: Int + public var dpi: Int + public var randomSeed: Int? + public var noRandomize: Bool? + + // Stage 0 / 2 calibration + public var calibrationFile: String? + public var applyCalibration: Bool? + + // Stage 4 (stored now, applied by issue 23) + public var colprofAlgorithm: String? + public var colprofQuality: String? + public var colprofIntent: String? + public var colprofFwa: String? + public var colprofIlluminant: String? + public var colprofObserver: String? + public var colprofInputViewingCond: String? + public var colprofOutputViewingCond: String? + + public init( + id: String, + name: String, + description: String = "", + colourSpace: String = "rgb", + patchCount: Int = 800, + whitePatches: Int = 4, + blackPatches: Int = 4, + greySteps: Int? = nil, + singleChannelSteps: Int? = nil, + neutralSteps: Int? = nil, + neutralConcentration: Double? = nil, + preconditioningProfile: String? = nil, + ofpsHighQuality: Bool? = nil, + ofpsAdaptation: Double? = nil, + fullSpreadAlgorithm: String? = nil, + totalInkLimit: Int? = nil, + darkEmphasis: Double? = nil, + devicePower: Double? = nil, + instrument: String = "i1", + pageSize: String = "A4", + bitDepth: Int = 8, + dpi: Int = 300, + randomSeed: Int? = 1, + noRandomize: Bool? = false, + calibrationFile: String? = nil, + applyCalibration: Bool? = nil, + colprofAlgorithm: String? = nil, + colprofQuality: String? = nil, + colprofIntent: String? = nil, + colprofFwa: String? = nil, + colprofIlluminant: String? = nil, + colprofObserver: String? = nil, + colprofInputViewingCond: String? = nil, + colprofOutputViewingCond: String? = nil + ) { + self.id = id + self.name = name + self.description = description + self.colourSpace = colourSpace + self.patchCount = patchCount + self.whitePatches = whitePatches + self.blackPatches = blackPatches + self.greySteps = greySteps + self.singleChannelSteps = singleChannelSteps + self.neutralSteps = neutralSteps + self.neutralConcentration = neutralConcentration + self.preconditioningProfile = preconditioningProfile + self.ofpsHighQuality = ofpsHighQuality + self.ofpsAdaptation = ofpsAdaptation + self.fullSpreadAlgorithm = fullSpreadAlgorithm + self.totalInkLimit = totalInkLimit + self.darkEmphasis = darkEmphasis + self.devicePower = devicePower + self.instrument = instrument + self.pageSize = pageSize + self.bitDepth = bitDepth + self.dpi = dpi + self.randomSeed = randomSeed + self.noRandomize = noRandomize + self.calibrationFile = calibrationFile + self.applyCalibration = applyCalibration + self.colprofAlgorithm = colprofAlgorithm + self.colprofQuality = colprofQuality + self.colprofIntent = colprofIntent + self.colprofFwa = colprofFwa + self.colprofIlluminant = colprofIlluminant + self.colprofObserver = colprofObserver + self.colprofInputViewingCond = colprofInputViewingCond + self.colprofOutputViewingCond = colprofOutputViewingCond + } + + enum CodingKeys: String, CodingKey { + case id, name, description + case colourSpace = "colour_space" + case patchCount = "patch_count" + case whitePatches = "white_patches" + case blackPatches = "black_patches" + case greySteps = "grey_steps" + case singleChannelSteps = "single_channel_steps" + case neutralSteps = "neutral_steps" + case neutralConcentration = "neutral_concentration" + case preconditioningProfile = "preconditioning_profile" + case ofpsHighQuality = "ofps_high_quality" + case ofpsAdaptation = "ofps_adaptation" + case fullSpreadAlgorithm = "full_spread_algorithm" + case totalInkLimit = "total_ink_limit" + case darkEmphasis = "dark_emphasis" + case devicePower = "device_power" + case instrument + case pageSize = "page_size" + case bitDepth = "bit_depth" + case dpi + case randomSeed = "random_seed" + case noRandomize = "no_randomize" + case calibrationFile = "calibration_file" + case applyCalibration = "apply_calibration" + case colprofAlgorithm = "colprof_algorithm" + case colprofQuality = "colprof_quality" + case colprofIntent = "colprof_intent" + case colprofFwa = "colprof_fwa" + case colprofIlluminant = "colprof_illuminant" + case colprofObserver = "colprof_observer" + case colprofInputViewingCond = "colprof_input_viewing_cond" + case colprofOutputViewingCond = "colprof_output_viewing_cond" + } + + /// Strict decode: required identity + Stage 1/2 core fields must be + /// present; optionals default to nil. 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) + description = try c.decodeIfPresent(String.self, forKey: .description) ?? "" + colourSpace = try c.decode(String.self, forKey: .colourSpace) + patchCount = try c.decode(Int.self, forKey: .patchCount) + whitePatches = try c.decode(Int.self, forKey: .whitePatches) + blackPatches = try c.decode(Int.self, forKey: .blackPatches) + greySteps = try c.decodeIfPresent(Int.self, forKey: .greySteps) + singleChannelSteps = try c.decodeIfPresent(Int.self, forKey: .singleChannelSteps) + neutralSteps = try c.decodeIfPresent(Int.self, forKey: .neutralSteps) + neutralConcentration = try c.decodeIfPresent(Double.self, forKey: .neutralConcentration) + preconditioningProfile = try c.decodeIfPresent(String.self, forKey: .preconditioningProfile) + ofpsHighQuality = try c.decodeIfPresent(Bool.self, forKey: .ofpsHighQuality) + ofpsAdaptation = try c.decodeIfPresent(Double.self, forKey: .ofpsAdaptation) + fullSpreadAlgorithm = try c.decodeIfPresent(String.self, forKey: .fullSpreadAlgorithm) + totalInkLimit = try c.decodeIfPresent(Int.self, forKey: .totalInkLimit) + darkEmphasis = try c.decodeIfPresent(Double.self, forKey: .darkEmphasis) + devicePower = try c.decodeIfPresent(Double.self, forKey: .devicePower) + instrument = try c.decode(String.self, forKey: .instrument) + pageSize = try c.decode(String.self, forKey: .pageSize) + bitDepth = try c.decode(Int.self, forKey: .bitDepth) + dpi = try c.decode(Int.self, forKey: .dpi) + randomSeed = try c.decodeIfPresent(Int.self, forKey: .randomSeed) + noRandomize = try c.decodeIfPresent(Bool.self, forKey: .noRandomize) + calibrationFile = try c.decodeIfPresent(String.self, forKey: .calibrationFile) + applyCalibration = try c.decodeIfPresent(Bool.self, forKey: .applyCalibration) + colprofAlgorithm = try c.decodeIfPresent(String.self, forKey: .colprofAlgorithm) + colprofQuality = try c.decodeIfPresent(String.self, forKey: .colprofQuality) + colprofIntent = try c.decodeIfPresent(String.self, forKey: .colprofIntent) + colprofFwa = try c.decodeIfPresent(String.self, forKey: .colprofFwa) + colprofIlluminant = try c.decodeIfPresent(String.self, forKey: .colprofIlluminant) + colprofObserver = try c.decodeIfPresent(String.self, forKey: .colprofObserver) + colprofInputViewingCond = try c.decodeIfPresent(String.self, forKey: .colprofInputViewingCond) + colprofOutputViewingCond = try c.decodeIfPresent(String.self, forKey: .colprofOutputViewingCond) + } + + // MARK: - Validation (import path) + + public enum ValidationError: LocalizedError, Equatable { + case emptyID + case emptyName + case invalidColourSpace(String) + case invalidPatchCount(Int) + case invalidBitDepth(Int) + case invalidDPI(Int) + case emptyPageSize + case emptyInstrument + + public var errorDescription: String? { + switch self { + case .emptyID: return "Preset is missing an id." + case .emptyName: return "Preset is missing a name." + case .invalidColourSpace(let v): + return "colour_space must be \"rgb\" or \"cmyk\", got \"\(v)\"." + case .invalidPatchCount(let v): + return "patch_count must be positive, got \(v)." + case .invalidBitDepth(let v): + return "bit_depth must be 8 or 16, got \(v)." + case .invalidDPI(let v): + return "dpi must be between 72 and 600, got \(v)." + case .emptyPageSize: return "page_size is empty." + case .emptyInstrument: return "instrument is empty." + } + } + } + + /// Validates the required fields for import / catalog use. + /// `colourSpace` is normalized to lowercase before comparison. + @discardableResult + public func validated() throws -> ProfilingPreset { + var p = self + p.id = id.trimmingCharacters(in: .whitespacesAndNewlines) + p.name = name.trimmingCharacters(in: .whitespacesAndNewlines) + p.colourSpace = colourSpace.lowercased() + guard !p.id.isEmpty else { throw ValidationError.emptyID } + guard !p.name.isEmpty else { throw ValidationError.emptyName } + guard p.colourSpace == "rgb" || p.colourSpace == "cmyk" else { + throw ValidationError.invalidColourSpace(colourSpace) + } + guard p.patchCount > 0 else { throw ValidationError.invalidPatchCount(patchCount) } + guard p.bitDepth == 8 || p.bitDepth == 16 else { + throw ValidationError.invalidBitDepth(bitDepth) + } + guard (72...600).contains(p.dpi) else { throw ValidationError.invalidDPI(dpi) } + guard !p.pageSize.trimmingCharacters(in: .whitespaces).isEmpty else { + throw ValidationError.emptyPageSize + } + guard !p.instrument.trimmingCharacters(in: .whitespaces).isEmpty else { + throw ValidationError.emptyInstrument + } + return p + } +} + +/// Per-element non-throwing decode wrapper — one malformed preset entry +/// must not drop the whole `custom_presets` array during migration. +struct FailableDecodable: Decodable { + let value: T? + init(from decoder: Decoder) throws { + value = try? T(from: decoder) + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Settings/SettingsStore.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Settings/SettingsStore.swift new file mode 100644 index 0000000..22bdca6 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Settings/SettingsStore.swift @@ -0,0 +1,46 @@ +import Foundation + +/// Persists `AppSettings` to +/// `~/Library/Application Support/com.gronod.iccery2/settings.json` +/// (issue #5 — the v1 path is never read). +/// +/// Writes are atomic (`JSONFileStore` → `AtomicFileWriter`). Invalid/corrupt +/// JSON falls back to defaults. Saving posts `settingsDidChange` so #20 can +/// reclassify swatches. +public final class SettingsStore: Sendable { + + /// Posted on `NotificationCenter.default` after every successful save. + public static let settingsDidChange = + Notification.Name("com.gronod.iccery2.settingsDidChange") + + public let fileURL: URL + private let store: JSONFileStore + + public init(fileURL: URL = AppPaths.appDataDir.appendingPathComponent("settings.json")) { + self.fileURL = fileURL + self.store = JSONFileStore( + fileURL: fileURL, + corrupt: .replaceWithDefault, + defaultValue: { .default } + ) + } + + public func load() -> AppSettings { + (try? store.load()) ?? .default + } + + /// Validates before persisting — throws `SettingsError` listing + /// every violation; nothing is written on failure. + public func save(_ settings: AppSettings) throws { + let errors = settings.validate() + guard errors.isEmpty else { + throw SettingsError.validationFailed(errors) + } + try store.save(settings) + NotificationCenter.default.post(name: Self.settingsDidChange, object: nil) + } + + public enum SettingsError: Error, Equatable { + case validationFailed([String]) + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Wizard/WizardGating.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Wizard/WizardGating.swift new file mode 100644 index 0000000..91973d0 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Wizard/WizardGating.swift @@ -0,0 +1,59 @@ +import Foundation + +/// Artefact-driven stage gating (issue #4, docs/06 §Stages). +/// +/// Navigation is *disk*, not buttons: a stage unlocks only when its +/// predecessor artefacts exist. Forward moves are gated; backward is +/// always allowed. Gating is re-evaluated on window focus and on stage +/// entry (#151 — files can disappear in Finder). +public enum WizardGating { + + /// Whether `stage` is reachable given the probed artefacts. + /// + /// - Stage 0 (calibrate): always — it is out-of-band, not gated. + /// - Stage 1: always. + /// - Stage 2: `.ti1` exists. + /// - Stage 3: `.ti1` **and** `.ti2`. + /// - Stage 4: `.ti3` exists (accepted measurement only — a `.ti2` + /// alone never unlocks it; #109/#110). + /// - Stage 5: `.ti3` **and** `.icc`/`.icm`. + public static func isUnlocked( + _ stage: WizardStage, + artefacts: StageArtefacts + ) -> Bool { + switch stage { + case .calibrate: return true + case .generate: return true + case .layOutPrint: return artefacts.stage1Complete + case .measure: return artefacts.stage1Complete && artefacts.stage2Complete + case .buildProfile: return artefacts.stage3Complete + case .verifyInstall: return artefacts.stage3Complete && artefacts.stage4Complete + } + } + + /// Whether `go(to:)` may proceed. Backward moves and the current + /// stage are always allowed; forward moves must be unlocked. + public static func canNavigate( + to target: WizardStage, + from current: WizardStage, + artefacts: StageArtefacts + ) -> Bool { + if target == current { return true } + if target == .calibrate || current == .calibrate { + // Stage 0 is a side-trip, not stepper navigation. + return true + } + if target.rawValue < current.rawValue { return true } + return isUnlocked(target, artefacts: artefacts) + } + + /// The deepest unlocked stepper stage — used when revalidation + /// locks the current stage (#151). + public static func deepestUnlocked(artefacts: StageArtefacts) -> WizardStage { + for stage in WizardStage.stepperStages.reversed() + where isUnlocked(stage, artefacts: artefacts) { + return stage + } + return .generate + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Wizard/WizardStage.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Wizard/WizardStage.swift new file mode 100644 index 0000000..c34c8a0 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Wizard/WizardStage.swift @@ -0,0 +1,45 @@ +import Foundation + +/// The five wizard stages plus Stage 0 (calibration), matching the v1 +/// `data-stage` contract in docs/21. Stepper buttons 1–5 map to +/// `.generate` … `.verifyInstall`; `.calibrate` lives outside the stepper. +public enum WizardStage: Int, CaseIterable, Sendable, Codable { + case calibrate = 0 + case generate = 1 + case layOutPrint = 2 + case measure = 3 + case buildProfile = 4 + case verifyInstall = 5 + + /// Sidebar stepper position (1–5); `nil` for the out-of-band calibrate stage. + public var stepperIndex: Int? { + self == .calibrate ? nil : rawValue + } + + public var title: String { + switch self { + case .calibrate: return "Printer Calibration" + case .generate: return "Generate Target" + case .layOutPrint: return "Lay Out & Print" + case .measure: return "Measure Chart" + case .buildProfile: return "Build Profile" + case .verifyInstall: return "Verify & Install" + } + } + + public var symbolName: String { + switch self { + case .calibrate: return "slider.horizontal.3" + case .generate: return "square.grid.3x3" + case .layOutPrint: return "printer" + case .measure: return "eyedropper.halffull" + case .buildProfile: return "paintpalette" + case .verifyInstall: return "checkmark.seal" + } + } + + /// Stages shown in the sidebar stepper, in order. + public static var stepperStages: [WizardStage] { + [.generate, .layOutPrint, .measure, .buildProfile, .verifyInstall] + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Wizard/WizardState.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Wizard/WizardState.swift new file mode 100644 index 0000000..b5fb31f --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Wizard/WizardState.swift @@ -0,0 +1,97 @@ +import Foundation + +/// Session mode (docs/06 §wizardState). `"calibration"` is set while +/// Stage 0 is driving a `CAL_` chart through the same pipeline. +public enum SessionMode: String, Codable, Sendable { + case profile + case calibration +} + +/// Persisted wizard state (docs/06 §wizardState fields) — +/// `wizard_state.json` in app data. +public struct WizardState: Codable, Equatable, Sendable { + /// 0–5 (`WizardStage.rawValue`). + public var currentStage: Int + /// Run name without extension — never invented (#60). + public var basename: String + /// Working directory for artefacts; empty → `resolveSafeCwd` (#59). + public var cwd: String + /// Last spooled printer, for calibration drift history. + public var printerName: String? + public var sessionMode: SessionMode + /// May differ from `basename` after a `.ti3` import (#94). + public var profileBasename: String? + /// The pre-`CAL_` basename, persisted so a crash/relaunch can + /// restore the original (#29). + public var calibrationOriginalBasename: String = "" + + private enum CodingKeys: String, CodingKey { + case currentStage, basename, cwd, printerName, sessionMode + case profileBasename, calibrationOriginalBasename + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.currentStage = try container.decodeIfPresent(Int.self, forKey: .currentStage) + ?? WizardStage.generate.rawValue + self.basename = try container.decodeIfPresent(String.self, forKey: .basename) ?? "" + self.cwd = try container.decodeIfPresent(String.self, forKey: .cwd) ?? "" + self.printerName = try container.decodeIfPresent(String.self, forKey: .printerName) + self.sessionMode = try container.decodeIfPresent(SessionMode.self, forKey: .sessionMode) + ?? .profile + self.profileBasename = try container.decodeIfPresent(String.self, forKey: .profileBasename) + self.calibrationOriginalBasename = try container.decodeIfPresent( + String.self, forKey: .calibrationOriginalBasename) ?? "" + } + + public init( + currentStage: Int = WizardStage.generate.rawValue, + basename: String = "", + cwd: String = "", + printerName: String? = nil, + sessionMode: SessionMode = .profile, + profileBasename: String? = nil, + calibrationOriginalBasename: String = "" + ) { + self.currentStage = currentStage + self.basename = basename + self.cwd = cwd + self.printerName = printerName + self.sessionMode = sessionMode + self.profileBasename = profileBasename + self.calibrationOriginalBasename = calibrationOriginalBasename + } + + public static let `default` = WizardState() + + /// The stage a saved `currentStage` resolves to, clamped to a valid + /// value (corrupt ints fall back to Stage 1). + public var stage: WizardStage { + WizardStage(rawValue: currentStage) ?? .generate + } +} + +/// Atomic JSON persistence for `WizardState` (issue #4). +public final class WizardStateStore: Sendable { + public let fileURL: URL + private let store: JSONFileStore + + public init( + fileURL: URL = AppPaths.appDataDir.appendingPathComponent("wizard_state.json") + ) { + self.fileURL = fileURL + self.store = JSONFileStore( + fileURL: fileURL, + corrupt: .replaceWithDefault, + defaultValue: { .default } + ) + } + + public func load() -> WizardState { + (try? store.load()) ?? .default + } + + public func save(_ state: WizardState) throws { + try store.save(state) + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..ccbdfe1 --- /dev/null +++ b/README.md @@ -0,0 +1,292 @@ +# ICCery + +Native macOS frontend for printer ICC/ICM profiling. ICCery walks a user from +chart generation through measurement, `colprof`, verification, and ColorSync +install. It is **not** a colour engine. + +**End-user guide:** the [repository wiki](https://git.i3omb.com/gronod/iccery-v2-mac/wiki) +covers every screen (Getting Started through Troubleshooting). This README is +for building, packaging, and contributing. + +All measurement, chart generation, and profile mathematics live in the +[Gronod ArgyllCMS 3.5.0 fork](https://git.i3omb.com/gronod/argyllcms), spawned +as AGPLv3 child processes. The GUI never `dlopen`s or links Argyll. + +| | | +|---|---| +| Product | ICCery v2 for macOS | +| Bundle | `com.gronod.iccery2` | +| Version | 2.0.0 | +| Floor | macOS 12.0 Monterey, universal `arm64` + `x86_64` | +| Toolchain | Xcode 14.2 / Swift 5.7 (project `SWIFT_VERSION` is 5.0) | +| CI | Gitea Actions `macos-12` runner | +| Default branch | `develop` | +| M6 | Stage 0 calibration, CGATS import, SceneKit gamut viewer, packaging — shipped | +| M7 | Pre-UAT hardening — shipped | +| M8 | Deduplication contracts & UAT-ready hardening (#79–#86) — shipped | +| M9 | macOS 12 / Xcode 14.2 retarget (PR #145) — shipped | +| M10 | Studio workflow: gamut compare (#147), Spot Read (#148), project files (#149) shipped on `develop`; media library (#146) is in the tree, issue still open | +| Licence | Proprietary source in [`LICENCE.md`](LICENCE.md); bundled Argyll sidecars remain AGPLv3 | + +## What it does + +The wizard is artefact-gated: + +1. **Stage 1** — `targen` → `.ti1` +2. **Stage 2** — `printtarg` → `.ti2` + TIFF, unmanaged `lp` spool, bound `NSPrintPanel` +3. **Stage 3** — `instlist` + streaming `chartread` (strip / XY / handheld) → `.ti3`, multi-pass average, CIEDE2000 +4. **Stage 4** — `colprof` → `.icc` / `.icm`; optional `applycal`; `iccgamut` next to the profile +5. **Stage 5** — `profcheck`, verification history, ColorSync user/system install + +Plus: + +- **Calibrate Printer** — optional `printcal` / `applycal` session under a `CAL_` basename +- **CGATS import** — `.ti3` / `.txt` / `.cgats` / `.csv` +- **Media recipes and presets** — printer + paper + ink bound to a preset and optional `.cal` +- **Spot Read** — live one-patch Lab/XYZ from the instrument +- **Project files** — `.icceryproj` bookmark over folder, basename, recipe, last ΔE +- **Gamut viewer** — SceneKit Lab hull, sRGB overlay, second-profile compare, click-inspect +- **Settings** — default instrument, ΔE good/warning cutoffs, install location, logging +- Signed `.dmg` packaging with a HiDPI Finder background (Monterey through Sonoma) + +**Not this product:** display calibration (`dispwin` / `dispread`), i18n, +Windows/Linux print trees, in-process Argyll, App Sandbox. + +## Requirements + +To **run** a packaged build: + +- macOS 12.0 Monterey or later (Intel or Apple silicon) + +To **build** on the supported CI/host floor: + +- macOS 12 with **Xcode 14.2** (macOS 12 SDK, Swift 5.7) +- [XcodeGen](https://github.com/yonaskolb/XcodeGen) **2.38.0** (Homebrew’s current + formula needs Xcode 15.3; CI installs the pinned zip via + `scripts/ensure-host-tools.sh`) +- Network once, to fetch Argyll sidecars +- For DMGs: Python 3.9+ and `dmgbuild==1.6.7` in `build/.venv-dmgbuild` + (`INSTALL_DMGBUILD=1 scripts/ensure-host-tools.sh`) + +App Sandbox is **off**. Hardened Runtime is **on**. Entitlements live in +`ICCery.entitlements`. + +## Build + +```bash +git clone https://git.i3omb.com/gronod/iccery-v2-mac.git +cd iccery-v2-mac +git checkout develop + +make fetch-argyll # Vendor/Argyll/macos-universal/, ad-hoc signed +make test # xcodegen + xcodebuild build test (host arch) +make universal # ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO +``` + +Equivalent without Make: + +```bash +xcodegen generate +xcodebuild test -scheme ICCery \ + -destination 'platform=macOS' \ + ARCHS="$(uname -m)" +``` + +`project.yml` sets `ARCHS: "$(ARCHS_STANDARD)"`. CI and `make test` override +that with `ARCHS="$(uname -m)"` so unit/UI tests build the host slice only. +Fat binaries are `make universal` / `scripts/package-release.sh`. + +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 +# optional +export ARGYLL_SERVER_URL=https://git.i3omb.com +export ARGYLL_REPO=gronod/argyllcms +export ARGYLL_RELEASE_TAG=… # default: latest +export GITEA_TOKEN=… # private releases +``` + +`make clean` drops `ICCery.xcodeproj`, `DerivedData`, and +`Packages/ICCeryCore/.build`. + +Do not open the generated xcodeproj as the source of truth. Edit `project.yml` +and regenerate. + +## Release packaging + +```bash +scripts/package-release.sh # fetch → sign → universal build → verify → DMG +``` + +The script builds with a fixed derived data path (`build/DerivedData`), locates +`Release/ICCery.app` from it, signs the bundle, recursively verifies every +bundled Mach-O sidecar (`scripts/verify-sidecar-signatures.sh`), builds a +HiDPI TIFF from `Resources/dmg-background.png` (+ `@2x`) via `tiffutil`, and +writes `ICCery-${VERSION}-${BUILD_NUM}.dmg` with `dmgbuild==1.6.7`. + +Sidecars stay ad-hoc signed inside the bundle — the app is never +`codesign --deep`ed. + +`dmgbuild` is **not** a test-job dependency. The package job sets +`INSTALL_DMGBUILD=1` so `scripts/ensure-host-tools.sh` creates +`build/.venv-dmgbuild`. On the Monterey runner (Python 3.9) that install uses +`PIP_IGNORE_REQUIRES_PYTHON=1` and pins `pip>=24.3,<26.1` (pip 26.1+ needs +3.10). Missing background art is a hard fail (#95). + +Environment variables read by the pipeline: + +| Variable | Purpose | +|---|---| +| `GITEA_TOKEN` | private `gronod/argyllcms` release downloads | +| `ARGYLL_SERVER_URL` / `ARGYLL_REPO` / `ARGYLL_RELEASE_TAG` | sidecar release override | +| `CODESIGN_IDENTITY` | Developer ID identity for the outer `.app`; unset or `-` = ad-hoc | +| `DEVELOPMENT_TEAM` | team ID passed to `xcodebuild` when signing | +| `NOTARIZE_APPLE_ID` / `NOTARIZE_PASSWORD` / `APPLE_TEAM_ID` | `notarytool` + staple when all three are set | + +## Layout + +``` +Sources/ICCery/ SwiftUI + AppKit shell, stage views, workflow VMs +Packages/ICCeryCore/ wizard state, ProcessManager, argv builders, + settings, CGATS, ΔE₀₀ — no NSPrintPanel +Resources/ assets; Argyll reference files (not the tools) +Vendor/Argyll/ fetched sidecars (gitignored) +Tests/ICCeryCoreTests/ argv goldens, parsers, stores +Tests/ICCeryUITests/ fixture / mock-binary UI tests +scripts/ensure-host-tools.sh +scripts/fetch-argyll.sh +scripts/package-release.sh +docs/ functional spec + v2 ticket plan +``` + +`ICCeryPrintKit` (issue #16, Quartz / AirPrint / TargetPrint) is v2.1 and is +not in this tree. + +## Architecture + +- **Spawn, never link.** Tools resolve through `BinaryResolver` inside the + bundle / `Vendor` tree. `$PATH` is not searched. `ARGYLL_NOT_INTERACTIVE=1` + is always set. +- **`ProcessManager` actor** owns child lifetime. Streaming tools + (`chartread`, `printtarg`, `colprof`, …) use the event bus; one-shot tools + (`printcal`, `applycal`, CUPS) use `runCaptured`. Exclusive `ProcessID` + leases. Quit path: `q\n`, ~500 ms, kill; `killAll` on terminate. +- **Argv builders** in ICCeryCore (`TargenArgs`, `PrinttargArgs`, + `ChartreadArgs`, `ColprofArgs`, `ApplycalArgs`, `IccgamutArgs`, + `ProfcheckArgs`, `LpArgs`, `SpotReadArgs`, …). UI must not concatenate flags. +- **Atomic artefacts.** Writes go to `*.tmp` then `replaceItemAt`. `applycal` + must not replace the input profile on cancel or non-zero exit. +- **Concurrency.** View models are `@MainActor`. No blocking I/O on the main + actor. Swift 5.7 / macOS 12: `ObservableObject`, not Observation + `@Observable`. +- **Print.** Unmanaged `lp` with ColorSync suppression + (`AP_ColorMatchingMode` / `AP.ColorMatchingMode`). Captured `NSPrintPanel` + options win over derived CUPS keys. Never `lp -o raw`. +- **SwiftUI ViewBuilder.** Xcode 14.2 / Swift 5.7 still has the ten-child + limit. Split large `VStack`/`Group` trees (#146). + +## Tests + +```bash +# full suite (host arch) — same as CI +xcodebuild test -scheme ICCery \ + -destination 'platform=macOS' \ + ARCHS="$(uname -m)" + +# fat compile-check (not the default test path): +# ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO + +# examples +xcodebuild test -scheme ICCery -destination 'platform=macOS' \ + -only-testing:ICCeryCoreTests/ChartreadClassifierTests +xcodebuild test -scheme ICCery -destination 'platform=macOS' \ + -only-testing:ICCeryUITests/Milestone5UITests +``` + +CI (`.gitea/workflows/macos.yml` on Gitea, `.github/workflows/macos.yml` on +GitHub) runs `build-and-test` then `package` on +`develop` and on `v*` tags. Tags whose name contains `prerelease` skip the +test job and still package. `pull_request` is wired for **`develop` only**. +The GitHub file is the same pipeline on `macos-14` (github.com retired +`macos-12`), `actions/upload-artifact@v4`, and `gh release upload` for tag +DMGs. + +UI tests need an unlocked console (`IOConsoleLocked=false`). Mock Argyll / +CUPS fixtures live under the test bundles; they must not be treated as proof +that a real `.gam` / `.icc` was extracted. + +Hardware gates (real instrument, real printer, Gatekeeper-open `.dmg`) are +manual and block release, not compile. + +`ArgyllRunnerPrinttargTests.testSuccess` can flake if streaming stdout is +dropped on a fast mock exit; that is a `ProcessManager` drain race, not a +missing fixture. + +## Instruments + +Detected via bundled `instlist`: + +- i1 Pro / i1 Pro 2 (`i1`) +- ColorMunki (`CM`) +- SpyderPrint (`p3`) +- SpectroScan (`SS`) +- DTP20 / 22 / 41 / 51 +- XY tables (SpectroScan, i1iO) when the `instlist` name matches + `/spectro\s?scan|i1io/i` + +## Docs + +| Where | Audience | +|---|---| +| [Wiki](https://git.i3omb.com/gronod/iccery-v2-mac/wiki) | End users — screens, workflow, troubleshooting | +| [`docs/`](docs/README.md) | Functional spec (normative for implementers) | +| [`AGENTS.md`](AGENTS.md), [`BUILD-PLAN.md`](BUILD-PLAN.md) | Agent / branch rules | + +Implementation order in `docs/`: + +| Doc | Topic | +|---|---| +| [`docs/01-overview.md`](docs/01-overview.md) | Product and wizard | +| [`docs/03-ipc-and-process-manager.md`](docs/03-ipc-and-process-manager.md) | Spawn / stdin / kill | +| [`docs/04-argyll-binaries.md`](docs/04-argyll-binaries.md) | CLI argv | +| [`docs/06-wizard-and-artefacts.md`](docs/06-wizard-and-artefacts.md) | Gating | +| [`docs/23-assets.md`](docs/23-assets.md) | Icons, DMG chrome | +| [`docs/24-issues-invariants.md`](docs/24-issues-invariants.md) | Bugs that must not return | +| [`docs/26-v2-mac-ticket-plan.md`](docs/26-v2-mac-ticket-plan.md) | Gitea tickets | +| [`docs/PREUAT.md`](docs/PREUAT.md) | Pre-UAT tester kit | + +## Git + +``` +develop # integration; PRs land here unless a milestone branch is announced +main # protected release line (PR from develop) +feat/- +fix/- +``` + +Open feature/fix PRs against **`develop`**. A `milestone/m…` integration +branch is used only while that milestone is assembling; `milestone/m10-studio` +has been merged and deleted. Do not open umbrella “bugfix” branches that mix +tickets. + +`main` is push-protected and requires status check +`macOS CI / build-and-test (push)`. Protected **file** patterns on `main` +block PR merges that touch matching paths — do not set that field to `*`. + +## Licence + +GUI source: © 2026 Gordon Bolton — see [`LICENCE.md`](LICENCE.md). Viewing +and personal evaluation only unless a separate grant says otherwise. + +ArgyllCMS binaries fetched into `Vendor/Argyll/` are **AGPLv3**. They stay +subprocess-isolated (stdin / stdout / stderr only). Linking them, or spawning +via `$PATH`, is a licence break. + +## Related + +- [User wiki](https://git.i3omb.com/gronod/iccery-v2-mac/wiki) +- [gronod/argyllcms](https://git.i3omb.com/gronod/argyllcms) — Argyll 3.5.0 fork (`-u` JSON, `instlist`) +- [gronod/ICCery](https://git.i3omb.com/gronod/ICCery) — v1 Tauri application (spec source, not this tree) diff --git a/Resources/Argyll/mocks/chartread.mock b/Resources/Argyll/mocks/chartread.mock new file mode 100755 index 0000000..f9699b3 --- /dev/null +++ b/Resources/Argyll/mocks/chartread.mock @@ -0,0 +1,79 @@ +#!/bin/bash +# Mock chartread for bundled/manual testing. +# Supports handheld and XY modes. Writes basename.ti3 on 'd'. +MODE="${MOCK_CHARTREAD_MODE:-strip}" +BASENAME="" + +# Basename is the last non-flag argument. +for arg in "$@"; do + case "$arg" in + -*) ;; + *) BASENAME="$arg" ;; + esac +done + +read_input() { + IFS= read -r line || return 1 +} + +emit_row() { + printf 'ROW_COLORS_JSON: %s\n' "$1" +} + +write_ti3() { + if [ -n "$BASENAME" ]; then + echo "MOCK_TI3" > "${BASENAME}.ti3" + fi +} + +if [ "$MODE" = "xy" ]; then + echo "Place instrument on calibration tile and hit [Space] to calibrate." + read_input + echo "Calibration successful." + + echo "Please place sheet 1 of 1 on the table" + echo "hit return to continue, Esc or 'q' to give up" + read_input + + echo "locate patch A1 with the sight," + echo "then hit return to continue" + read_input + + echo "Reading sheet 1..." + emit_row '{"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 1, "patch_count": 3, "patches": [{"id": "1", "loc": "A1", "is_pad": false, "device": [0.0, 50.0, 100.0], "expected": {"XYZ": [18.4210, 20.1234, 15.6789], "Lab": [51.98, -8.45, 12.32]}, "measured": {"XYZ": [18.5120, 20.0451, 15.7100], "Lab": [51.89, -8.31, 12.15]}}, {"id": "2", "loc": "A2", "is_pad": false, "device": [10.0, 60.0, 90.0], "expected": {"Lab": [60.0, 10.0, -20.0]}, "measured": {"Lab": [60.1, 10.5, -19.5]}}, {"id": "3", "loc": "A3", "is_pad": true, "device": [100.0, 100.0, 100.0], "measured": {"Lab": [95.0, 0.0, 0.0]}}]}' + + echo "Sheet 1 of 1 read OK" + echo "Please remove last sheet from table" + echo "'d' if/when done" + while read_input; do + case "$line" in + d*) write_ti3; exit 0 ;; + q*) exit 0 ;; + esac + done + exit 0 +fi + +# Handheld / strip mode (default) +echo "Place instrument on calibration tile and hit [Space] to calibrate." +read_input +echo "Calibration successful." + +echo "Hit [Space] to read strip A" +read_input +echo "Reading strip A..." +emit_row '{"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2, "patch_count": 3, "patches": [{"id": "1", "loc": "A1", "is_pad": false, "device": [0.0, 50.0, 100.0], "expected": {"XYZ": [18.4210, 20.1234, 15.6789], "Lab": [51.98, -8.45, 12.32]}, "measured": {"XYZ": [18.5120, 20.0451, 15.7100], "Lab": [51.89, -8.31, 12.15]}}, {"id": "2", "loc": "A2", "is_pad": false, "device": [10.0, 60.0, 90.0], "expected": {"Lab": [60.0, 10.0, -20.0]}, "measured": {"Lab": [60.1, 10.5, -19.5]}}, {"id": "3", "loc": "A3", "is_pad": true, "device": [100.0, 100.0, 100.0], "measured": {"Lab": [95.0, 0.0, 0.0]}}]}' + +echo "Hit [Space] to read strip B" +read_input +echo "Reading strip B..." +emit_row '{"event": "row_complete", "row_id": "B", "row_index": 1, "total_rows": 2, "patch_count": 2, "patches": [{"id": "4", "loc": "B1", "is_pad": false, "device": [100.0, 0.0, 0.0], "expected": {"Lab": [40.0, 40.0, 40.0]}, "measured": {"Lab": [38.0, 41.0, 39.0]}}, {"id": "5", "loc": "B2", "is_pad": false, "device": [0.0, 100.0, 0.0], "expected": {"Lab": [80.0, -50.0, 50.0]}, "measured": {"Lab": [79.0, -49.0, 51.0]}}]}' + +echo "'d' if/when done" +while read_input; do + case "$line" in + d*) write_ti3; exit 0 ;; + q*) exit 0 ;; + esac +done +exit 0 diff --git a/Resources/Argyll/mocks/colprof.mock b/Resources/Argyll/mocks/colprof.mock new file mode 100755 index 0000000..8bbd22c --- /dev/null +++ b/Resources/Argyll/mocks/colprof.mock @@ -0,0 +1,20 @@ +#!/bin/bash +# Mock script for colprof +# Simulates colprof execution and outputs progress log + +basename="$1" +# Find last argument if -D or other flags are used +for arg in "$@"; do + basename="$arg" +done + +echo "colprof: Starting profile calculation for $basename" +sleep 1 +echo "Gamut mapping calculation..." +sleep 1 +echo "Fitting cLUT grid points..." +sleep 1 +echo "Writing ICC profile $basename.icc..." +touch "$basename.icc" +echo "Done." +exit 0 diff --git a/Resources/Argyll/mocks/profcheck.mock b/Resources/Argyll/mocks/profcheck.mock new file mode 100755 index 0000000..c424509 --- /dev/null +++ b/Resources/Argyll/mocks/profcheck.mock @@ -0,0 +1,12 @@ +#!/bin/bash +# Mock script for profcheck +# Simulates real ArgyllCMS profcheck -v -k -s -u output + +echo "profcheck: Checking profile accuracy..." +echo "No of test patches = 52" +sleep 1 +cat << 'EOF' +{"event": "report", "peak_de2000": 2.41, "avg_de2000": 0.85, "rms": 1.02} +EOF +echo "Profile check complete, errors(CIEDE2000): max. = 2.41, avg. = 0.85, RMS = 1.02" +exit 0 diff --git a/Resources/Argyll/reference_gamuts/sRGB.gam b/Resources/Argyll/reference_gamuts/sRGB.gam new file mode 100644 index 0000000..993c5b5 --- /dev/null +++ b/Resources/Argyll/reference_gamuts/sRGB.gam @@ -0,0 +1,1379 @@ +GAMUT + +DESCRIPTOR "Argyll Gamut surface poligon data" +ORIGINATOR "Argyll CMS gamut library" +CREATED "Tue Aug 25 10:22:46 2026" +COLOR_REP "LAB" +GAMUT_CENTER "50.000000 0.000000 0.000000" +CSPACE_WHITE "100.000000 -2.387816 -19.404026" +GAMUT_WHITE "99.998874 -2.387789 -19.403808" +CSPACE_BLACK "0.000000 0.000000 0.000000" +GAMUT_BLACK "0.000000 0.000000 0.000000" +CUSP_RED "53.237382 78.287871 62.148058" +CUSP_YELLOW "97.137321 -23.769791 84.721311" +CUSP_GREEN "87.733928 -87.887785 73.898355" +CUSP_CYAN "91.113320 -50.031832 -33.437063" +CUSP_BLUE "32.301170 77.838134 -126.416203" +CUSP_MAGENTA "60.323069 96.208467 -79.513768" +# First come the triangle verticy location + +NUMBER_OF_FIELDS 4 +BEGIN_DATA_FORMAT +VERTEX_NO LAB_L LAB_A LAB_B +END_DATA_FORMAT + +NUMBER_OF_SETS 448 +BEGIN_DATA +0 53.23738 78.28787 62.14806 +1 87.73393 -87.88779 73.89835 +2 32.30117 77.83813 -126.4162 +3 97.13732 -23.76979 84.72131 +4 60.32307 96.20847 -79.51377 +5 92.08525 -14.24625 81.54220 +6 53.35447 78.60413 50.79253 +7 83.87564 2.280649 76.53540 +8 80.69498 9.066293 74.66362 +9 77.59264 15.90655 72.88418 +10 57.93092 64.97051 63.46530 +11 58.76878 62.66166 63.75963 +12 53.57668 79.20211 36.15187 +13 89.34786 -74.05441 75.78451 +14 71.68937 29.56313 69.65578 +15 65.07949 45.92003 66.37764 +16 73.12190 26.16928 70.41736 +17 66.31765 42.76656 66.95748 +18 53.65394 79.40935 32.13248 +19 90.79613 -62.92803 77.46661 +20 91.08723 -60.81567 77.80354 +21 53.94195 80.17896 19.76368 +22 92.04631 -54.11558 78.91087 +23 54.05752 80.48649 15.59671 +24 92.39458 -51.77448 79.31196 +25 95.22381 -34.29738 82.55042 +26 54.46471 81.56421 3.147035 +27 55.15354 83.36736 -13.13741 +28 58.23555 91.15715 -58.67024 +29 56.25383 86.19822 -32.74892 +30 43.82204 44.39999 -107.3457 +31 88.00240 -84.44335 56.17783 +32 53.79934 90.68751 -90.31915 +33 52.74215 89.83989 -92.07732 +34 88.28748 -80.88480 41.84045 +35 91.11332 -50.03183 -33.43706 +36 50.66167 88.21685 -95.54287 +37 55.47044 15.53048 -88.43226 +38 70.25635 -15.11766 -65.05040 +39 88.51724 -78.08693 32.21627 +40 49.64029 87.44353 -97.24689 +41 50.24043 27.91848 -96.87445 +42 90.56657 -55.46975 -23.06920 +43 12.35755 45.69866 -74.21878 +44 13.42559 47.41983 -77.01411 +45 90.39594 -57.21271 -19.59352 +46 67.01897 -70.33719 59.14136 +47 89.11856 -71.03844 11.82216 +48 74.03954 -22.07350 -59.19191 +49 90.07208 -60.58426 -12.62126 +50 49.46805 -55.46731 46.63837 +51 64.65829 -3.983664 -73.81222 +52 75.93542 -25.44286 -56.27611 +53 62.76463 -0.333769 -76.80409 +54 5.923683 34.08038 -56.46335 +55 89.62939 -65.33530 -2.130596 +56 2.837725 19.48753 -41.27759 +57 15.14731 -26.38932 20.02350 +58 28.15269 -37.40803 31.45365 +59 17.84328 -28.67346 22.94881 +60 30.63103 -39.50779 33.21918 +61 85.42860 -41.27479 -41.88407 +62 2.264561 15.55144 -36.92225 +63 87.32555 -44.25171 -39.05084 +64 3.487739 23.76854 -45.40603 +65 35.09873 57.77819 45.86665 +66 26.18016 47.69382 36.88900 +67 9.565408 -19.61366 13.12312 +68 1.765340 12.12314 -32.33317 +69 24.64917 45.96270 35.11541 +70 99.99887 -2.368094 -19.42243 +71 0.00000 0.00000 0.00000 +72 73.84328 58.41718 -58.35473 +73 89.01410 21.35955 -35.43485 +74 90.53891 17.92010 -33.18221 +75 92.08034 14.49254 -30.91473 +76 77.62019 48.68730 -52.56424 +77 13.46979 33.32198 20.41046 +78 86.02128 28.25508 -39.88383 +79 11.79010 31.42273 17.96859 +80 0.976169 6.703653 -22.41022 +81 78.94902 45.34606 -50.54002 +82 97.40326 -21.64644 65.83800 +83 84.55614 31.70238 -42.07507 +84 97.87024 -17.99013 42.20060 +85 99.53319 -5.650714 -9.059997 +86 99.24797 -7.696724 -2.125014 +87 99.11290 -8.675347 1.345406 +88 98.62197 -12.28656 15.20945 +89 99.38806 -6.688337 -5.594113 +90 98.98277 -9.624138 4.816620 +91 98.73735 -11.43000 11.75069 +92 98.85759 -10.54257 8.286179 +93 98.39120 0.957317 -21.73439 +94 0.679866 4.668850 -17.05062 +95 1.282928 -2.661895 1.764970 +96 0.129513 0.889405 -3.341660 +97 6.734616 -13.97338 9.265051 +98 5.199688 21.73802 7.977318 +99 99.52551 -4.488846 -20.16416 +100 98.18088 -10.71721 -22.27412 +101 10.15105 -14.53865 -3.319695 +102 3.938488 17.17106 6.042395 +103 1.664400 -0.998752 2.350220 +104 9.951148 -16.30966 1.630954 +105 93.99286 -32.42990 -28.87434 +106 10.39826 -12.29228 -8.240623 +107 93.73185 -33.92167 -29.28710 +108 0.825811 4.714559 -10.87949 +109 9.796380 -17.65318 6.241842 +110 8.071554 -4.789824 -17.08635 +111 1.547389 -0.845764 -5.058574 +112 7.646280 28.27220 -11.70984 +113 7.710785 -7.269724 -12.26130 +114 4.531049 -8.243161 2.713690 +115 10.19545 29.79051 12.06211 +116 8.489888 -1.807740 -21.73665 +117 10.69333 -9.569632 -13.07176 +118 15.40892 -23.12967 8.665202 +119 2.591700 3.044100 3.772876 +120 5.703828 26.49037 -25.86414 +121 3.285427 6.068621 4.837187 +122 3.595860 1.268164 -18.47215 +123 6.203050 28.34728 -30.40435 +124 4.382828 20.02936 -5.422344 +125 1.438284 6.595400 -1.333755 +126 20.91115 -7.754914 26.97127 +127 20.27872 -11.23640 26.16018 +128 13.07359 -16.15192 -4.356306 +129 19.75035 24.39195 -61.99173 +130 20.43478 27.07432 -65.41167 +131 7.513566 -10.57730 10.46011 +132 5.081402 -4.463716 -10.68033 +133 5.377705 -2.428913 -15.78637 +134 21.61150 -4.222775 27.85812 +135 7.464250 30.09177 -28.31670 +136 23.48754 27.82838 -69.36887 +137 19.23150 -17.78240 24.79616 +138 30.72092 34.40688 -83.56651 +139 21.24832 7.098725 28.18333 +140 22.37545 -0.689668 28.81160 +141 28.48420 11.23173 -56.81067 +142 5.221417 14.44958 7.807366 +143 27.77366 37.12635 37.13350 +144 3.555104 17.20435 -12.53467 +145 19.39693 11.38805 26.46904 +146 47.69490 -47.30841 18.78004 +147 6.482617 18.55770 9.742288 +148 36.73982 11.47773 -65.27615 +149 4.202949 18.92348 -0.781149 +150 62.93561 -64.00783 42.28715 +151 31.67616 21.19027 38.39296 +152 65.12867 -65.33676 41.60032 +153 4.665997 -7.316435 -0.768194 +154 17.39142 22.45460 24.82553 +155 20.90425 -25.78611 6.307888 +156 30.74844 -38.00092 25.43964 +157 26.66549 -0.0907351 -41.71491 +158 13.91397 34.50998 3.577564 +159 20.33466 3.625928 27.01590 +160 4.068001 18.04872 2.700735 +161 15.90574 -20.09466 21.08254 +162 30.98258 -35.10897 14.50448 +163 35.98428 -37.59082 12.34234 +164 11.88289 -5.858106 16.57264 +165 10.67036 -13.02915 14.77963 +166 33.49846 -36.37075 13.41920 +167 32.29113 14.26994 38.16461 +168 47.73146 -1.699098 -60.36746 +169 30.09398 36.68870 39.02219 +170 26.37535 -26.46001 -0.308204 +171 28.98639 -30.35030 32.42729 +172 15.46523 8.927534 21.70760 +173 20.77851 -27.26990 10.70253 +174 45.39094 -0.217804 -59.91525 +175 17.88513 -0.883742 -32.12110 +176 14.74711 23.14890 21.53339 +177 72.35878 -60.47868 11.85376 +178 91.67496 -32.25284 -32.33429 +179 92.20045 -29.29361 -31.50100 +180 92.48407 -27.72691 -31.05153 +181 77.63971 34.10040 -52.78188 +182 83.30741 27.60819 -44.07625 +183 13.76159 34.10536 8.718749 +184 75.54892 35.67697 -56.03791 +185 43.72381 -36.17902 -2.844653 +186 76.39551 30.21868 -54.79453 +187 76.21464 20.90006 -55.22162 +188 13.64415 33.79146 13.43158 +189 78.87199 21.14692 -51.06609 +190 87.32002 15.09810 -38.13399 +191 67.26989 26.66099 -69.28892 +192 38.66394 62.86364 10.75828 +193 35.19656 58.04239 36.57309 +194 83.65310 20.03624 -43.67447 +195 90.66601 -12.51473 -33.53344 +196 66.21972 13.60448 -71.12634 +197 89.57944 -17.91095 -35.26060 +198 37.26335 61.33582 8.675190 +199 23.91933 -23.30291 -3.733546 +200 2.682365 13.39937 -13.90632 +201 98.13709 -14.63988 14.48116 +202 74.35996 -7.603405 -58.51742 +203 74.68159 15.36010 -57.70693 +204 37.13105 60.98447 13.13676 +205 35.71721 59.43591 11.05708 +206 75.17492 17.17678 -56.90661 +207 27.77639 49.62372 31.59393 +208 67.91145 44.21902 -68.02449 +209 65.35662 20.31670 -72.42599 +210 11.42291 6.317206 16.36066 +211 88.37362 9.567093 -36.62598 +212 3.319578 15.58691 -7.053573 +213 88.56494 -11.00468 -36.68412 +214 74.63991 -6.294046 -58.06294 +215 27.98927 50.19579 18.84953 +216 31.00037 -16.59208 34.74828 +217 65.71297 30.38908 -71.73081 +218 36.82309 60.16166 26.36287 +219 67.26234 49.39310 -68.98901 +220 88.19055 -12.81586 -37.28068 +221 74.59533 4.811492 -57.98787 +222 74.20986 3.198571 -58.61370 +223 16.10752 19.55477 23.06607 +224 37.41096 61.72633 4.231548 +225 13.31752 -13.69400 -9.151914 +226 26.39913 48.28369 21.36279 +227 87.86746 7.522807 -37.43259 +228 98.90170 -7.761468 -17.43958 +229 35.24765 62.42859 -36.12594 +230 31.75068 -3.266419 -42.71249 +231 28.01680 52.51032 -19.93533 +232 59.44113 -50.08606 6.299467 +233 33.94732 61.18875 -38.24646 +234 96.00062 -29.01119 61.31600 +235 88.10144 -1.914353 -37.23781 +236 94.56869 -35.07831 27.10537 +237 95.28485 -31.26178 31.65535 +238 23.35841 44.90028 17.21548 +239 2.266959 10.54665 -3.751327 +240 28.09646 50.48229 14.21560 +241 34.34219 60.15362 -23.87512 +242 37.29636 63.54341 -23.72363 +243 26.33149 50.38084 -17.82716 +244 95.98191 -27.88023 39.51939 +245 2.446838 11.78193 -8.392522 +246 13.92629 -8.181595 -18.46406 +247 94.87371 -33.59099 31.05815 +248 98.29312 -11.03405 -14.68680 +249 95.37605 -30.49052 28.28770 +250 94.77740 -35.63092 53.89733 +251 27.50338 51.19254 -11.10843 +252 98.45214 -9.864210 -18.14280 +253 29.98711 53.20877 2.465737 +254 28.53656 51.64811 0.250181 +255 32.13856 -0.522276 -46.54584 +256 48.32618 -40.06980 -0.680740 +257 11.85507 -0.428976 -26.77594 +258 9.504890 10.87540 13.86657 +259 93.81622 -39.51807 26.00116 +260 74.52452 -61.48449 10.94735 +261 97.70015 -14.30301 -11.90641 +262 31.55372 -13.38783 35.37864 +263 10.42073 2.579694 14.87232 +264 97.44691 -17.41806 6.142709 +265 97.55030 -15.43866 -8.437139 +266 94.36246 -38.06815 53.33448 +267 13.04887 34.71996 -13.88170 +268 12.75360 33.96459 -9.020249 +269 93.39587 -43.35149 42.52101 +270 33.25497 17.34379 39.19856 +271 8.715568 -5.149986 12.30012 +272 97.78216 -16.07696 10.30740 +273 20.20364 -5.467953 -28.48208 +274 97.40563 -16.54334 -4.963766 +275 26.61730 51.10743 -22.18650 +276 34.30220 62.06052 -42.18343 +277 91.00758 -56.66178 11.02375 +278 70.70544 -53.57729 -1.864092 +279 14.10349 35.00912 -1.521011 +280 30.98682 57.86378 -38.52871 +281 9.512529 -1.287457 13.50842 +282 93.32611 -43.99792 45.67200 +283 94.01887 -37.70669 19.16257 +284 13.60171 -11.02093 -13.85885 +285 97.27410 -16.40380 -12.56932 +286 8.043309 -8.267045 11.27282 +287 93.43482 -40.88541 14.67685 +288 93.77435 -38.83760 15.18622 +289 90.89224 -57.84543 14.48903 +290 97.26612 -17.61638 -1.488146 +291 92.20520 -50.48310 34.11207 +292 91.36553 -55.25143 22.38978 +293 93.03680 -42.60741 6.773126 +294 92.86103 -42.47785 -4.573553 +295 96.97734 -18.69401 -5.625079 +296 24.91315 48.95145 -20.10400 +297 93.22558 -41.81436 10.72090 +298 92.74040 -42.78198 -8.474425 +299 97.13171 -18.65765 1.989240 +300 91.12239 -56.93396 22.03022 +301 92.69355 -47.79108 41.53138 +302 92.89173 -41.39279 -11.95982 +303 93.21108 -38.50587 -18.91533 +304 97.00239 -19.66648 5.465989 +305 93.04862 -39.96704 -15.44071 +306 91.52506 -54.47515 26.16916 +307 33.23674 -12.70974 -31.45839 +308 34.01268 10.28814 39.10684 +309 33.49186 -3.503987 37.56308 +310 11.50229 33.18357 -16.37708 +311 29.30833 55.73847 -36.66815 +312 8.335318 7.298841 12.08963 +313 91.90437 -52.48325 33.67877 +314 24.12544 -21.15564 -8.183776 +315 96.86083 -9.866071 16.50597 +316 77.95635 -13.90732 70.77213 +317 9.421928 32.61559 -30.32216 +318 62.64612 -31.14253 57.93254 +319 60.40093 -39.80946 -16.46213 +320 48.42654 -15.59406 -42.63749 +321 6.404035 -0.402050 9.127567 +322 7.276775 3.402146 10.46652 +323 48.84672 -34.55524 -12.53862 +324 61.81548 -36.45738 56.97924 +325 59.94608 24.49026 60.21842 +326 6.558180 11.46464 9.646398 +327 61.42550 11.70671 60.16647 +328 48.14355 -45.40177 46.12738 +329 49.32816 -19.97056 48.49804 +330 69.33594 -43.88677 -19.45580 +331 48.82496 -22.91644 47.92431 +332 47.73441 19.14651 50.39953 +333 49.07231 31.10492 52.63467 +334 49.32683 15.43808 51.26434 +335 45.27055 19.94972 48.60077 +336 39.82562 28.05534 45.30432 +337 48.13794 -18.00573 -38.88927 +338 70.36580 -33.95143 -37.58748 +339 38.79589 25.27193 44.22368 +340 25.20039 -11.21379 -25.49234 +341 49.77231 -4.182637 49.95564 +342 31.38055 -33.09592 34.09503 +343 22.54416 -9.789864 -24.85476 +344 35.74211 -14.05042 -32.06197 +345 37.37400 28.84882 43.53951 +346 47.71964 34.51697 51.98748 +347 34.91799 -21.38088 -19.94736 +348 49.04452 -32.55371 -16.46482 +349 34.26401 -13.90936 37.41112 +350 38.45770 31.53684 44.66277 +351 70.14358 -36.02027 -33.99700 +352 37.41040 -22.57751 -20.67675 +353 49.71131 -26.14210 -28.10082 +354 24.35665 -18.85029 -12.59794 +355 24.89436 -13.85676 -21.26108 +356 49.47680 -28.34128 -24.24881 +357 22.22683 -12.49970 -20.56104 +358 24.61284 -16.41120 -16.95794 +359 35.44970 -16.55937 -28.07054 +360 21.07184 47.77452 -45.56465 +361 23.34524 51.15712 -51.13708 +362 35.17491 -19.00693 -24.03086 +363 87.06072 9.370494 7.187899 +364 88.52195 5.174662 12.87112 +365 90.12211 1.800273 14.97607 +366 87.61671 12.88924 -7.151265 +367 82.81879 9.180387 41.93159 +368 85.62945 13.62353 1.468319 +369 86.93636 8.570999 10.77074 +370 31.85722 61.79949 -59.60587 +371 89.33123 10.42784 -8.524011 +372 87.46891 11.96257 -3.571796 +373 85.49520 12.78818 5.074162 +374 88.76889 6.798596 5.741566 +375 85.76969 14.49072 -2.135386 +376 85.36696 11.98546 8.677448 +377 92.22258 1.787498 2.973154 +378 90.62339 5.158576 0.784580 +379 91.96858 0.0543591 10.04090 +380 80.27421 19.38626 17.09611 +381 79.28559 25.73400 0.430351 +382 86.81771 7.803842 14.34869 +383 91.73611 -1.550446 17.08429 +384 81.21472 24.81315 -8.495535 +385 91.21404 9.016163 -13.38990 +386 82.75019 8.740551 45.22326 +387 81.18401 12.15713 43.52679 +388 92.09286 0.905013 6.508738 +389 78.77087 22.82519 15.14776 +390 92.35770 2.700996 -0.561588 +391 79.58249 27.38259 -6.915997 +392 81.50122 14.12340 29.79385 +393 83.37224 25.16023 -20.83636 +394 85.91600 15.38956 -5.735542 +395 83.94803 16.21828 2.971552 +396 83.14824 11.27243 28.15276 +397 79.74100 28.25421 -10.57715 +398 71.32551 47.57174 -18.68964 +399 90.90744 7.026770 -6.313611 +400 81.54185 26.64941 -15.76232 +401 78.88980 23.50318 11.47322 +402 85.04753 22.73359 -22.24328 +403 93.96598 -0.651517 1.647721 +404 93.47278 -4.094151 15.70088 +405 70.07438 50.78030 -20.50606 +406 85.22966 23.78332 -25.80187 +407 83.55337 26.18182 -24.41524 +408 12.49315 37.63989 -40.46678 +409 93.11594 7.722502 -18.17200 +410 79.01517 24.21397 7.793819 +411 89.90427 0.315521 22.02200 +412 92.95320 6.659334 -14.66383 +413 81.59492 14.69861 26.24226 +414 69.07035 46.34499 2.218811 +415 78.65839 22.18071 18.80973 +416 94.70243 4.342196 -15.90200 +417 77.68103 28.39026 2.114921 +418 81.41337 13.58186 33.30720 +419 80.16523 18.74256 20.72951 +420 69.53654 48.42463 -9.213966 +421 87.93016 14.83435 -14.28997 +422 87.83105 0.527343 37.31729 +423 70.49374 43.77068 0.154891 +424 94.46153 -12.13477 41.60092 +425 69.85356 40.76189 19.17483 +426 65.82409 56.94787 -10.42383 +427 85.87591 1.556436 51.72802 +428 74.82919 24.13317 53.16703 +429 8.603005 16.24850 -42.11586 +430 76.36188 20.99314 51.57010 +431 68.79887 45.11718 9.878961 +432 69.96660 41.29868 15.38241 +433 63.04901 60.16254 2.151702 +434 72.28298 32.67149 33.56793 +435 63.53064 61.96164 -9.575327 +436 73.19295 46.96005 -27.89996 +437 62.77183 59.11453 10.03247 +438 20.37149 48.00729 -51.43156 +439 73.40029 47.90326 -31.55172 +440 72.06443 31.53639 44.02758 +441 68.34758 43.04775 25.12152 +442 70.90211 36.02996 31.97182 +443 62.64618 58.63630 13.97813 +444 70.82030 35.62091 35.58479 +445 63.48416 54.93669 23.14435 +446 62.42032 57.77172 21.84206 +447 64.73492 66.34472 -32.42670 +END_DATA + + +# And then come the triangles + +NUMBER_OF_FIELDS 3 +BEGIN_DATA_FORMAT +VERTEX_0 VERTEX_1 VERTEX_2 +END_DATA_FORMAT + +NUMBER_OF_SETS 892 +BEGIN_DATA +13 1 46 +6 0 65 +4 32 72 +32 33 72 +25 3 82 +70 93 99 +99 93 100 +95 71 103 +63 35 107 +97 67 109 +104 97 109 +96 71 111 +108 96 111 +97 104 114 +104 101 114 +79 98 115 +110 113 117 +113 106 117 +67 57 118 +109 67 118 +104 109 118 +103 71 119 +62 56 120 +68 62 120 +119 71 121 +80 94 122 +116 80 122 +110 116 122 +64 54 123 +56 64 123 +120 56 123 +71 96 125 +96 108 125 +121 71 125 +102 121 125 +101 104 128 +106 101 128 +43 54 129 +54 64 129 +44 43 130 +43 129 130 +97 114 131 +106 113 132 +94 108 132 +108 111 132 +110 122 133 +122 94 133 +94 132 133 +113 110 133 +132 113 133 +120 123 135 +2 44 136 +44 130 136 +130 129 136 +59 57 137 +30 2 138 +2 136 138 +136 129 141 +138 136 141 +121 102 142 +69 66 143 +68 120 144 +120 112 144 +112 124 144 +60 50 146 +102 98 147 +142 102 147 +37 41 148 +41 30 148 +30 138 148 +138 141 148 +124 98 149 +50 46 150 +146 50 150 +1 31 152 +46 1 152 +150 46 152 +31 34 152 +34 146 152 +146 150 152 +71 95 153 +111 71 153 +101 106 153 +114 101 153 +95 114 153 +132 111 153 +106 132 153 +77 69 154 +69 143 154 +143 151 154 +151 145 154 +58 60 156 +141 129 157 +115 98 158 +139 140 159 +140 134 159 +98 102 160 +149 98 160 +127 137 161 +57 67 161 +137 57 161 +59 58 162 +58 156 162 +60 146 163 +156 60 163 +126 127 164 +134 126 164 +159 134 164 +67 97 165 +97 131 165 +127 161 165 +164 127 165 +161 67 165 +162 156 166 +156 163 166 +139 145 167 +145 151 167 +51 53 168 +53 37 168 +66 65 169 +143 66 169 +151 143 169 +162 166 170 +166 163 170 +60 58 171 +58 59 171 +59 137 171 +145 139 172 +139 159 172 +57 59 173 +118 57 173 +155 118 173 +59 162 173 +162 170 173 +170 155 173 +37 148 174 +168 37 174 +62 68 175 +56 62 175 +98 79 176 +147 98 176 +79 77 176 +77 154 176 +39 47 177 +61 63 178 +63 107 178 +107 105 178 +61 178 179 +178 105 179 +105 100 180 +179 105 180 +83 81 181 +78 83 182 +79 115 183 +115 158 183 +81 76 184 +181 81 184 +170 163 185 +83 181 186 +181 184 186 +77 79 188 +79 183 188 +182 83 189 +83 186 189 +186 187 189 +74 73 190 +187 186 191 +23 21 192 +12 6 193 +6 65 193 +65 66 193 +73 78 194 +78 182 194 +182 189 194 +190 73 194 +100 93 195 +37 53 196 +61 179 197 +179 180 197 +180 100 197 +100 195 197 +52 61 197 +26 23 198 +23 192 198 +104 118 199 +118 155 199 +128 104 199 +155 170 199 +94 80 200 +108 94 200 +80 68 200 +68 144 200 +38 48 202 +194 189 203 +21 18 204 +192 21 204 +198 192 205 +192 204 205 +187 191 206 +191 203 206 +189 187 206 +203 189 206 +193 66 207 +184 76 208 +2 30 208 +186 184 208 +41 37 209 +191 41 209 +37 196 209 +196 203 209 +203 191 209 +75 74 211 +74 190 211 +124 149 212 +144 124 212 +51 38 214 +38 202 214 +202 48 214 +137 127 216 +171 137 216 +30 41 217 +41 191 217 +208 30 217 +191 186 217 +186 208 217 +18 12 218 +12 193 218 +204 18 218 +215 204 218 +193 207 218 +36 40 219 +33 36 219 +72 33 219 +76 72 219 +208 76 219 +40 2 219 +2 208 219 +48 52 220 +52 197 220 +197 195 220 +195 213 220 +213 214 220 +214 48 220 +196 53 221 +203 196 221 +53 51 222 +221 53 222 +51 214 222 +154 145 223 +145 172 223 +176 154 223 +27 26 224 +26 198 224 +106 128 225 +66 69 226 +207 66 226 +215 218 226 +218 207 226 +75 211 227 +190 194 227 +211 190 227 +194 203 227 +203 221 227 +28 29 229 +34 39 232 +146 34 232 +39 177 232 +25 82 234 +82 84 234 +195 93 235 +213 195 235 +221 222 235 +222 214 235 +93 75 235 +75 227 235 +214 213 235 +227 221 235 +183 158 238 +188 183 238 +215 226 238 +69 77 238 +226 69 238 +77 188 238 +102 125 239 +160 102 239 +149 160 239 +212 149 239 +125 108 239 +205 204 240 +204 215 240 +215 238 240 +29 27 242 +229 29 242 +241 229 242 +84 237 244 +200 144 245 +144 212 245 +108 200 245 +212 239 245 +239 108 245 +116 110 246 +110 117 246 +85 70 248 +70 228 248 +88 201 249 +84 88 249 +237 84 249 +236 247 249 +247 237 249 +24 25 250 +25 234 250 +234 84 250 +84 244 250 +27 224 251 +242 27 251 +231 241 251 +241 242 251 +243 231 251 +70 99 252 +228 70 252 +99 100 252 +100 248 252 +248 228 252 +198 205 253 +224 198 253 +205 240 253 +238 158 254 +240 238 254 +253 240 254 +251 224 254 +224 253 254 +148 141 255 +141 157 255 +157 230 255 +174 148 255 +230 174 255 +163 146 256 +185 163 256 +146 232 256 +68 80 257 +80 116 257 +175 68 257 +116 246 257 +172 210 258 +223 172 258 +147 176 258 +176 223 258 +247 236 259 +177 47 260 +85 248 261 +127 126 262 +216 127 262 +126 134 262 +159 164 263 +172 159 263 +210 172 263 +92 90 264 +91 92 264 +89 85 265 +85 261 265 +22 24 266 +24 250 266 +250 244 266 +112 267 268 +267 251 268 +244 237 269 +237 247 269 +247 259 269 +167 151 270 +88 91 272 +91 264 272 +201 88 272 +249 201 272 +157 175 273 +175 257 273 +257 246 273 +86 89 274 +89 265 274 +241 231 275 +231 243 275 +4 28 276 +28 229 276 +229 233 276 +32 4 276 +55 47 277 +55 49 278 +47 55 278 +256 232 278 +260 47 278 +232 177 278 +177 260 278 +98 124 279 +158 98 279 +124 112 279 +251 254 279 +254 158 279 +112 268 279 +268 251 279 +276 233 280 +263 164 281 +164 271 281 +13 19 282 +19 20 282 +20 22 282 +22 266 282 +266 244 282 +244 269 282 +259 236 283 +236 249 283 +249 272 283 +117 106 284 +106 225 284 +246 117 284 +100 105 285 +248 100 285 +261 248 285 +265 261 285 +164 165 286 +165 131 286 +114 95 286 +95 271 286 +131 114 286 +271 164 286 +287 283 288 +277 47 289 +87 86 290 +86 274 290 +31 1 291 +287 289 292 +55 277 293 +49 55 294 +55 293 294 +274 265 295 +265 285 295 +290 274 295 +243 251 296 +251 267 296 +275 243 296 +277 289 297 +289 287 297 +293 277 297 +287 288 297 +45 49 298 +49 294 298 +90 87 299 +87 290 299 +264 90 299 +290 295 299 +295 294 299 +47 39 300 +39 292 300 +289 47 300 +292 289 300 +1 13 301 +13 282 301 +282 269 301 +269 259 301 +259 291 301 +291 1 301 +45 298 302 +294 295 302 +298 294 302 +35 42 303 +107 35 303 +105 107 303 +285 105 303 +295 285 303 +272 264 304 +283 272 304 +288 283 304 +294 293 304 +299 294 304 +264 299 304 +297 288 304 +293 297 304 +42 45 305 +45 302 305 +303 42 305 +302 295 305 +295 303 305 +39 34 306 +259 283 306 +283 287 306 +287 292 306 +291 259 306 +292 39 306 +230 157 307 +157 273 307 +140 139 308 +139 167 308 +167 270 308 +134 140 309 +262 134 309 +140 308 309 +112 120 310 +120 135 310 +267 112 310 +296 267 310 +229 241 311 +241 275 311 +233 229 311 +280 233 311 +258 210 312 +210 263 312 +34 31 313 +31 291 313 +291 306 313 +306 34 313 +199 170 314 +128 199 314 +225 128 314 +88 84 315 +5 3 316 +7 5 316 +123 54 317 +135 123 317 +296 310 317 +310 135 317 +3 25 318 +316 3 318 +25 24 318 +24 22 318 +278 49 319 +256 278 319 +38 51 320 +51 168 320 +168 174 320 +174 230 320 +95 103 321 +271 95 321 +281 271 321 +103 119 321 +119 121 322 +321 119 322 +263 281 322 +281 321 322 +312 263 322 +121 312 322 +185 256 323 +256 319 323 +19 13 324 +13 46 324 +46 50 324 +20 19 324 +22 20 324 +318 22 324 +17 14 325 +142 147 326 +147 258 326 +121 142 326 +312 121 326 +258 312 326 +9 8 327 +16 9 327 +50 60 328 +324 50 328 +316 318 329 +42 35 330 +45 42 330 +49 45 330 +319 49 330 +318 324 331 +329 318 331 +324 328 331 +17 325 332 +15 17 333 +17 332 333 +14 16 334 +325 14 334 +16 327 334 +332 325 334 +308 270 335 +333 332 335 +332 334 335 +334 308 335 +333 335 336 +52 48 337 +48 38 337 +38 320 337 +61 52 338 +270 151 339 +335 270 339 +336 335 339 +307 273 340 +8 7 341 +327 8 341 +7 316 341 +316 329 341 +334 327 341 +309 308 341 +308 334 341 +60 171 342 +171 216 342 +328 60 342 +331 328 342 +273 246 343 +340 273 343 +230 307 344 +320 230 344 +337 320 344 +151 169 345 +339 151 345 +336 339 345 +11 15 346 +15 333 346 +333 336 346 +170 185 347 +314 170 347 +323 319 348 +262 309 349 +216 262 349 +329 331 349 +309 341 349 +341 329 349 +331 342 349 +342 216 349 +65 0 350 +336 345 350 +169 65 350 +345 169 350 +0 10 350 +346 336 350 +10 11 350 +11 346 350 +35 63 351 +330 35 351 +63 61 351 +61 338 351 +319 330 351 +185 323 352 +347 185 352 +323 348 352 +52 337 353 +338 52 353 +351 338 353 +284 225 354 +225 314 354 +314 347 354 +340 343 355 +348 319 356 +319 351 356 +351 353 356 +352 348 356 +353 352 356 +246 284 357 +343 246 357 +355 343 357 +354 347 358 +347 355 358 +284 354 358 +357 284 358 +355 357 358 +344 307 359 +337 344 359 +353 337 359 +307 340 359 +275 296 360 +311 275 360 +44 2 361 +280 311 361 +311 360 361 +347 352 362 +352 353 362 +353 359 362 +340 355 362 +359 340 362 +355 347 362 +33 32 370 +32 276 370 +36 33 370 +40 36 370 +2 40 370 +361 2 370 +276 280 370 +280 361 370 +366 371 372 +364 369 374 +369 363 374 +363 373 374 +373 368 374 +372 368 375 +363 369 376 +373 363 376 +368 372 378 +374 368 378 +377 374 378 +92 91 379 +365 364 379 +364 374 379 +364 365 382 +369 364 382 +376 369 382 +380 376 382 +7 8 386 +8 9 387 +386 8 387 +367 386 387 +90 92 388 +92 379 388 +374 377 388 +379 374 388 +89 86 390 +85 89 390 +377 378 390 +384 381 391 +366 372 394 +372 375 394 +375 381 394 +381 384 394 +368 373 395 +375 368 395 +381 375 395 +373 376 395 +384 391 397 +70 85 399 +372 371 399 +378 372 399 +371 385 399 +85 390 399 +390 378 399 +384 397 400 +376 380 401 +395 376 401 +380 389 401 +73 74 402 +74 75 402 +400 393 402 +86 87 403 +87 90 403 +90 388 403 +388 377 403 +377 390 403 +390 86 403 +88 315 404 +315 383 404 +91 88 404 +379 91 404 +365 379 404 +383 365 404 +78 73 406 +73 402 406 +83 78 407 +393 400 407 +78 406 407 +402 393 407 +406 402 407 +54 43 408 +43 360 408 +317 54 408 +296 317 408 +360 296 408 +75 93 409 +381 395 410 +395 401 410 +315 84 411 +383 315 411 +382 365 411 +396 382 411 +365 383 411 +399 385 412 +385 409 412 +409 93 412 +382 396 413 +396 392 413 +389 380 415 +93 70 416 +412 93 416 +70 399 416 +399 412 416 +391 381 417 +381 410 417 +367 387 418 +392 396 418 +396 367 418 +380 382 419 +382 413 419 +413 392 419 +415 380 419 +397 391 420 +398 397 420 +405 398 420 +371 366 421 +385 371 421 +402 75 421 +366 394 421 +394 384 421 +384 400 421 +400 402 421 +75 409 421 +409 385 421 +3 5 422 +367 396 422 +396 411 422 +391 417 423 +420 391 423 +414 420 423 +82 3 424 +3 422 424 +84 82 424 +411 84 424 +422 411 424 +401 389 425 +389 415 425 +27 29 426 +29 405 426 +405 420 426 +420 414 426 +5 7 427 +422 5 427 +7 386 427 +386 367 427 +367 422 427 +64 56 429 +129 64 429 +157 129 429 +56 175 429 +175 157 429 +9 16 430 +387 9 430 +16 14 430 +14 428 430 +418 387 430 +414 423 431 +423 417 431 +410 401 432 +401 425 432 +417 410 432 +431 417 432 +26 27 433 +426 414 433 +415 419 434 +419 392 434 +27 426 435 +426 433 435 +433 27 435 +397 398 436 +400 397 436 +398 405 436 +407 400 436 +23 26 437 +26 433 437 +414 431 437 +433 414 437 +43 44 438 +44 361 438 +360 43 438 +361 360 438 +28 4 439 +436 28 439 +4 72 439 +81 83 439 +83 407 439 +407 436 439 +72 76 439 +76 81 439 +17 15 440 +14 17 440 +428 14 440 +392 418 440 +434 392 440 +418 430 440 +430 428 440 +0 6 441 +6 12 441 +425 415 442 +415 434 442 +10 0 442 +0 441 442 +441 425 442 +11 10 442 +21 23 443 +23 437 443 +437 431 443 +434 440 444 +440 15 444 +15 11 444 +11 442 444 +442 434 444 +12 18 445 +432 425 445 +425 441 445 +441 12 445 +431 432 445 +18 21 446 +445 18 446 +21 443 446 +443 431 446 +431 445 446 +29 28 447 +405 29 447 +28 436 447 +436 405 447 +END_DATA diff --git a/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..64563b3 --- /dev/null +++ b/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.800", + "green" : "0.478", + "red" : "0.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..64dc11e --- /dev/null +++ b/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "filename" : "icon_16x16.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "filename" : "icon_16x16@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "filename" : "icon_32x32.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "filename" : "icon_32x32@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "filename" : "icon_128x128.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "filename" : "icon_128x128@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "filename" : "icon_256x256.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "filename" : "icon_256x256@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "filename" : "icon_512x512.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "filename" : "icon_512x512@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png b/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png new file mode 100644 index 0000000..84a366e Binary files /dev/null and b/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png differ diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png b/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png new file mode 100644 index 0000000..0693bd9 Binary files /dev/null and b/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png differ diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png b/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png new file mode 100644 index 0000000..e684ea4 Binary files /dev/null and b/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png differ diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png b/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png new file mode 100644 index 0000000..a778fe1 Binary files /dev/null and b/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png differ diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png b/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png new file mode 100644 index 0000000..0693bd9 Binary files /dev/null and b/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png differ diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png b/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png new file mode 100644 index 0000000..ea56053 Binary files /dev/null and b/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png differ diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png b/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png new file mode 100644 index 0000000..a778fe1 Binary files /dev/null and b/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png differ diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png b/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png new file mode 100644 index 0000000..dd06966 Binary files /dev/null and b/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png differ diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png b/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png new file mode 100644 index 0000000..ea56053 Binary files /dev/null and b/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png differ diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png b/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png new file mode 100644 index 0000000..28e8f13 Binary files /dev/null and b/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png differ diff --git a/Resources/Assets.xcassets/Contents.json b/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resources/Assets.xcassets/ICCery-logo.imageset/Contents.json b/Resources/Assets.xcassets/ICCery-logo.imageset/Contents.json new file mode 100644 index 0000000..a1ec0b2 --- /dev/null +++ b/Resources/Assets.xcassets/ICCery-logo.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ICCery-logo.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/Resources/Assets.xcassets/ICCery-logo.imageset/ICCery-logo.svg b/Resources/Assets.xcassets/ICCery-logo.imageset/ICCery-logo.svg new file mode 100644 index 0000000..2b7b7e2 --- /dev/null +++ b/Resources/Assets.xcassets/ICCery-logo.imageset/ICCery-logo.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ICCery + diff --git a/Resources/ICCery.Debug.entitlements b/Resources/ICCery.Debug.entitlements new file mode 100644 index 0000000..1179d62 --- /dev/null +++ b/Resources/ICCery.Debug.entitlements @@ -0,0 +1,16 @@ + + + + + + com.apple.security.device.usb + + + com.apple.security.cs.disable-library-validation + + + diff --git a/Resources/ICCery.entitlements b/Resources/ICCery.entitlements new file mode 100644 index 0000000..d8671ee --- /dev/null +++ b/Resources/ICCery.entitlements @@ -0,0 +1,10 @@ + + + + + + com.apple.security.device.usb + + + diff --git a/Resources/dmg-background.png b/Resources/dmg-background.png new file mode 100644 index 0000000..f930d40 Binary files /dev/null and b/Resources/dmg-background.png differ diff --git a/Resources/dmg-background@2x.png b/Resources/dmg-background@2x.png new file mode 100644 index 0000000..732ce99 Binary files /dev/null and b/Resources/dmg-background@2x.png differ diff --git a/Sources/ICCery/AboutView.swift b/Sources/ICCery/AboutView.swift new file mode 100644 index 0000000..8fd604a --- /dev/null +++ b/Sources/ICCery/AboutView.swift @@ -0,0 +1,81 @@ +import AppKit +import SwiftUI +import ICCeryCore + +/// About dialog for ICCery (issue #31, docs/21 §Modals). +struct AboutView: View { + let onClose: () -> Void + + @State private var showingLicenses = false + + private let info = ArtefactFiles.appInfo() + + var body: some View { + VStack(spacing: 20) { + if let icon = NSImage(named: NSImage.applicationIconName) { + Image(nsImage: icon) + .resizable() + .scaledToFit() + .frame(height: 64) + } + + Text("ICCery") + .font(.title) + .foregroundStyle(Theme.text) + + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Version:") + .foregroundStyle(.secondary) + Text(info.version) + .foregroundStyle(Theme.text) + .accessibilityIdentifier("aboutVersion") + } + .accessibilityElement(children: .contain) + HStack { + Text("Build:") + .foregroundStyle(.secondary) + Text(info.build) + .foregroundStyle(Theme.text) + } + .accessibilityElement(children: .contain) + HStack { + Text("Build date:") + .foregroundStyle(.secondary) + Text(info.buildDate) + .foregroundStyle(Theme.text) + .accessibilityIdentifier("aboutBuildDate") + } + .accessibilityElement(children: .contain) + } + .font(.callout) + .accessibilityElement(children: .contain) + + Text("Native macOS printer profiling workstation.") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + Button("View Licenses") { + showingLicenses = true + } + .controlSize(.large) + .accessibilityIdentifier("viewLicensesBtn") + + Button("Close") { + onClose() + } + .controlSize(.large) + .keyboardShortcut(.cancelAction) + .accessibilityIdentifier("closeAboutBtn") + } + .padding(32) + .frame(width: 360) + .background(Theme.panel) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("aboutDialog") + .sheet(isPresented: $showingLicenses) { + LicenseWindowView() + } + } +} diff --git a/Sources/ICCery/AppEnvironment.swift b/Sources/ICCery/AppEnvironment.swift new file mode 100644 index 0000000..88d0bd5 --- /dev/null +++ b/Sources/ICCery/AppEnvironment.swift @@ -0,0 +1,153 @@ +import Foundation +import ICCeryCore + +/// App dependency container (docs/02). Production builds resolve the +/// user's `argyll_binary_dir` override or bundled sidecars; DEBUG UI +/// tests inject fixture binaries via `ICCERY_ARGYLL_BINARY_DIR` and +/// redirect `AppPaths` via `ICCERY_TEST_ROOT`, so tests never touch the +/// developer's settings, wizard state, or real Argyll install. +struct AppEnvironment: Sendable { + let stateStore: WizardStateStore + let settingsStore: SettingsStore + let presetStore: PresetStore + let runner: ArgyllRunner + let cupsService: CupsService + let historyStore: VerificationHistoryStore + let mediaStore: MediaLibraryStore + let recentProjectsStore: RecentProjectsStore + + static func live( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> AppEnvironment { + let settingsStore = SettingsStore() + var overrideDir = settingsStore.load().argyllBinaryDir + .map { URL(fileURLWithPath: $0) } + var bundledRoot = AppPaths.bundledArgyllDir + var cupsDir = URL(fileURLWithPath: "/usr/bin") + #if DEBUG + if let dir = environment["ICCERY_ARGYLL_BINARY_DIR"], !dir.isEmpty { + overrideDir = URL(fileURLWithPath: dir) + } + if let dir = environment["ICCERY_ARGYLL_BUNDLED_ROOT"], !dir.isEmpty { + bundledRoot = URL(fileURLWithPath: dir) + } + if let dir = environment["ICCERY_CUPS_BIN_DIR"], !dir.isEmpty { + cupsDir = URL(fileURLWithPath: dir) + } + #endif + return AppEnvironment( + stateStore: WizardStateStore(), + settingsStore: settingsStore, + presetStore: PresetStore(settingsStore: settingsStore), + runner: ArgyllRunner( + processManager: .shared, + binaryResolver: BinaryResolver( + bundledRoot: bundledRoot, overrideDir: overrideDir) + ), + cupsService: CupsService( + processManager: .shared, + binaryDir: cupsDir), + historyStore: VerificationHistoryStore(), + mediaStore: MediaLibraryStore(), + recentProjectsStore: RecentProjectsStore() + ) + } +} + +/// DEBUG-only UI-test hooks. When `ICCERY_UI_TESTING=1` the workflow +/// honours these env-provided paths instead of presenting modal panels +/// (XCUITest cannot drive NSOpenPanel/NSSavePanel reliably). These are +/// compiled out of release builds. +enum UITestHooks { + private static var env: [String: String] { + ProcessInfo.processInfo.environment + } + + static var isEnabled: Bool { + #if DEBUG + return env["ICCERY_UI_TESTING"] == "1" + #else + return false + #endif + } + + /// `select_target_file` result (Stage 1 save picker). + static var saveTargetURL: URL? { url("ICCERY_TEST_SAVE_TARGET") } + /// `select_existing_target` result (`.ti1`/`.ti2` resume). + static var existingTargetURL: URL? { url("ICCERY_TEST_EXISTING_TARGET") } + /// `select_directory` result (working-directory browse). + static var workDirURL: URL? { url("ICCERY_TEST_WORKDIR") } + /// Dataset import file (`.ti3`, `.txt`, `.cgats`, `.csv`). + static var datasetImportURL: URL? { url("ICCERY_TEST_DATASET_IMPORT") } + /// Preset import file. + static var presetImportURL: URL? { url("ICCERY_TEST_PRESET_IMPORT") } + /// Preset export destination. + static var presetExportURL: URL? { url("ICCERY_TEST_PRESET_EXPORT") } + /// Spot-read CSV export destination (`selectCsvSavePath`, #148). + static var csvExportURL: URL? { url("ICCERY_TEST_CSV_EXPORT") } + /// `.gam` compare picker result (gamut sheet, #147). Unset → cancel. + static var gamutFileURL: URL? { url("ICCERY_TEST_GAMUT_FILE") } + /// `.icc/.icm` compare picker result (gamut sheet, #147). Unset → cancel. + static var gamutProfileURL: URL? { url("ICCERY_TEST_GAMUT_PROFILE") } + /// TIFF sample picker result (gamut sheet, #147). Unset → cancel. + static var gamutTiffURL: URL? { url("ICCERY_TEST_GAMUT_TIFF") } + /// `selectProjectFile` result (`.icceryproj` open, #149). Unset → cancel. + static var projectOpenURL: URL? { url("ICCERY_TEST_PROJECT_OPEN") } + /// `selectProjectSavePath` result (`.icceryproj` save-as, #149). + static var projectSaveURL: URL? { url("ICCERY_TEST_PROJECT_SAVE") } + /// Relocate-folder result when a project's `cwd` is missing (#149). + static var projectRelocateURL: URL? { url("ICCERY_TEST_PROJECT_RELOCATE") } + /// Forces the gamut sheet into its no-Metal fallback even on a GPU + /// host (#147). Set per-test only — never in a default launch env, + /// or CI's future GPU run would skip SceneKit too. + static var skipSceneKit: Bool { + isEnabled && env["ICCERY_TEST_SKIP_SCENEKIT"] == "1" + } + + // MARK: - Print panel / CUPS stubs (issue 13/17) + + /// Directory of mock `lp`/`lpstat`/`lpoptions` fixture scripts — + /// `CupsService.binaryDir` under UI tests. + static var cupsBinaryDir: URL? { url("ICCERY_CUPS_BIN_DIR") } + + /// Path the mock `lp` script appends its argv to, for assertions. + static var lpArgvOutURL: URL? { url("ICCERY_TEST_LP_ARGV") } + + /// Whether the `NSPrintPanel` should be stubbed under UI testing — + /// separate from the stub's *result* so "cancel" (`nil`) does not + /// fall through to the real modal. + static var printPanelStubbed: Bool { isEnabled } + + /// Canned `NSPrintPanel` outcome — XCUITest cannot drive the + /// system modal. `ICCERY_TEST_PRINT_PANEL`: + /// - `cancel` (or unset while testing) → user cancelled → `nil` + /// - `ok` → `PrintPropertiesResult` with + /// `ICCERY_TEST_PANEL_OPTIONS` (captured `k=v` string) and + /// `ICCERY_TEST_PANEL_PRINTER` (selected queue; default = the + /// queue the panel was opened for). + static func printPanelResult(forQueue queue: String) -> PrintPropertiesResult? { + switch env["ICCERY_TEST_PRINT_PANEL"] { + case "ok": + let options = env["ICCERY_TEST_PANEL_OPTIONS"].flatMap { + $0.isEmpty ? nil : $0 + } + return PrintPropertiesResult( + selectedPrinter: env["ICCERY_TEST_PANEL_PRINTER"].flatMap { + $0.isEmpty ? nil : $0 + } ?? queue, + options: PrintOptions( + mediaType: options.flatMap { + CupsParsers.extractMediaType(fromOptionsString: $0) + }, + ppdUncorrectedPassthrough: true, + cupsOptions: options)) + default: + return nil + } + } + + private static func url(_ key: String) -> URL? { + guard let raw = env[key], !raw.isEmpty else { return nil } + return URL(fileURLWithPath: raw) + } +} diff --git a/Sources/ICCery/CalibrationView.swift b/Sources/ICCery/CalibrationView.swift new file mode 100644 index 0000000..3a0bcc0 --- /dev/null +++ b/Sources/ICCery/CalibrationView.swift @@ -0,0 +1,141 @@ +import SwiftUI +import ICCeryCore + +/// Stage 0 calibration dashboard (issue #29, docs/07). +struct CalibrationView: View { + @ObservedObject var model: CalibrationViewModel + @ObservedObject var wizard: WizardViewModel + + var body: some View { + VStack(spacing: 0) { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Text("Calibrate Printer") + .font(.title2.bold()) + .foregroundStyle(Theme.text) + wedgeSection + workflowSection + if !model.calibrationLog.isEmpty { logSection } + } + .padding(20) + .frame(maxWidth: .infinity, alignment: .leading) + } + .background(Theme.background) + + Divider().overlay(Theme.border) + + HStack { + Spacer() + Button("Return to Profiling", role: .cancel) { model.returnToProfiling() } + .keyboardShortcut(.cancelAction) + .accessibilityIdentifier("btnCalReturn") + } + .padding(16) + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("stage-cal") + } + + // MARK: - Wedge settings + + private var wedgeSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Wedge Settings").font(.headline).foregroundStyle(Theme.text) + Picker("Colour Space", selection: $model.colourSpace) { + Text("RGB").tag(ColourSpace.rgb) + Text("CMYK").tag(ColourSpace.cmyk) + } + .pickerStyle(.segmented) + .frame(maxWidth: 220) + + HStack(spacing: 12) { + Text("Steps per channel") + .foregroundStyle(Theme.text) + .frame(width: 140, alignment: .leading) + TextField("", value: $model.steps, format: .number) + .textFieldStyle(.roundedBorder) + .frame(width: 70) + .accessibilityIdentifier("calSteps") + } + + HStack(spacing: 12) { + Text("White patches") + .foregroundStyle(Theme.text) + .frame(width: 140, alignment: .leading) + TextField("", value: $model.whitePatches, format: .number) + .textFieldStyle(.roundedBorder) + .frame(width: 70) + } + + if model.colourSpace == .cmyk { + HStack(spacing: 12) { + Text("Ink-limit exploration") + .foregroundStyle(Theme.text) + .frame(width: 140, alignment: .leading) + TextField("", text: $model.inkLimit) + .textFieldStyle(.roundedBorder) + .frame(width: 70) + .accessibilityIdentifier("calInkExplore") + } + } + + Toggle("Neutral emphasis", isOn: $model.includeNeutralEmphasis) + .toggleStyle(.checkbox) + .foregroundStyle(Theme.text) + .accessibilityIdentifier("calNeutralEmphasis") + } + } + + // MARK: - Workflow + + private var workflowSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Workflow").font(.headline).foregroundStyle(Theme.text) + HStack(spacing: 12) { + Button("Generate Target") { model.generateTarget() } + .accessibilityIdentifier("btnCalGenerate") + .disabled(wizard.basename.isEmpty + || wizard.effectiveWorkingDirectory == nil + || model.isGenerating) + + Button("Create Layout & Print") { model.createLayout() } + .accessibilityIdentifier("btnCalLayout") + .disabled(wizard.basename.isEmpty + || wizard.effectiveWorkingDirectory == nil + || model.isGenerating) + + Button("Measure") { model.measureChart() } + .accessibilityIdentifier("btnCalMeasure") + .disabled(model.calibrationTi3URL == nil) + + Button("Compute Curves") { model.computeCurves() } + .accessibilityIdentifier("btnCalCompute") + .disabled(!model.canCompute) + } + + if let url = model.computedCalURL { + Toggle("Apply calibration to next profile", isOn: $model.applyToProfile) + .toggleStyle(.checkbox) + .foregroundStyle(Theme.text) + .onChange(of: model.applyToProfile) { _ in model.updateApplyToProfile() } + .accessibilityIdentifier("calApplyToggle") + + Text("Loaded: \(url.lastPathComponent)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + // MARK: - Log + + private var logSection: some View { + ProcessLogView( + lines: model.calibrationLog, + minHeight: 80, + maxHeight: 120, + containerId: "calLogContainer", + logId: "calLog" + ) + } +} diff --git a/Sources/ICCery/CalibrationViewModel.swift b/Sources/ICCery/CalibrationViewModel.swift new file mode 100644 index 0000000..5736609 --- /dev/null +++ b/Sources/ICCery/CalibrationViewModel.swift @@ -0,0 +1,200 @@ +import Combine +import Foundation +import SwiftUI +import ICCeryCore + +/// Stage 0 calibration workflow: generate wedge, print, measure, and +/// compute `.cal` curves. +@MainActor +final class CalibrationViewModel: ObservableObject { + + let workflow: TargetWorkflowViewModel + let profile: ProfileWorkflowViewModel + let environment: AppEnvironment + + // MARK: - Form state + + @Published var colourSpace: ColourSpace = .cmyk + @Published var steps: Int = 21 + @Published var whitePatches: Int = 4 + @Published var includeNeutralEmphasis: Bool = false + @Published var inkLimit: String = "320" + @Published var applyToProfile: Bool = false + @Published var computedCalURL: URL? + @Published var calibrationLog: [String] = [] + @Published var isGenerating = false + @Published var isComputing = false + + init(workflow: TargetWorkflowViewModel, profile: ProfileWorkflowViewModel, environment: AppEnvironment) { + self.workflow = workflow + self.profile = profile + self.environment = environment + } + + private var wizard: WizardViewModel { workflow.wizard } + + // MARK: - Derived + + var canGenerate: Bool { + !wizard.basename.isEmpty && wizard.effectiveWorkingDirectory != nil && !isGenerating + } + + var canCompute: Bool { + calibrationTi3URL != nil && !isComputing + } + + var calibrationTi3URL: URL? { + guard let cwd = wizard.effectiveWorkingDirectory else { return nil } + return cwd.appendingPathComponent("\(calBasename).ti3") + } + + private var identity: CalibrationIdentity { + CalibrationIdentity.parse( + liveBasename: wizard.basename, + persistedOriginal: wizard.calibrationOriginalBasename + ) + } + + private var calBasename: String { identity.calibrationBasename } + + private var calOutputURL: URL? { + guard let cwd = wizard.effectiveWorkingDirectory else { return nil } + return cwd.appendingPathComponent("\(calBasename).cal") + } + + // MARK: - Generate calibration target + + func generateTarget() { + guard canGenerate, let cwd = wizard.effectiveWorkingDirectory else { return } + // Snapshot the original (pre-CAL_) basename before changing the live one. + let identity = CalibrationIdentity.parse( + liveBasename: wizard.basename, + persistedOriginal: wizard.calibrationOriginalBasename + ) + wizard.calibrationOriginalBasename = identity.originalBasename + wizard.basename = identity.calibrationBasename + wizard.sessionMode = .calibration + + let config = CalibrationTargenConfig( + colourSpace: colourSpace, + steps: steps, + whitePatches: whitePatches, + includeNeutralEmphasis: includeNeutralEmphasis, + inkLimit: inkLimitValue, + basename: identity.originalBasename, + workingDirectory: cwd + ) + + Task { @MainActor in + do { + _ = try await ProcessRunSupport.runLogged( + setRunning: { self.isGenerating = $0 }, + resetLog: { self.calibrationLog = [] }, + onLog: { self.calibrationLog.append(contentsOf: $0) } + ) { onLog in + try await self.environment.runner.runCalibrationTargen( + config: config, onLogBatch: onLog) + } + self.wizard.refreshGating() + self.wizard.showNotice("Calibration target generated.") + self.wizard.go(to: .layOutPrint) + } catch { + self.wizard.showNotice( + "Calibration target failed: \(error.localizedDescription)", + kind: .error + ) + self.wizard.restoreCalibration() + } + } + } + + // MARK: - Layout, print, measure + + /// Hand off to the normal Stage 2/3 machinery using the `CAL_` basename. + /// After measurement, the user returns and presses Compute Curves. + func createLayout() { + wizard.sessionMode = .calibration + wizard.go(to: .layOutPrint) + } + + func measureChart() { + wizard.sessionMode = .calibration + wizard.go(to: .measure) + } + + // MARK: - Compute curves + + func computeCurves() { + guard canCompute, + let cwd = wizard.effectiveWorkingDirectory, + let outputURL = calOutputURL else { return } + + // Collision check: the Argyll `printcal` exit error contains + // "already exists" when the user declines overwrite. We do not + // silently clobber. + if FileManager.default.fileExists(atPath: outputURL.path) { + wizard.showNotice( + "\(outputURL.lastPathComponent) already exists. Rename or overwrite it first.", + kind: .error + ) + return + } + + let config = PrintcalConfig( + ti3Basename: calBasename, + workingDirectory: cwd, + outputURL: outputURL, + noInkLimit: false, + verify: false, + previousCalPath: nil, + totalInkLimit: inkLimitValue.map { Double($0) }, + channelLimits: [] + ) + + Task { @MainActor in + do { + let url = try await ProcessRunSupport.runLogged( + setRunning: { self.isComputing = $0 }, + resetLog: { self.calibrationLog = [] }, + onLog: { self.calibrationLog.append(contentsOf: $0) } + ) { onLog in + try await self.environment.runner.runPrintcal( + config: config, onLogBatch: onLog) + } + self.computedCalURL = url + self.profile.calibrationFile = url.path + self.profile.applyCalibration = self.applyToProfile + self.wizard.showNotice("Calibration curves computed.") + self.wizard.restoreCalibration() + } catch { + self.wizard.showNotice( + "Calibration curve computation failed: \(error.localizedDescription)", + kind: .error + ) + } + } + } + + // MARK: - Apply toggle + + func updateApplyToProfile() { + profile.applyCalibration = applyToProfile + if applyToProfile, let url = computedCalURL { + profile.calibrationFile = url.path + } else if applyToProfile { + // User toggled on before computing; keep the path if already set. + } else { + profile.applyCalibration = false + } + } + + func returnToProfiling() { + wizard.restoreCalibration() + wizard.go(to: .generate) + } + + private var inkLimitValue: Int? { + guard colourSpace == .cmyk else { return nil } + return Int(inkLimit) + } +} diff --git a/Sources/ICCery/DriftChartView.swift b/Sources/ICCery/DriftChartView.swift new file mode 100644 index 0000000..e2e8fa9 --- /dev/null +++ b/Sources/ICCery/DriftChartView.swift @@ -0,0 +1,121 @@ +import SwiftUI +import ICCeryCore + +/// Minimal line chart for drift history without depending on the +/// `Charts` framework link. Renders avg/max series with shaded quality +/// bands. +struct DriftChartView: View { + let records: [VerificationRecord] + + var body: some View { + GeometryReader { geometry in + let width = geometry.size.width + let height = geometry.size.height + + ZStack(alignment: .topLeading) { + if let (_, _, _, maxV) = scales(in: height) { + // Quality bands — bottom (red, > 3.5) drawn first, then + // orange, yellow, green so the upper-most bands overlay. + band(from: 3.5, to: maxV, color: .red.opacity(0.12), height: height, maxValue: maxV) + band(from: 2.0, to: 3.5, color: .orange.opacity(0.12), height: height, maxValue: maxV) + band(from: 1.0, to: 2.0, color: .yellow.opacity(0.12), height: height, maxValue: maxV) + band(from: 0.0, to: 1.0, color: .green.opacity(0.12), height: height, maxValue: maxV) + } + + if !records.isEmpty, let (minT, maxT, minV, maxV) = scales(in: height) { + // Average ΔE series + Path { path in + for (index, record) in records.enumerated() { + let pt = point( + for: record, + minTime: minT, + maxTime: maxT, + minValue: minV, + maxValue: maxV, + width: width, + height: height, + keyPath: \.avgDE + ) + if index == 0 { + path.move(to: pt) + } else { + path.addLine(to: pt) + } + } + } + .stroke(Color.blue, lineWidth: 2) + .accessibilityIdentifier("driftAvgSeries") + + // Max ΔE series + Path { path in + for (index, record) in records.enumerated() { + let pt = point( + for: record, + minTime: minT, + maxTime: maxT, + minValue: minV, + maxValue: maxV, + width: width, + height: height, + keyPath: \.maxDE + ) + if index == 0 { + path.move(to: pt) + } else { + path.addLine(to: pt) + } + } + } + .stroke(Color.orange, lineWidth: 2) + .accessibilityIdentifier("driftMaxSeries") + } else { + Text("No data") + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } + } + + private func band( + from lower: Double, + to upper: Double, + color: Color, + height: CGFloat, + maxValue: Double + ) -> some View { + let yTop = valueY(lower, minValue: 0, maxValue: maxValue, height: height) + let yBottom = valueY(upper, minValue: 0, maxValue: maxValue, height: height) + return color + .frame(height: yBottom - yTop) + .offset(y: yTop) + } + + private func scales(in height: CGFloat) -> (Date, Date, Double, Double)? { + guard let minT = records.first?.timestamp, let maxT = records.last?.timestamp else { return nil } + let maxV = max(records.map { max($0.avgDE, $0.maxDE) }.max() ?? 5.0, 5.0) + return (minT, maxT, 0.0, maxV) + } + + private func point( + for record: VerificationRecord, + minTime: Date, + maxTime: Date, + minValue: Double, + maxValue: Double, + width: CGFloat, + height: CGFloat, + keyPath: KeyPath + ) -> CGPoint { + let timeSpan = max(1, maxTime.timeIntervalSince(minTime)) + let x = width * CGFloat(record.timestamp.timeIntervalSince(minTime) / timeSpan) + let y = valueY(record[keyPath: keyPath], minValue: minValue, maxValue: maxValue, height: height) + return CGPoint(x: x, y: y) + } + + private func valueY(_ value: Double, minValue: Double, maxValue: Double, height: CGFloat) -> CGFloat { + let valueSpan = max(1, maxValue - minValue) + return height - height * CGFloat((value - minValue) / valueSpan) + } +} diff --git a/Sources/ICCery/FileDialogService.swift b/Sources/ICCery/FileDialogService.swift new file mode 100644 index 0000000..7b30144 --- /dev/null +++ b/Sources/ICCery/FileDialogService.swift @@ -0,0 +1,162 @@ +import AppKit +import UniformTypeIdentifiers + +/// Dedicated NSOpenPanel / NSSavePanel wrappers (issue #6) — one method +/// per purpose, matching the v1 `select_*` commands (docs/21 §Dialogs). +/// No call site shares a generic picker (#103/#210/#211). +@MainActor +final class FileDialogService { + + static let shared = FileDialogService() + private init() {} + + // MARK: - selectDirectory + + /// `#btnBrowse` — working directory for Argyll artefacts. + /// Defaults to Documents (docs/06 §Empty cwd). + func selectDirectory(startingAt start: URL? = nil) -> URL? { + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.allowsMultipleSelection = false + panel.directoryURL = start + ?? FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first + panel.prompt = "Choose" + return run(panel) + } + + // MARK: - Dedicated open pickers + + /// `selectTargetFile` — **save** panel for the new `.ti1` target. + func selectTargetFile(startingAt start: URL? = nil) -> URL? { + save(named: "target.ti1", extensions: ["ti1"], startingAt: start, + message: "Choose the .ti1 target file to create") + } + + /// `selectExistingTarget` — open `.ti1`/`.ti2` (docs/06 §Resume, #140). + func selectExistingTarget(startingAt start: URL? = nil) -> URL? { + open(extensions: ["ti1", "ti2"], startingAt: start, + message: "Open an existing target (.ti1 or .ti2)") + } + + /// `selectProfileFile` — `.icc`/`.icm`/`.mpp` only — **never** `.ti*` + /// (#172: the profile filter must not accept datasets). + func selectProfileFile(startingAt start: URL? = nil) -> URL? { + open(extensions: ["icc", "icm", "mpp"], startingAt: start, + message: "Choose an ICC/ICM profile or measurement preconditioning file") + } + + /// `selectSpectrumFile` — `.sp` illuminant spectrum (colprof -i). + func selectSpectrumFile(startingAt start: URL? = nil) -> URL? { + open(extensions: ["sp"], startingAt: start, + message: "Choose a custom illuminant spectrum (.sp)") + } + + /// `selectDatasetFile` — open a measured dataset (`.ti3`, `.txt`, + /// `.cgats`, `.csv`). Always an *open* dialog, never save (#211). + func selectDatasetFile(startingAt start: URL? = nil) -> URL? { + open(extensions: ["ti3", "txt", "cgats", "csv"], startingAt: start, + message: "Import a measured dataset") + } + + /// `selectCsvSavePath` — verification-history CSV export. + func selectCsvSavePath(startingAt start: URL? = nil) -> URL? { + save(named: "verification-history.csv", extensions: ["csv"], startingAt: start) + } + + /// `selectCalFile` — `.cal` calibration curves. + func selectCalFile(startingAt start: URL? = nil) -> URL? { + open(extensions: ["cal"], startingAt: start, + message: "Choose a calibration file (.cal)") + } + + /// `selectGamutFile` — `.gam` surface mesh for the compare slot (#147). + func selectGamutFile(startingAt start: URL? = nil) -> URL? { + open(extensions: ["gam"], startingAt: start, + message: "Choose a .gam surface mesh", + allowsOtherFileTypes: false) + } + + /// `selectTiffFile` — `.tif`/`.tiff` target page for gamut sampling (#147). + func selectTiffFile(startingAt start: URL? = nil) -> URL? { + open(extensions: ["tif", "tiff"], startingAt: start, + message: "Choose a target TIFF page") + } + + /// `btnImportPreset` — open a `.json` preset file. + func selectPresetFile(startingAt start: URL? = nil) -> URL? { + open(extensions: ["json"], startingAt: start, + message: "Import a profiling preset (.json)") + } + + /// `btnExportActivePreset` — save a `.json` preset file. + func selectPresetSavePath(name: String, startingAt start: URL? = nil) -> URL? { + save(named: "\(name).json", extensions: ["json"], startingAt: start, + message: "Export this preset as JSON") + } + + /// `selectProjectFile` — open `.icceryproj` only (issue #149). + func selectProjectFile(startingAt start: URL? = nil) -> URL? { + open(extensions: ["icceryproj"], startingAt: start, + message: "Open ICCery Project", + title: "Open ICCery Project", + allowsOtherFileTypes: false) + } + + /// `selectProjectSavePath` — save `.icceryproj`, suggested name + /// `{basename}.icceryproj` in the working folder (issue #149). + func selectProjectSavePath( + basename: String, startingAt start: URL? = nil + ) -> URL? { + save(named: "\(basename).icceryproj", extensions: ["icceryproj"], + startingAt: start, message: "Save ICCery Project") + } + + // MARK: - Internals (private — not a shared public picker API) + + private func save( + named: String, + extensions: [String], + startingAt start: URL?, + message: String? = nil + ) -> URL? { + let panel = NSSavePanel() + panel.nameFieldStringValue = named + panel.allowedContentTypes = utTypes(extensions) + panel.allowsOtherFileTypes = false + panel.directoryURL = start + if let message { panel.message = message } + return run(panel) + } + + private func open( + extensions: [String], + startingAt start: URL?, + message: String?, + title: String? = nil, + allowsOtherFileTypes: Bool = true + ) -> URL? { + let panel = NSOpenPanel() + panel.canChooseDirectories = false + panel.canChooseFiles = true + panel.allowsMultipleSelection = false + panel.allowedContentTypes = utTypes(extensions) + panel.allowsOtherFileTypes = allowsOtherFileTypes + panel.directoryURL = start + if let message { panel.message = message } + if let title { panel.title = title } + return run(panel) + } + + private func utTypes(_ extensions: [String]) -> [UTType] { + extensions.compactMap { UTType(filenameExtension: $0) } + } + + private func run(_ panel: NSOpenPanel) -> URL? { + panel.runModal() == .OK ? panel.url : nil + } + + private func run(_ panel: NSSavePanel) -> URL? { + panel.runModal() == .OK ? panel.url : nil + } +} diff --git a/Sources/ICCery/GamutView.swift b/Sources/ICCery/GamutView.swift new file mode 100644 index 0000000..63bfdcf --- /dev/null +++ b/Sources/ICCery/GamutView.swift @@ -0,0 +1,916 @@ +import SwiftUI +import SceneKit +import Metal +import ICCeryCore +import simd + +/// Builds a SceneKit geometry from a `GamutMesh` while dropping faces whose +/// indices are not backed by a vertex in the source mesh. +@MainActor +internal struct GamutSceneGeometryBuilder { + static func geometry(for mesh: GamutMesh) -> (SCNGeometry, SCNGeometryElement) { + let positions = mesh.vertices.map { $0.position } + let positionData = positions.withUnsafeBytes { Data($0) } + let positionSource = SCNGeometrySource( + data: positionData, + semantic: .vertex, + vectorCount: positions.count, + usesFloatComponents: true, + componentsPerVector: 3, + bytesPerComponent: MemoryLayout.size, + dataOffset: 0, + dataStride: MemoryLayout>.stride + ) + + let colors: [SIMD4] = mesh.vertices.map { v in + SIMD4(Float(v.rgb.r), Float(v.rgb.g), Float(v.rgb.b), 1.0) + } + let colorData = colors.withUnsafeBytes { Data($0) } + let colorSource = SCNGeometrySource( + data: colorData, + semantic: .color, + vectorCount: colors.count, + usesFloatComponents: true, + componentsPerVector: 4, + bytesPerComponent: MemoryLayout.size, + dataOffset: 0, + dataStride: MemoryLayout>.stride + ) + + let vcount = mesh.vertices.count + var indices: [UInt32] = [] + var validFaces: [GamutTriangle] = [] + indices.reserveCapacity(mesh.faces.count * 3) + for face in mesh.faces { + guard Int(face.a) < vcount, Int(face.b) < vcount, Int(face.c) < vcount else { + continue + } + indices.append(face.a) + indices.append(face.b) + indices.append(face.c) + validFaces.append(face) + } + let data = indices.withUnsafeBytes { Data($0) } + let element = SCNGeometryElement( + data: data, + primitiveType: .triangles, + primitiveCount: validFaces.count, + bytesPerIndex: 4 + ) + + let geometry = SCNGeometry(sources: [positionSource, colorSource], elements: [element]) + return (geometry, element) + } +} + +/// Native SceneKit 3D gamut viewer (issues #28, #147). +/// +/// Displays the bundled `sRGB.gam` reference plus up to two profile +/// meshes with independent visibility toggles, a status line, and an +/// inspect panel (click a mesh, type a Lab value, or sample a TIFF +/// pixel). Uses the CIELAB coordinate convention `x = a*`, `y = L*`, +/// `z = b*`. +struct GamutView: View { + @StateObject private var viewModel: GamutViewModel + @State private var pause: () -> Void = {} + @FocusState private var isFocused: Bool + @Environment(\.dismiss) private var dismiss + @Binding var showingAllHelp: Bool + + init( + environment: AppEnvironment, + profileGamURL: URL? = nil, + showingAllHelp: Binding + ) { + _viewModel = StateObject(wrappedValue: GamutViewModel( + environment: environment, profileGamURL: profileGamURL)) + _showingAllHelp = showingAllHelp + } + + var body: some View { + VStack(spacing: 0) { + toolbar + Divider().overlay(Theme.border) + sceneArea + Divider().overlay(Theme.border) + statusLine + inspectPanel + Divider().overlay(Theme.border) + footer + } + .frame(minWidth: 720, minHeight: 520) + .background(Theme.background) + .onDisappear { pause() } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("gamutView") + .sheet(isPresented: $viewModel.showingTiffPreview) { + tiffPreviewSheet + } + } + + // MARK: - Toolbar + + private var toolbar: some View { + HStack(spacing: 12) { + layerToggle(id: GamutViewModel.srgbLayerID, fallback: "sRGB") + layerToggle(id: GamutViewModel.profileLayerID, fallback: "Profile") + layerToggle(id: GamutViewModel.compareLayerID, fallback: "Compare") + Spacer() + addCompareMenu + Button("Remove compare") { viewModel.removeCompare() } + .disabled(viewModel.layer(id: GamutViewModel.compareLayerID) == nil) + .accessibilityIdentifier("btnGamutRemoveCompare") + Button("Sample TIFF…") { viewModel.openTiffSample() } + .accessibilityIdentifier("btnGamutSampleTiff") + .helpOverlay( + "Sample a colour from a target TIFF page.", + showing: $showingAllHelp) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + + private func layerToggle(id: String, fallback: String) -> some View { + let layer = viewModel.layer(id: id) + return Toggle(isOn: Binding( + get: { layer != nil && viewModel.visibleIDs.contains(id) }, + set: { on in + if on { + viewModel.visibleIDs.insert(id) + } else { + viewModel.visibleIDs.remove(id) + } + } + )) { + Text(layer?.displayName ?? fallback) + } + .toggleStyle(.checkbox) + .disabled(layer == nil) + .help(layer.map { $0.sourceURL.lastPathComponent } ?? "No profile .gam loaded") + // macOS 12 puts the identifier on the Toggle's container, an + // element that never reports isEnabled — combine so the a11y + // leaf is the checkbox itself. + .accessibilityElement(children: .combine) + .accessibilityIdentifier("gamutLayer-\(id)") + } + + private var addCompareMenu: some View { + Menu("Add compare…") { + Button("Open .gam…") { viewModel.openCompareGam() } + .accessibilityIdentifier("btnGamutOpenGam") + Button("Open profile…") { viewModel.openCompareProfile() } + .accessibilityIdentifier("btnGamutOpenProfile") + } + .accessibilityIdentifier("btnGamutAddCompare") + .helpOverlay( + "Add a second profile or .gam mesh to compare against.", + showing: $showingAllHelp) + } + + // MARK: - Scene + + private var sceneArea: some View { + ZStack(alignment: .topTrailing) { + if viewModel.viewerUnavailable { + Text("3D gamut viewer is unavailable on this Mac; the rest of ICCery still works.") + .font(.callout) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .accessibilityIdentifier("gamutViewerUnavailable") + } else { + GamutSceneView( + layers: viewModel.layers, + visibleIDs: viewModel.visibleIDs, + onReset: $viewModel.resetCamera, + onPause: $pause, + onUnavailable: { viewModel.viewerUnavailable = true }, + onInspect: { point, layerID in + if let layerID { + viewModel.inspectSceneHit(world: point, layerID: layerID) + } else { + viewModel.clearInspect() + } + } + ) + .focusable() + .focused($isFocused) + .onAppear { isFocused = true } + } + Button(action: { viewModel.resetCamera() }) { + Text("Reset view") + } + .accessibilityIdentifier("btnResetGamutCamera") + .padding(8) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Status + + private var statusLine: some View { + HStack(spacing: 10) { + Text(viewModel.status) + .font(.caption) + .foregroundStyle(Theme.text) + .accessibilityIdentifier("gamutStatusText") + if let notice = viewModel.noticeText { + Text(notice) + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityIdentifier("gamutNoticeText") + } + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + } + + // MARK: - Inspect panel + + private var inspectPanel: some View { + HStack(spacing: 12) { + if let lab = viewModel.inspectLab { + inspectSwatch + labReadout(lab) + containmentColumn + if viewModel.inspectIsApproximate { + Text("approx. Lab, not ColorSync") + .font(.caption2) + .foregroundStyle(.secondary) + .accessibilityIdentifier("gamutInspectApprox") + } + } else { + Text("Click the mesh, or enter Lab, to inspect.") + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityIdentifier("gamutInspectIdle") + } + Spacer() + labEntryFields + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(minHeight: 56) + .background(Theme.panel) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("gamutInspectPanel") + .helpOverlay( + "Inspect a Lab point against each loaded gamut.", + showing: $showingAllHelp) + } + + @ViewBuilder + private var inspectSwatch: some View { + if let swatch = viewModel.inspectSwatch { + Color(red: swatch.r, green: swatch.g, blue: swatch.b) + .frame(width: 16, height: 16) + .clipShape(RoundedRectangle(cornerRadius: 2)) + .overlay(RoundedRectangle(cornerRadius: 2).stroke(Theme.border)) + .accessibilityIdentifier("gamutInspectSwatch") + } + } + + private func labReadout(_ lab: LabColor) -> some View { + HStack(spacing: 10) { + Text(String(format: "L %.1f", lab.l)) + .accessibilityIdentifier("gamutInspectL") + Text(String(format: "a %.1f", lab.a)) + .accessibilityIdentifier("gamutInspectA") + Text(String(format: "b %.1f", lab.b)) + .accessibilityIdentifier("gamutInspectB") + } + .font(.caption.monospacedDigit()) + .foregroundStyle(Theme.text) + } + + private var containmentColumn: some View { + HStack(spacing: 10) { + ForEach(viewModel.inspectResults, id: \.id) { result in + Text("\(result.name) \(containmentWord(result.containment))") + .font(.caption) + .foregroundStyle(Theme.text) + .accessibilityIdentifier("gamutInspect-\(result.id)") + } + } + } + + private func containmentWord(_ containment: GamutContainment) -> String { + switch containment { + case .inside: return "in" + case .outside: return "out" + case .unknown: return "?" + } + } + + private var labEntryFields: some View { + HStack(spacing: 6) { + Text("Lab 0–100 · ±128") + .font(.caption2) + .foregroundStyle(.secondary) + TextField("L", text: $viewModel.labEntryL) + .textFieldStyle(.roundedBorder) + .frame(width: 56) + .accessibilityIdentifier("gamutLabEntryL") + TextField("a", text: $viewModel.labEntryA) + .textFieldStyle(.roundedBorder) + .frame(width: 56) + .accessibilityIdentifier("gamutLabEntryA") + TextField("b", text: $viewModel.labEntryB) + .textFieldStyle(.roundedBorder) + .frame(width: 56) + .accessibilityIdentifier("gamutLabEntryB") + Button("Inspect") { viewModel.inspectEnteredLab() } + .disabled(!viewModel.canInspectLab) + .accessibilityIdentifier("btnGamutInspectLab") + } + } + + // MARK: - Footer + + /// Always-visible Close (#147) — the fallback banner keeps it + /// reachable and Escape works via `.cancelAction` without SceneKit. + private var footer: some View { + HStack { + Spacer() + Button("Close") { dismiss() } + .keyboardShortcut(.cancelAction) + .accessibilityIdentifier("btnCloseGamut") + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + + // MARK: - TIFF sample sheet + + private var tiffPreviewSheet: some View { + VStack(spacing: 12) { + Text("Click a pixel to sample its colour.") + .font(.headline) + .foregroundStyle(Theme.text) + if let png = viewModel.tiffPreviewPNG { + TiffSampleImageView(pngData: png) { r, g, b in + viewModel.sampleTiffPixel(r: r, g: g, b: b) + } + .frame(minWidth: 320, minHeight: 240) + } + HStack { + Spacer() + Button("Cancel") { viewModel.showingTiffPreview = false } + .accessibilityIdentifier("btnCloseGamutTiffPreview") + } + } + .padding(16) + .background(Theme.background) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("gamutTiffPreview") + } +} + +/// `NSViewRepresentable` wrapper around an `SCNView` rendering one node +/// per ``NamedGamut`` layer. +/// +/// Layer toggles hide/show `SCNNode`s — the scene is built once and the +/// camera is only reset through the explicit reset path, never on a +/// mesh or visibility update. +private struct GamutSceneView: NSViewRepresentable { + var layers: [NamedGamut] + var visibleIDs: Set + var onReset: Binding<() -> Void> + var onPause: Binding<() -> Void> + var onUnavailable: () -> Void + var onInspect: (SIMD3, String?) -> Void + + func makeNSView(context: Context) -> SCNView { + let scnView = SCNView() + scnView.backgroundColor = NSColor(red: 0.055, green: 0.055, blue: 0.078, alpha: 1) + scnView.allowsCameraControl = true + scnView.showsStatistics = false + scnView.antialiasingMode = .multisampling4X + + let scene = SCNScene() + scnView.scene = scene + scnView.autoenablesDefaultLighting = false + + context.coordinator.scnView = scnView + context.coordinator.scene = scene + context.coordinator.onInspect = onInspect + context.coordinator.buildSceneOnce() + context.coordinator.syncLayers(layers, visibleIDs: visibleIDs) + context.coordinator.installKeyMonitor() + context.coordinator.installClickGesture() + + // Safety net only — the primary no-Metal check is + // `GamutSceneAvailability.isAvailable`, evaluated before this + // view is mounted. Never respawn the view in a loop. + if MTLCreateSystemDefaultDevice() == nil { + DispatchQueue.main.async { onUnavailable() } + } + + return scnView + } + + func updateNSView(_ nsView: SCNView, context: Context) { + context.coordinator.onInspect = onInspect + context.coordinator.syncLayers(layers, visibleIDs: visibleIDs) + } + + func makeCoordinator() -> Coordinator { + let coordinator = Coordinator() + onReset.wrappedValue = { [weak coordinator] in + coordinator?.resetCamera() + } + onPause.wrappedValue = { [weak coordinator] in + coordinator?.pause() + } + return coordinator + } + + static func dismantleNSView(_ nsView: SCNView, coordinator: Coordinator) { + coordinator.removeKeyMonitor() + if let click = coordinator.clickGesture { + nsView.removeGestureRecognizer(click) + } + nsView.isPlaying = false + } + + @MainActor + final class Coordinator: NSObject { + weak var scnView: SCNView? + weak var scene: SCNScene? + var onInspect: (SIMD3, String?) -> Void = { _, _ in } + private(set) var clickGesture: NSClickGestureRecognizer? + private var keyMonitor: Any? + + /// One `SCNNode` per loaded layer, keyed by `NamedGamut.id`. + private var layerNodes: [String: SCNNode] = [:] + private let axisNode = SCNNode() + private let layerGroup = SCNNode() + private let cameraNode: SCNNode = { + let node = SCNNode() + node.camera = SCNCamera() + node.camera?.zFar = 2000 + return node + }() + + /// Builds the static scene furniture exactly once — axis + /// scaffold, lights, camera home. Layer content lives under + /// `layerGroup` and is managed by `syncLayers`. + func buildSceneOnce() { + guard let scene, scene.rootNode.childNodes.isEmpty else { return } + + scene.rootNode.addChildNode(axisNode) + scene.rootNode.addChildNode(layerGroup) + scene.rootNode.addChildNode(cameraNode) + + buildAxisScaffold() + addLights(to: scene) + resetCamera() + } + + /// Reconciles the node set with `layers` and `visibleIDs`. + /// + /// New layers get a node; removed layers lose theirs; hidden + /// layers keep their mesh (`isHidden` only). Never rebuilds the + /// scene, so the camera is untouched by a checkbox toggle. + func syncLayers(_ layers: [NamedGamut], visibleIDs: Set) { + guard scene != nil else { return } + let wanted = Set(layers.map { $0.id }) + for (id, node) in layerNodes where !wanted.contains(id) { + node.removeFromParentNode() + layerNodes.removeValue(forKey: id) + } + for layer in layers { + if layerNodes[layer.id] == nil { + let node = makeLayerNode(for: layer) + layerNodes[layer.id] = node + layerGroup.addChildNode(node) + } + layerNodes[layer.id]?.isHidden = !visibleIDs.contains(layer.id) + } + } + + private func makeLayerNode(for layer: NamedGamut) -> SCNNode { + let node: SCNNode + switch layer.role { + case .reference: + node = referenceMeshNode(layer.mesh) + case .profileA: + node = profileMeshNode(layer.mesh) + case .profileB: + node = compareMeshNode(layer.mesh) + } + node.name = layer.id + return node + } + + // MARK: - Click inspect (#147) + + /// Click (not drag) hit-tests the scene. `NSClickGestureRecognizer` + /// only fires on a press+release in place, so orbit drags are + /// untouched. + func installClickGesture() { + guard let scnView, clickGesture == nil else { return } + let gesture = NSClickGestureRecognizer(target: self, action: #selector(handleClick(_:))) + scnView.addGestureRecognizer(gesture) + clickGesture = gesture + } + + @objc private func handleClick(_ gesture: NSClickGestureRecognizer) { + guard let scnView else { return } + let point = gesture.location(in: scnView) + for hit in scnView.hitTest(point, options: nil) { + if let layerID = layerID(for: hit.node) { + let world = hit.worldCoordinates + onInspect( + SIMD3(Float(world.x), Float(world.y), Float(world.z)), + layerID) + return + } + } + // Axis scaffold / empty background → back to idle. + onInspect(.zero, nil) + } + + /// Walks the hit node's ancestor chain looking for a layer node. + private func layerID(for node: SCNNode) -> String? { + var current: SCNNode? = node + while let node = current { + if let name = node.name, layerNodes[name] != nil { return name } + current = node.parent + } + return nil + } + + // MARK: - Scene furniture (unchanged from #28) + + private func addLights(to scene: SCNScene) { + let ambient = SCNNode() + ambient.light = SCNLight() + ambient.light?.type = .ambient + ambient.light?.color = NSColor.white + ambient.light?.intensity = 750 + scene.rootNode.addChildNode(ambient) + + let key = SCNNode() + key.light = SCNLight() + key.light?.type = .directional + key.light?.color = NSColor.white + key.light?.intensity = 800 + key.position = SCNVector3(150, 250, 150) + key.look(at: SCNVector3(0, 50, 0)) + scene.rootNode.addChildNode(key) + + let fill = SCNNode() + fill.light = SCNLight() + fill.light?.type = .directional + fill.light?.color = NSColor.white + fill.light?.intensity = 350 + fill.position = SCNVector3(-120, -80, -120) + fill.look(at: SCNVector3(0, 50, 0)) + scene.rootNode.addChildNode(fill) + } + + private func buildAxisScaffold() { + axisNode.childNodes.forEach { $0.removeFromParentNode() } + + // Bounding box: a*,b* ±128, L* 0–100. + let box = buildWireBox(size: SIMD3(256, 100, 256), color: NSColor(red: 0.137, green: 0.137, blue: 0.212, alpha: 0.9)) + box.position = SCNVector3(0, 50, 0) + axisNode.addChildNode(box) + + // Ground grid at y=0. + axisNode.addChildNode(buildGridNode()) + + // Axis lines. + axisNode.addChildNode(buildLineNode( + from: SIMD3(0, 0, 0), + to: SIMD3(0, 100, 0), + color: NSColor(red: 0.8, green: 0.8, blue: 0.8, alpha: 1.0) + )) + let abAxisColor = NSColor(red: 0.6, green: 0.733, blue: 0.8, alpha: 1.0) + axisNode.addChildNode(buildLineNode( + from: SIMD3(-128, 0, 0), + to: SIMD3(128, 0, 0), + color: abAxisColor + )) + axisNode.addChildNode(buildLineNode( + from: SIMD3(0, 0, -128), + to: SIMD3(0, 0, 128), + color: abAxisColor + )) + } + + private func buildWireBox(size: SIMD3, color: NSColor) -> SCNNode { + let hx = size.x / 2 + let hy = size.y / 2 + let hz = size.z / 2 + + let corners: [SIMD3] = [ + SIMD3(-hx, -hy, -hz), SIMD3(hx, -hy, -hz), + SIMD3(hx, -hy, hz), SIMD3(-hx, -hy, hz), + SIMD3(-hx, hy, -hz), SIMD3(hx, hy, -hz), + SIMD3(hx, hy, hz), SIMD3(-hx, hy, hz), + ] + + // 12 edges, two vertices each. + let edges: [(Int, Int)] = [ + (0,1), (1,2), (2,3), (3,0), + (4,5), (5,6), (6,7), (7,4), + (0,4), (1,5), (2,6), (3,7), + ] + + var points: [SIMD3] = [] + for (a, b) in edges { + points.append(corners[a]) + points.append(corners[b]) + } + + return lineNode(points: points, color: color) + } + + private func buildGridNode() -> SCNNode { + let divisions = 16 + let half = Float(128) + let step = (half * 2) / Float(divisions) + + var points: [SIMD3] = [] + for i in 0...divisions { + let v = -half + step * Float(i) + // X-aligned + points.append(SIMD3(-half, 0, v)) + points.append(SIMD3(half, 0, v)) + // Z-aligned + points.append(SIMD3(v, 0, -half)) + points.append(SIMD3(v, 0, half)) + } + + let gridColor = NSColor(red: 0.118, green: 0.118, blue: 0.157, alpha: 1.0) + return lineNode(points: points, color: gridColor) + } + + private func buildLineNode(from: SIMD3, to: SIMD3, color: NSColor) -> SCNNode { + return lineNode(points: [from, to], color: color) + } + + /// Builds a line-set from a flat list of point pairs. + /// + /// Uses data-backed `SCNGeometrySource` so it works with `simd` vectors + /// and avoids the SceneKit convenience-initializer label mismatch. + private func lineNode(points: [SIMD3], color: NSColor) -> SCNNode { + let source = source(for: points) + + let count = points.count + var indices: [UInt32] = [] + indices.reserveCapacity(count) + for i in 0.. SCNNode { + let (geometry, _) = scnGeometry(for: mesh) + + let material = SCNMaterial() + material.lightingModel = .lambert + material.diffuse.contents = NSColor.white + material.transparency = 0.88 + material.isDoubleSided = true + geometry.materials = [material] + + return SCNNode(geometry: geometry) + } + + /// Compare profile B: same vertex colours at ~30 % opacity so + /// overlaps with A and the sRGB reference stay readable. + private func compareMeshNode(_ mesh: GamutMesh) -> SCNNode { + let (geometry, _) = scnGeometry(for: mesh) + + let material = SCNMaterial() + material.lightingModel = .lambert + material.diffuse.contents = NSColor.white + material.transparency = 0.30 + material.isDoubleSided = true + material.writesToDepthBuffer = false + geometry.materials = [material] + + return SCNNode(geometry: geometry) + } + + /// Bundled sRGB reference: faint fill + structural edge lines. + private func referenceMeshNode(_ mesh: GamutMesh) -> SCNNode { + let (geometry, _) = scnGeometry(for: mesh) + + // Faint fill. + let fillMaterial = SCNMaterial() + fillMaterial.lightingModel = .lambert + fillMaterial.diffuse.contents = NSColor(red: 0.533, green: 0.6, blue: 0.733, alpha: 1.0) + fillMaterial.transparency = 0.93 + fillMaterial.isDoubleSided = true + fillMaterial.writesToDepthBuffer = false + geometry.materials = [fillMaterial] + + let fillNode = SCNNode(geometry: geometry) + + // Structural outline: one line per triangle edge. + var linePoints: [SIMD3] = [] + let vcount = mesh.vertices.count + for face in mesh.faces + where Int(face.a) < vcount && Int(face.b) < vcount && Int(face.c) < vcount { + let va = mesh.vertices[Int(face.a)].position + let vb = mesh.vertices[Int(face.b)].position + let vc = mesh.vertices[Int(face.c)].position + linePoints.append(va); linePoints.append(vb) + linePoints.append(vb); linePoints.append(vc) + linePoints.append(vc); linePoints.append(va) + } + + let edgeColor = NSColor(red: 0.4, green: 0.533, blue: 0.667, alpha: 0.55) + let edgeNode = lineNode(points: linePoints, color: edgeColor) + + let group = SCNNode() + group.addChildNode(fillNode) + group.addChildNode(edgeNode) + return group + } + + /// Returns an `SCNGeometry` with per-vertex positions and sRGB colours. + /// + /// Uses data-backed `SCNGeometrySource` initializers; this is the only + /// path that supports vertex colours through the `.color` semantic. + private func scnGeometry(for mesh: GamutMesh) -> (SCNGeometry, SCNGeometryElement) { + GamutSceneGeometryBuilder.geometry(for: mesh) + } + + /// Shared helper for data-backed position sources. + private func source(for points: [SIMD3]) -> SCNGeometrySource { + let data = points.withUnsafeBytes { Data($0) } + return SCNGeometrySource( + data: data, + semantic: .vertex, + vectorCount: points.count, + usesFloatComponents: true, + componentsPerVector: 3, + bytesPerComponent: MemoryLayout.size, + dataOffset: 0, + dataStride: MemoryLayout>.stride + ) + } + + func pause() { + scnView?.isPlaying = false + } + + /// Local key-down monitor for the R camera-reset shortcut (the + /// SwiftUI key-press modifier is unavailable on macOS 12). Only + /// events aimed at this view's window are handled; everything + /// else passes through untouched. + func installKeyMonitor() { + guard keyMonitor == nil else { return } + keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { + [weak self] event in + guard let self, + let scnView = self.scnView, + event.window === scnView.window, + event.charactersIgnoringModifiers?.uppercased() == "R" + else { return event } + self.resetCamera() + return nil + } + } + + func removeKeyMonitor() { + if let keyMonitor { + NSEvent.removeMonitor(keyMonitor) + self.keyMonitor = nil + } + } + + func resetCamera() { + guard let scnView else { return } + + // Re-create the camera node so `allowsCameraControl` starts from the + // canonical home position every time. + let newCameraNode = SCNNode() + newCameraNode.camera = SCNCamera() + newCameraNode.camera?.zFar = 2000 + + let eye = SIMD3(180, 120, 180) + let target = SIMD3(0, 50, 0) + newCameraNode.simdTransform = lookAt(eye: eye, target: target, up: SIMD3(0, 1, 0)) + + if let scene = scnView.scene, scene.rootNode.childNodes.contains(cameraNode) { + cameraNode.removeFromParentNode() + } + scnView.scene?.rootNode.addChildNode(newCameraNode) + scnView.pointOfView = newCameraNode + } + + private func lookAt(eye: SIMD3, target: SIMD3, up: SIMD3) -> simd_float4x4 { + let forward = normalize(target - eye) + let right = normalize(cross(up, forward)) + let newUp = cross(forward, right) + + var matrix = simd_float4x4() + matrix.columns.0 = SIMD4(right, 0) + matrix.columns.1 = SIMD4(newUp, 0) + matrix.columns.2 = SIMD4(-forward, 0) + matrix.columns.3 = SIMD4(eye, 1) + return matrix + } + } +} + +/// Click-to-sample image view for the TIFF preview sheet (#147). +/// +/// The TIFF is already decoded to PNG on the host side (#58); the view +/// reports 8-bit sRGB pixel values at the clicked point — the Lab +/// conversion is the documented approximate matrix helper, not a CMM. +private struct TiffSampleImageView: NSViewRepresentable { + let pngData: Data + var onSample: (Int, Int, Int) -> Void + + func makeNSView(context: Context) -> TiffSampleNSView { + let view = TiffSampleNSView() + view.image = NSImage(data: pngData) + view.onSample = onSample + return view + } + + func updateNSView(_ nsView: TiffSampleNSView, context: Context) { + nsView.onSample = onSample + } +} + +private final class TiffSampleNSView: NSView { + var image: NSImage? { + didSet { + bitmapRep = image?.cgImage(forProposedRect: nil, context: nil, hints: nil) + .flatMap { NSBitmapImageRep(cgImage: $0) } + invalidateIntrinsicContentSize() + needsDisplay = true + } + } + var onSample: ((Int, Int, Int) -> Void)? + private var bitmapRep: NSBitmapImageRep? + + override var intrinsicContentSize: NSSize { + image?.size ?? NSSize(width: 320, height: 240) + } + + override var acceptsFirstResponder: Bool { true } + + override func draw(_ dirtyRect: NSRect) { + NSColor(red: 0.055, green: 0.055, blue: 0.078, alpha: 1).setFill() + dirtyRect.fill() + guard let image else { return } + image.draw(in: imageRect()) + } + + override func mouseUp(with event: NSEvent) { + guard let rep = bitmapRep else { return } + let rect = imageRect() + let location = convert(event.locationInWindow, from: nil) + guard rect.contains(location), rect.width > 0, rect.height > 0 else { return } + + let x = Int((location.x - rect.minX) / rect.width * CGFloat(rep.pixelsWide)) + // This view is not flipped: y grows up, bitmap rows grow down. + let y = rep.pixelsHigh - 1 + - Int((location.y - rect.minY) / rect.height * CGFloat(rep.pixelsHigh)) + guard x >= 0, x < rep.pixelsWide, y >= 0, y < rep.pixelsHigh else { return } + + guard let color = rep.colorAt(x: x, y: y)?.usingColorSpace(.sRGB) else { return } + onSample?( + Int((color.redComponent * 255).rounded()), + Int((color.greenComponent * 255).rounded()), + Int((color.blueComponent * 255).rounded())) + } + + /// Aspect-fit rect of the image inside `bounds`. + private func imageRect() -> NSRect { + guard let image, image.size.width > 0, image.size.height > 0 else { return .zero } + let scale = min(bounds.width / image.size.width, bounds.height / image.size.height) + let size = NSSize(width: image.size.width * scale, height: image.size.height * scale) + return NSRect( + x: (bounds.width - size.width) / 2, + y: (bounds.height - size.height) / 2, + width: size.width, + height: size.height) + } +} diff --git a/Sources/ICCery/GamutViewModel.swift b/Sources/ICCery/GamutViewModel.swift new file mode 100644 index 0000000..f7f7e75 --- /dev/null +++ b/Sources/ICCery/GamutViewModel.swift @@ -0,0 +1,321 @@ +import Combine +import Foundation +import ICCeryCore +import Metal +import simd + +/// Whether the SceneKit gamut scene can render on this host (#147). +/// +/// Checked **before** `GamutSceneView` is mounted — constructing an +/// `SCNView` on a Metal-less machine can wedge the main thread, which +/// also stalls app quit behind the open sheet. +enum GamutSceneAvailability { + static var isAvailable: Bool { + if UITestHooks.skipSceneKit { return false } + return MTLCreateSystemDefaultDevice() != nil + } +} + +/// View model for the native SceneKit gamut viewer (issues #28, #147). +/// +/// Loads the bundled `sRGB.gam` reference immediately, the workflow's own +/// profile `.gam` when one exists, and an optional compare mesh the user +/// adds from the sheet toolbar. `iccgamut` failure is an in-sheet info +/// notice, never fatal (#24). +@MainActor +final class GamutViewModel: ObservableObject { + + /// Stable layer ids — also the `gamutLayer-` a11y suffixes. + static let srgbLayerID = "sRGB" + static let profileLayerID = "profile" + static let compareLayerID = "compare" + + /// Loaded meshes: bundled sRGB plus up to two profiles. + @Published var layers: [NamedGamut] = [] + + /// Layer ids currently shown in the scene. Toggling never unloads + /// the mesh — the `SCNNode` is hidden only. + @Published var visibleIDs: Set = [srgbLayerID] + + /// User-facing status line (`gamutStatusText`). Always non-empty + /// once set — `Milestone6GamutUITests` asserts it. + @Published var status = "Loading gamut…" + + /// In-sheet info line (`gamutNoticeText`). The main `NoticeBanner` + /// sits behind the sheet, so notices surface here instead. + @Published var noticeText: String? + + /// Set when `SCNView` cannot create a render context; the scene is + /// replaced by the docs/18 fallback text (`gamutViewerUnavailable`). + @Published var viewerUnavailable = false + + /// Closure injected into the SceneKit view to request a camera reset. + @Published var resetCamera: () -> Void = {} + + // MARK: - Inspect panel + + /// Lab point currently inspected, or `nil` for the idle state. + @Published var inspectLab: LabColor? + + /// Swatch colour: the hit vertex's `rgb`, or the approximate sRGB of + /// the inspected Lab. + @Published var inspectSwatch: DisplayRGB? + + /// `true` when the swatch/Lab came from the approximate helper or a + /// typed value — drives the "approx. Lab, not ColorSync" caption. + @Published var inspectIsApproximate = false + + /// Per-layer containment for `inspectLab`, in layer order. + @Published var inspectResults: [(id: String, name: String, containment: GamutContainment)] = [] + + /// Manual Lab entry fields (`gamutLabEntry*`). + @Published var labEntryL = "" + @Published var labEntryA = "" + @Published var labEntryB = "" + + // MARK: - TIFF sampling + + /// PNG bytes for the preview sheet (`gamutTiffPreview`). + @Published var tiffPreviewPNG: Data? + @Published var showingTiffPreview = false + + private let environment: AppEnvironment + private let profileGamURL: URL? + private let fileDialogs = FileDialogService.shared + + init(environment: AppEnvironment, profileGamURL: URL? = nil) { + self.environment = environment + self.profileGamURL = profileGamURL + // Never let the view mount an SCNView without Metal (#147). + viewerUnavailable = !GamutSceneAvailability.isAvailable + loadTask = Task { await load() } + } + + private var loadTask: Task? + + /// Awaits the initial sRGB/profile load — used by tests. + func awaitInitialLoad() async { + await loadTask?.value + } + + func layer(id: String) -> NamedGamut? { + layers.first { $0.id == id } + } + + // MARK: - Initial load + + private func load() async { + do { + let referenceURL = environment.runner.binaryResolver.referenceGamut("sRGB") + let reference = try await parse(url: referenceURL) + layers.append(NamedGamut( + id: Self.srgbLayerID, + displayName: "sRGB", + role: .reference, + mesh: reference, + sourceURL: referenceURL)) + visibleIDs.insert(Self.srgbLayerID) + } catch { + status = "Could not load gamut: \(error.localizedDescription)" + return + } + + if let profileGamURL { + do { + let profile = try await parse(url: profileGamURL) + layers.append(NamedGamut( + id: Self.profileLayerID, + displayName: profileGamURL.deletingPathExtension().lastPathComponent, + role: .profileA, + mesh: profile, + sourceURL: profileGamURL)) + visibleIDs.insert(Self.profileLayerID) + } catch { + // #24 — a missing/unparseable profile mesh is info, not fatal. + noticeText = "Profile gamut could not be loaded: \(error.localizedDescription)" + } + } + refreshStatus() + } + + // MARK: - Compare slot (profile B) + + /// `btnGamutOpenGam` — pick an existing `.gam` for the compare slot. + func openCompareGam() { + let url = UITestHooks.isEnabled + ? UITestHooks.gamutFileURL + : fileDialogs.selectGamutFile() + guard let url else { return } + Task { await loadCompareGam(url: url) } + } + + /// `btnGamutOpenProfile` — pick `.icc/.icm`; uses a sibling `.gam` + /// when present, otherwise runs bundled `iccgamut -v -d 10` (#24). + func openCompareProfile() { + let url = UITestHooks.isEnabled + ? UITestHooks.gamutProfileURL + : fileDialogs.selectProfileFile() + guard let url else { return } + Task { await loadCompareProfile(url: url) } + } + + /// `btnGamutRemoveCompare` — drops layer B, leaves sRGB + A. + func removeCompare() { + layers.removeAll { $0.id == Self.compareLayerID } + visibleIDs.remove(Self.compareLayerID) + refreshStatus() + } + + /// Parses `url` into the compare slot. Internal for tests — the UI + /// reaches it through `openCompareGam` / `openCompareProfile`. + func loadCompareGam(url: URL) async { + do { + let mesh = try await parse(url: url) + installCompare(NamedGamut( + id: Self.compareLayerID, + displayName: url.deletingPathExtension().lastPathComponent, + role: .profileB, + mesh: mesh, + sourceURL: url)) + } catch { + noticeText = "Could not load compare gamut: \(error.localizedDescription)" + } + } + + /// `.icc/.icm` → sibling `.gam` or `iccgamut` → compare slot. + /// Internal for tests. + func loadCompareProfile(url: URL) async { + let gamURL = url.deletingPathExtension().appendingPathExtension("gam") + do { + if !FileManager.default.fileExists(atPath: gamURL.path) { + _ = try await environment.runner.runIccgamut( + config: IccgamutConfig(profileURL: url)) + } + await loadCompareGam(url: gamURL) + } catch { + noticeText = "Gamut extraction failed: \(error.localizedDescription)" + } + } + + /// A third profile replaces B — the compare slot never stacks and + /// sRGB is never touched. + private func installCompare(_ gamut: NamedGamut) { + if layers.contains(where: { $0.id == gamut.id }) { + noticeText = "Compare slot holds one profile. The previous compare mesh was replaced." + } + layers.removeAll { $0.id == gamut.id } + layers.append(gamut) + visibleIDs.insert(gamut.id) + refreshStatus() + } + + // MARK: - Status line + + /// `sRGB 448v / 892 faces · Profile 1024v / 2048 faces · vol 62% of sRGB`. + /// Unloaded layers are omitted; the volume clause appears only when + /// both volumes are finite and positive. + private func refreshStatus() { + var clauses = layers.map { + "\($0.displayName) \($0.mesh.vertices.count)v / \($0.mesh.faces.count) faces" + } + if let srgb = layer(id: Self.srgbLayerID), + let profile = layer(id: Self.profileLayerID) { + let srgbVolume = GamutGeometry.volume(of: srgb.mesh) + let profileVolume = GamutGeometry.volume(of: profile.mesh) + if srgbVolume.isFinite, srgbVolume > 0, + profileVolume.isFinite, profileVolume > 0 { + clauses.append("vol \(Int((profileVolume / srgbVolume * 100).rounded()))% of sRGB") + } + } + status = clauses.isEmpty ? "No gamut loaded" : clauses.joined(separator: " · ") + } + + // MARK: - Inspect + + /// Whether every manual Lab field parses as a number; the Inspect + /// button is disabled while this is false. + var canInspectLab: Bool { + [labEntryL, labEntryA, labEntryB].allSatisfy { Double($0) != nil } + } + + /// Runs containment for a Lab point and publishes the inspect row. + func inspect(lab: LabColor, swatch: DisplayRGB?, isApproximate: Bool) { + inspectLab = lab + inspectSwatch = swatch ?? LabColorMath.labToSRGB(lab) + inspectIsApproximate = isApproximate + inspectResults = layers.map { + ($0.id, $0.displayName, GamutGeometry.containment(of: lab, in: $0.mesh)) + } + } + + /// Click on the axis scaffold or empty background returns the panel + /// to idle. + func clearInspect() { + inspectLab = nil + inspectSwatch = nil + inspectIsApproximate = false + inspectResults = [] + } + + /// SceneKit hit callback: world `(x, y, z)` → Lab `(x→a*, y→L*, z→b*)`. + /// The swatch is the nearest vertex colour of the hit layer's mesh. + func inspectSceneHit(world: SIMD3, layerID: String) { + let lab = LabColor(l: Double(world.y), a: Double(world.x), b: Double(world.z)) + var swatch: DisplayRGB? + var approximate = true + if let mesh = layer(id: layerID)?.mesh, + let nearest = mesh.vertices.min(by: { + simd_distance($0.position, world) < simd_distance($1.position, world) + }) { + swatch = nearest.rgb + approximate = false + } + inspect(lab: lab, swatch: swatch, isApproximate: approximate) + } + + /// `btnGamutInspectLab` — typed L*a*b* path. No clamping; out-of-axis + /// values still run containment and report `?` outside every hull. + func inspectEnteredLab() { + guard let l = Double(labEntryL), + let a = Double(labEntryA), + let b = Double(labEntryB) else { return } + inspect(lab: LabColor(l: l, a: a, b: b), swatch: nil, isApproximate: true) + } + + // MARK: - TIFF sampling + + /// `btnGamutSampleTiff` — pick a target TIFF, decode a host-side PNG + /// preview (#58), and open the click-to-sample sheet. + func openTiffSample() { + let url = UITestHooks.isEnabled + ? UITestHooks.gamutTiffURL + : fileDialogs.selectTiffFile() + guard let url else { return } + guard let png = TiffPreview.previewPNG(tiff: url) else { + noticeText = "Could not decode TIFF preview." + return + } + tiffPreviewPNG = png + showingTiffPreview = true + } + + /// Pixel tap inside the preview sheet: sRGB8 → approximate Lab D50. + func sampleTiffPixel(r: Int, g: Int, b: Int) { + inspect( + lab: ApproximateLab.srgb8ToLab(r: r, g: g, b: b), + swatch: DisplayRGB( + r: Double(r) / 255.0, + g: Double(g) / 255.0, + b: Double(b) / 255.0), + isApproximate: true) + showingTiffPreview = false + } + + /// Parses a `.gam` file off the main actor so large meshes do not + /// stall the UI. + private func parse(url: URL) async throws -> GamutMesh { + try await Task.detached { + try GamutMeshParser.parse(url: url) + }.value + } +} diff --git a/Sources/ICCery/HelpOverlayView.swift b/Sources/ICCery/HelpOverlayView.swift new file mode 100644 index 0000000..99574b5 --- /dev/null +++ b/Sources/ICCery/HelpOverlayView.swift @@ -0,0 +1,31 @@ +import SwiftUI + +/// Reusable help overlay badge that does not reflow layout (#171). +/// +/// When `showing` is `true`, a small indicator is rendered as an overlay at the +/// top-trailing corner of the wrapped view. The native `.help` tooltip is always +/// available on hover, so the overlay is purely a visual cue in help mode. +struct HelpOverlay: ViewModifier { + let text: String + @Binding var showing: Bool + + func body(content: Content) -> some View { + content + .help(text) + .overlay(alignment: .topTrailing) { + if showing { + Image(systemName: "questionmark.circle.fill") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(Theme.accent) + .offset(x: 8, y: -8) + } + } + } +} + +extension View { + /// Adds a non-reflowing help overlay to the view. + func helpOverlay(_ text: String, showing: Binding) -> some View { + modifier(HelpOverlay(text: text, showing: showing)) + } +} diff --git a/Sources/ICCery/ICCeryApp.swift b/Sources/ICCery/ICCeryApp.swift new file mode 100644 index 0000000..29d2f5e --- /dev/null +++ b/Sources/ICCery/ICCeryApp.swift @@ -0,0 +1,115 @@ +import AppKit +import ICCeryCore +import SwiftUI + +@main +struct ICCeryApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + @StateObject private var workflow: TargetWorkflowViewModel + + init() { + let environment = AppEnvironment.live() + _workflow = StateObject(wrappedValue: TargetWorkflowViewModel(environment: environment)) + try? AppPaths.ensureDirectories() + // Log level is runtime state — apply persisted settings at + // startup (#158); the Settings sheet re-applies on save. + LogSink.shared.applySettings(environment.settingsStore.load()) + } + + var body: some Scene { + // Single fixed window (docs/21 §Shell: 1280×800, min 1100×700); + // metrics are applied by AppDelegate once the window exists. + WindowGroup("ICCery") { + RootView(workflow: workflow) + .frame(minWidth: 1100, minHeight: 700) + .preferredColorScheme(.dark) + } + .commands { + // Single-window app: the File menu carries the project + // commands (issue #149). Replacing `.newItem` keeps + // SwiftUI's empty default New from stacking (R19 — the + // group is filled, so no second New appears). + CommandGroup(replacing: .newItem) { + ProjectCommands(workflow: workflow) + } + } + } +} + +/// AppDelegate: quit when the single window closes, and `killAll` Argyll +/// children before teardown (#147/#149). Termination is deferred until +/// `killAll` has signaled every child so `chartread` can park an XY head +/// when the UI already sent `q\n`. +final class AppDelegate: NSObject, NSApplicationDelegate { + private var terminationRequested = false + + func applicationDidFinishLaunching(_ notification: Notification) { + // SwiftUI scenes launched by XCTest stay `.runningBackground` + // unless the app takes regular activation and orders the window + // front (CI run 29804). + NSApp.setActivationPolicy(.regular) + for window in NSApp.windows { + configureMainWindow(window) + window.makeKeyAndOrderFront(nil) + } + NSApp.activate(ignoringOtherApps: true) + } + + /// docs/21 §Shell: 1280×800 content, min 1100×700, centred. + /// GitHub-hosted Macs (and any display smaller than 1280×800) must + /// not get a window that hangs off-screen — XCTest then reports + /// sidebar controls at negative x as not hittable (run 34864198118). + private func configureMainWindow(_ window: NSWindow) { + let desired = NSSize(width: 1280, height: 800) + let minimum = NSSize(width: 1100, height: 700) + let visible = (window.screen ?? NSScreen.main)?.visibleFrame + ?? NSRect(origin: .zero, size: desired) + + window.contentMinSize = NSSize( + width: min(minimum.width, visible.width), + height: min(minimum.height, visible.height) + ) + window.setContentSize(NSSize( + width: min(desired.width, visible.width), + height: min(desired.height, max(minimum.height, visible.height - 40)) + )) + window.center() + + var frame = window.frame + if frame.width > visible.width { + frame.size.width = visible.width + } + if frame.height > visible.height { + frame.size.height = visible.height + } + frame.origin.x = min( + max(frame.origin.x, visible.minX), + visible.maxX - frame.width + ) + frame.origin.y = min( + max(frame.origin.y, visible.minY), + visible.maxY - frame.height + ) + window.setFrame(frame, display: true) + } + + /// Dock-click reopen: let the WindowGroup re-show or recreate the + /// main window when none are visible. + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + true + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + true + } + + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + guard !terminationRequested else { return .terminateNow } + terminationRequested = true + Task { + await ProcessManager.shared.killAll() + NSApplication.shared.reply(toApplicationShouldTerminate: true) + } + return .terminateLater + } +} diff --git a/Sources/ICCery/LicenseWindowView.swift b/Sources/ICCery/LicenseWindowView.swift new file mode 100644 index 0000000..f578c38 --- /dev/null +++ b/Sources/ICCery/LicenseWindowView.swift @@ -0,0 +1,148 @@ +import AppKit +import SwiftUI + +/// Detailed license and attribution window (issue #31, docs/21 §Modals). +struct LicenseWindowView: View { + @Environment(\.dismiss) private var dismiss + + private let icceryLicense: String + private let argyllLicense: String + + init() { + // ICCery license from LICENCE.md (embedded at compile time) + self.icceryLicense = """ +# LICENCE + +**Copyright (c) 2026 Gordon Bolton** +**All Rights Reserved.** + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), strictly to view the source code and execute the Software for the sole purpose of personal testing, evaluation, and providing feedback. + +Under this licence, you may **not**: + +* Modify, alter, or create derivative works of the Software. +* Distribute, publish, or sublicense the Software or any derivatives. +* Use the Software for any commercial or production purpose. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--- + +## Bundled ArgyllCMS sidecar binaries + +This application bundles and invokes command-line binaries from the Gronod fork of ArgyllCMS. Those binaries are licensed separately under the **GNU Affero General Public License v3 (AGPLv3)**. They are executed strictly as independent subprocesses — they are never linked, loaded, or incorporated into this application — and a copy of `License.txt` is shipped beside the binaries in `Resources/Argyll/`. The terms above apply only to the ICCery application source code, not to the ArgyllCMS binaries. +""" + + // ArgyllCMS license from bundled License.txt + if let url = Bundle.main.url(forResource: "License", withExtension: "txt", subdirectory: "Argyll"), + let content = try? String(contentsOf: url, encoding: .utf8) { + self.argyllLicense = content + } else { + self.argyllLicense = """ +ArgyllCMS license not found — run `scripts/fetch-argyll.sh` to bundle binaries and license. + +The ArgyllCMS binaries are licensed under the GNU Affero General Public License v3 (AGPLv3). +A copy of the license should be present at Resources/Argyll/License.txt. + +See: https://git.i3omb.com/gronod/argyllcms/releases +""" + } + } + + var body: some View { + VStack(spacing: 0) { + // Title bar + HStack { + Text("Licenses & Attribution") + .font(.headline) + .foregroundStyle(Theme.text) + Spacer() + Button("Close") { + dismiss() + } + .keyboardShortcut(.cancelAction) + .accessibilityIdentifier("closeLicenseBtn") + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + .background(Theme.panel.opacity(0.9)) + + Divider() + + ScrollView { + VStack(alignment: .leading, spacing: 24) { + // Section 1: ICCery License + licenseSection( + title: "ICCery License", + content: icceryLicense, + identifier: "icceryLicenseSection" + ) + + Divider() + + // Section 2: ArgyllCMS License + licenseSection( + title: "ArgyllCMS License (AGPLv3)", + content: argyllLicense, + identifier: "argyllLicenseSection" + ) + + Divider() + + // Section 3: Attribution & Links + VStack(alignment: .leading, spacing: 12) { + Text("Attribution & Links") + .font(.headline) + .foregroundStyle(Theme.text) + .accessibilityIdentifier("attributionHeader") + + VStack(alignment: .leading, spacing: 8) { + Link("ArgyllCMS by Graeme Gill → https://www.argyllcms.com/", + destination: URL(string: "https://www.argyllcms.com/")!) + .font(.callout) + .foregroundStyle(Theme.accent) + .accessibilityIdentifier("argyllUpstreamLink") + + Link("Gronod ArgyllCMS fork (v3.5.0-ICCery.1.x) → https://git.i3omb.com/gronod/argyllcms", + destination: URL(string: "https://git.i3omb.com/gronod/argyllcms")!) + .font(.callout) + .foregroundStyle(Theme.accent) + .accessibilityIdentifier("argyllForkLink") + } + + Text("ICCery bundles and invokes ArgyllCMS binaries as isolated subprocesses per AGPLv3 isolation requirements. The ArgyllCMS binaries are never linked, loaded, or incorporated into the ICCery application binary.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("agplIsolationNote") + } + .padding(.horizontal, 4) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("attributionSection") + } + .padding(24) + } + .frame(minWidth: 600, minHeight: 500) + .background(Theme.panel) + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("licenseWindow") + } + + private func licenseSection(title: String, content: String, identifier: String) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.headline) + .foregroundStyle(Theme.text) + .accessibilityIdentifier(identifier + "Header") + + Text(content) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(Theme.text) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityIdentifier(identifier + "Content") + } + .padding(.horizontal, 4) + } +} \ No newline at end of file diff --git a/Sources/ICCery/MeasurementWorkflowViewModel.swift b/Sources/ICCery/MeasurementWorkflowViewModel.swift new file mode 100644 index 0000000..59656d5 --- /dev/null +++ b/Sources/ICCery/MeasurementWorkflowViewModel.swift @@ -0,0 +1,478 @@ +import Combine +import Foundation +import SwiftUI +import ICCeryCore + +/// A single evaluated swatch for the live grid. +struct Swatch: Sendable, Equatable, Identifiable { + let rowId: String + let loc: String + let isPad: Bool + let intended: DisplayRGB + let measured: DisplayRGB + let deltaE: Double? + let classification: SwatchClassification + + var id: String { "\(rowId)\(loc)" } +} + +/// A row of swatches in display order. +struct SwatchRow: Sendable, Equatable, Identifiable { + let index: Int + let rowId: String + let patches: [Swatch] + + var id: String { rowId } +} + +/// Stage of the XY-table badge bar. +enum XYStep: Equatable, Sendable { + case place, align, scan, remove +} + +/// Stage 3 workflow state and interaction (issues #18–#22). +@MainActor +final class MeasurementWorkflowViewModel: ObservableObject { + + // MARK: - Authorities + + let wizard: WizardViewModel + let environment: AppEnvironment + + // MARK: - Settings-driven thresholds + + @Published private(set) var goodMax: Double = 2.0 + @Published private(set) var warningMax: Double = 5.0 + @Published private(set) var enableLEDs: Bool = false + + // MARK: - Instrument detection + + @Published var instruments: [InstrumentDevice] = [] + @Published var selectedInstrument: InstrumentSelection = .auto + @Published var isDetecting = false + @Published var detectionError: String? + + // MARK: - Chartread session + + @Published var isChartreadRunning = false + @Published var chartreadState: ChartreadState = .idle + @Published var currentPrompt: String? + @Published var requestedWarningKey: String? + @Published var chartreadLog: [String] = [] + @Published var rows: [ChartreadRow] = [] + @Published var swatchRows: [SwatchRow] = [] + @Published var showRemoveSheetNotice = false + /// Stage-local chartread error notice (`#chartreadLastError`, #80). + @Published var chartreadNotice: Notice? + private var chartreadTask: Task? + + // MARK: - Averaging + + @Published var passSnapshots: [URL] = [] + @Published var isFinishing = false + @Published var finishNotice: Notice? + @Published var resumedFromTi2 = false + + init(wizard: WizardViewModel, environment: AppEnvironment) { + self.wizard = wizard + self.environment = environment + loadSettings() + discoverPassSnapshots() + } + + // MARK: - Derived state + + var basename: String { wizard.basename } + var workingDirectory: URL? { wizard.effectiveWorkingDirectory } + + var canDetect: Bool { !isDetecting } + + var canStartRead: Bool { + !basename.isEmpty && workingDirectory != nil && !isChartreadRunning + } + + var canMeasureAnotherSheet: Bool { + isFinished && !passSnapshots.isEmpty + } + + var canFinish: Bool { + isFinished && !passSnapshots.isEmpty + } + + var isFinished: Bool { + chartreadState == .allStripsRead || chartreadState == .finished + } + + var xyStep: XYStep { + if showRemoveSheetNotice { return .remove } + switch chartreadState { + case .tablePlaceSheet: + return .place + case .tableAlign: + return .align + case .reading, .awaitingStrip: + return .scan + default: + return .place + } + } + + var hasCanonicalTi3: Bool { + guard let cwd = workingDirectory else { return false } + let url = cwd.appendingPathComponent("\(basename).ti3") + return FileManager.default.fileExists(atPath: url.path) + } + + // MARK: - Settings + + func loadSettings() { + let settings = environment.settingsStore.load() + goodMax = settings.deltaEGoodMax + warningMax = settings.deltaEWarningMax + enableLEDs = settings.enableI1Pro2Leds + } + + // MARK: - Instrument detection + + func detectInstruments() { + guard !isDetecting else { return } + isDetecting = true + detectionError = nil + + Task { @MainActor [weak self] in + guard let self else { return } + do { + let devices = try await self.environment.runner.detectInstruments() + self.instruments = devices + if case .device(let selected) = self.selectedInstrument, + !devices.contains(where: { $0.port == selected.port }) { + self.selectedInstrument = .auto + } + } catch { + self.detectionError = error.localizedDescription + } + self.isDetecting = false + } + } + + // MARK: - Chartread lifecycle + + func startRead() { + guard canStartRead, let cwd = workingDirectory else { return } + let config = buildChartreadConfig(cwd: cwd) + startChartread(config: config) + } + + func measureAnotherSheet() { + guard let cwd = workingDirectory, isFinished else { return } + let config = buildChartreadConfig(cwd: cwd) + startChartread(config: config) + } + + private func buildChartreadConfig(cwd: URL) -> ChartreadConfig { + ChartreadConfig( + basename: basename, + workingDirectory: cwd, + selectedPort: selectedInstrument.chartreadPort, + enableLEDs: enableLEDs, + isXY: selectedInstrument.isXY + ) + } + + private func startChartread(config: ChartreadConfig) { + guard !isChartreadRunning else { return } + + isChartreadRunning = true + chartreadState = .idle + currentPrompt = nil + chartreadNotice = nil + chartreadLog.removeAll() + + // Optional: reset rows when starting a fresh first pass. + if passSnapshots.isEmpty { + rows.removeAll() + swatchRows.removeAll() + } + + let stream = environment.runner.runChartread(config: config) + + chartreadTask = Task { @MainActor [weak self] in + guard let self else { return } + for await event in stream { + self.handle(event: event) + } + self.isChartreadRunning = false + } + } + + private func handle(event: ChartreadEvent) { + switch event { + case .prompt(let result): + chartreadState = result.state + currentPrompt = promptText(for: result) + requestedWarningKey = result.requestedWarningKey + showRemoveSheetNotice = result.isRemoveSheetNotice + + case .row(let row): + upsert(row: row) + if row.isFinalRow { + chartreadState = .allStripsRead + } + + case .log(let batch): + chartreadLog.append(contentsOf: batch) + + case .removeSheetNotice: + showRemoveSheetNotice = true + + case .exit(let code): + if code != 0 { + chartreadNotice = Notice( + kind: .error, + text: "chartread exited with code \(code)" + ) + } + + case .completed(let canonicalURL): + chartreadState = .finished + completePass(canonicalURL: canonicalURL) + + case .failed(let error): + chartreadNotice = Notice(kind: .error, text: error.localizedDescription) + chartreadState = .error + isChartreadRunning = false + } + } + + private func promptText(for result: ChartreadClassifyResult) -> String { + switch result.state { + case .calibrating: + return "Place instrument on calibration tile and press Calibrate." + case .awaitingStrip: + return "Press a key to read the next strip." + case .allStripsRead: + return "All strips read. Press Done & Save when ready." + case .warning: + if let key = result.requestedWarningKey { + return "Warning — press '\(key.uppercased())' to continue." + } + return "Warning — press Continue." + case .promptContinue: + return "Press Continue." + case .tablePlaceSheet: + if let n = result.sheetNumber, let t = result.sheetTotal { + return "Place sheet \(n) of \(t) on the table." + } + return "Place the sheet on the table." + case .tableAlign: + if let patch = result.alignmentPatch { + return "Locate patch \(patch) with the sight, then continue." + } + return "Align the fiducial, then continue." + case .reading: + return "Reading..." + case .error: + return "Read error — you can Retry or Cancel." + case .finished: + return "Measurement saved." + case .idle: + return "Press Start to begin reading." + } + } + + // MARK: - User actions + + func calibrate() { + send(.trigger) + } + + func accept() { + if let key = requestedWarningKey { + send(.customKey(key)) + requestedWarningKey = nil + } else { + send(.accept) + } + } + + func retry() { + send(.trigger) + } + + func doneAndSave() { + send(.done) + } + + func cancelRead() { + environment.runner.cancelChartread(basename: basename, isXY: selectedInstrument.isXY) + chartreadTask?.cancel() + isChartreadRunning = false + chartreadState = .idle + currentPrompt = nil + requestedWarningKey = nil + showRemoveSheetNotice = false + } + + func sendWarningKey(_ key: String) { + send(.customKey(key)) + } + + private func send(_ input: ChartreadInput) { + Task { @MainActor [weak self] in + guard let self, self.isChartreadRunning else { return } + try? await self.environment.runner.sendChartreadInput(basename: self.basename, input: input) + } + } + + // MARK: - Rows and swatches + + private func upsert(row: ChartreadRow) { + if let index = rows.firstIndex(where: { $0.rowIndex == row.rowIndex }) { + rows[index] = row + } else { + rows.append(row) + } + rows.sort { $0.rowIndex < $1.rowIndex } + recomputeSwatches() + } + + func recomputeSwatches() { + var displayRows: [SwatchRow] = [] + for (rowIndex, row) in rows.enumerated() { + var swatches: [Swatch] = [] + for patch in row.patches { + let skip = shouldSkipPad(patch) + if skip { continue } + + let eval = ColorDifference.evaluate( + patch: patch, + goodMax: goodMax, + warningMax: warningMax + ) + if let eval { + swatches.append(Swatch( + rowId: row.rowId, + loc: patch.loc, + isPad: patch.isPad, + intended: eval.intended, + measured: eval.measured, + deltaE: eval.deltaE, + classification: eval.classification + )) + } + } + if !swatches.isEmpty { + displayRows.append(SwatchRow(index: rowIndex, rowId: row.rowId, patches: swatches)) + } + } + swatchRows = displayRows + } + + private func shouldSkipPad(_ patch: ChartreadPatch) -> Bool { + guard patch.isPad else { return false } + let measuredEmpty = patch.measured.xyz == nil && patch.measured.lab == nil + let deviceAllZero = patch.device.allSatisfy { $0 == 0 } + return measuredEmpty && deviceAllZero + } + + // MARK: - Pass management + + private func completePass(canonicalURL: URL) { + guard let cwd = workingDirectory else { return } + do { + _ = try MeasurementArtefacts.snapshotPass(basename: basename, cwd: cwd) + discoverPassSnapshots() + wizard.refreshGating() + } catch { + chartreadNotice = Notice( + kind: .error, + text: "Could not snapshot pass: \(error.localizedDescription)" + ) + } + } + + func discoverPassSnapshots() { + guard let cwd = workingDirectory else { + passSnapshots = [] + return + } + passSnapshots = MeasurementArtefacts.passSnapshots(basename: basename, cwd: cwd) + } + + // MARK: - Finish / Average + + func finishAndAverage() { + guard !isFinishing, let cwd = workingDirectory, !passSnapshots.isEmpty else { return } + finishNotice = nil + + Task { @MainActor [weak self] in + guard let self else { return } + do { + // No log reset: prior chartread output must be preserved. + let canonical = try await ProcessRunSupport.runLogged( + setRunning: { self.isFinishing = $0 }, + resetLog: {}, + onLog: { self.chartreadLog.append(contentsOf: $0) } + ) { onLog in + if self.passSnapshots.count == 1, let pass = self.passSnapshots.first { + return try MeasurementArtefacts.promotePass( + pass: pass, + basename: self.basename, + cwd: cwd + ) + } + let config = AverageConfig( + workingDirectory: cwd, + basename: self.basename, + passFiles: self.passSnapshots + ) + return try await self.environment.runner.runAverage( + config: config, + onLogBatch: onLog + ) + } + self.discoverPassSnapshots() + self.wizard.refreshGating() + if self.wizard.isUnlocked(.buildProfile) { + self.wizard.go(to: .buildProfile) + } else { + self.finishNotice = Notice( + kind: .info, + text: "Finished: \(canonical.lastPathComponent) ready.", + autoHideAfter: nil + ) + } + } catch { + // Fallback to pass 1 promotion if averaging failed. + if let pass = self.passSnapshots.first { + do { + _ = try MeasurementArtefacts.promotePass( + pass: pass, + basename: self.basename, + cwd: cwd + ) + self.discoverPassSnapshots() + self.wizard.refreshGating() + self.finishNotice = Notice( + kind: .error, + text: "Averaging failed — promoted first pass.", + autoHideAfter: nil + ) + } catch { + self.finishNotice = Notice( + kind: .error, + text: "Finish failed: \(error.localizedDescription)", + autoHideAfter: nil + ) + } + } else { + self.finishNotice = Notice( + kind: .error, + text: "Finish failed: \(error.localizedDescription)", + autoHideAfter: nil + ) + } + } + } + } +} diff --git a/Sources/ICCery/MediaLibraryDialogs.swift b/Sources/ICCery/MediaLibraryDialogs.swift new file mode 100644 index 0000000..4d3932e --- /dev/null +++ b/Sources/ICCery/MediaLibraryDialogs.swift @@ -0,0 +1,278 @@ +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 + } + + // Swift 5.7 (Xcode 14.2 CI runner) caps a ViewBuilder body at 10 + // children (#146); Group blocks are layout-transparent, so field + // order and every docs/21 id are unchanged. + private var fields: some View { + Group { + 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") + } + } + + private var readOnlyRows: some View { + Group { + 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") + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Save Media Recipe").font(.title3).foregroundStyle(Theme.text) + fields + readOnlyRows + 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) + + if let notice = media.manageApplyNotice { + Text(notice) + .font(.callout) + .foregroundStyle(.orange) + .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("manageMediaNotice") + .accessibilityValue(notice) + } + + 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.") + } + } +} diff --git a/Sources/ICCery/MediaLibraryViewModel.swift b/Sources/ICCery/MediaLibraryViewModel.swift new file mode 100644 index 0000000..930edb3 --- /dev/null +++ b/Sources/ICCery/MediaLibraryViewModel.swift @@ -0,0 +1,429 @@ +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() + + @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? + /// Last failed Apply while Manage is open. The window banner sits + /// behind the sheet on Monterey, so the dialog shows this too (#170). + @Published var manageApplyNotice: 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 { + manageApplyNotice = nil + guard let r = try? recipe.validated() else { + return failApply("Media recipe is invalid — not applied.", kind: .error) + } + guard let preset = environment.presetStore.all() + .first(where: { $0.id == r.presetID }) + else { + return failApply( + "Preset \(r.presetID) no longer exists — recipe not applied.", + kind: .error) + } + guard preset.colourSpace.lowercased() == r.colourSpace.lowercased() else { + return failApply( + "Recipe colour space does not match its preset — not applied.", + kind: .error) + } + + // 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 + + // 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 { + return failApply( + "Printer \(r.printerDisplayName) is not installed.", + kind: .warning) + } + } else { + return failApply( + "Could not enumerate printers — queue left unchanged.", + kind: .warning) + } + + // 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 + return failApply( + "Calibration file is missing: \(calPath)", kind: .error) + } + 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 + return failApply( + "Could not load calibration: \(error.localizedDescription)", + kind: .error) + } + } else { + workflow.profile.applyCalibration = false + workflow.profile.calibrationFile = calPath + } + + selectedRecipeID = r.id + workflow.wizard.showNotice("Applied \(r.name)") + refreshStaleness() + return true + } + + private func failApply(_ text: String, kind: Notice.Kind) -> Bool { + manageApplyNotice = text + workflow.wizard.showNotice(text, kind: kind) + refreshStaleness() + return false + } + + // 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() + } + } +} diff --git a/Sources/ICCery/NoticeBanner.swift b/Sources/ICCery/NoticeBanner.swift new file mode 100644 index 0000000..a5c7454 --- /dev/null +++ b/Sources/ICCery/NoticeBanner.swift @@ -0,0 +1,72 @@ +import SwiftUI + +/// Banner notice model — the v2 equivalent of `#wizardNotification` +/// (docs/21 §Banner). +struct Notice: Identifiable, Equatable { + enum Kind: Equatable { + case info, warning, error + + var symbolName: String { + switch self { + case .info: return "info.circle" + case .warning: return "exclamationmark.triangle" + case .error: return "xmark.octagon" + } + } + + var tint: Color { + switch self { + case .info: return Theme.accent + case .warning: return .orange + case .error: return .red + } + } + + var accessibilityValue: String { + switch self { + case .info: return "info" + case .warning: return "warning" + case .error: return "error" + } + } + } + + let id = UUID() + let kind: Kind + let text: String + /// Auto-dismiss interval; `nil` keeps the banner until closed. + var autoHideAfter: TimeInterval? = 6 +} + +struct NoticeBanner: View { + let notice: Notice + let onClose: () -> Void + + var body: some View { + HStack(spacing: 10) { + Image(systemName: notice.kind.symbolName) + .foregroundStyle(notice.kind.tint) + Text(notice.text) + .font(.callout) + .foregroundStyle(Theme.text) + .lineLimit(3) + .accessibilityIdentifier("noticeText") + .accessibilityValue(notice.text) + Spacer() + Button(action: onClose) { + Image(systemName: "xmark") + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Theme.panel) + .overlay( + Rectangle() + .frame(height: 1) + .foregroundStyle(Theme.border), + alignment: .bottom + ) + } +} diff --git a/Sources/ICCery/PresetDialogs.swift b/Sources/ICCery/PresetDialogs.swift new file mode 100644 index 0000000..40a072c --- /dev/null +++ b/Sources/ICCery/PresetDialogs.swift @@ -0,0 +1,92 @@ +import SwiftUI +import ICCeryCore + +/// `#savePresetDialog` — save the live Stage 1/2 form as a custom +/// preset (issue #11). Names/descriptions render via `Text` only (#114). +struct SavePresetDialog: View { + @ObservedObject var workflow: TargetWorkflowViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Save Preset").font(.title3).foregroundStyle(Theme.text) + TextField("Name", text: $workflow.savePresetName) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("savePresetName") + TextField("Description (optional)", text: $workflow.savePresetDesc) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("savePresetDesc") + HStack { + Spacer() + Button("Cancel") { workflow.showingSavePreset = false } + .accessibilityIdentifier("btnCloseSavePresetDialog") + Button("Save") { workflow.saveCurrentAsPreset() } + .accessibilityIdentifier("btnConfirmSavePreset") + .disabled(workflow.savePresetName + .trimmingCharacters(in: .whitespaces).isEmpty) + } + } + .padding(20) + .frame(width: 380) + .background(Theme.background) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("savePresetDialog") + } +} + +/// `#managePresetsDialog` — list, delete (custom only), import, export. +struct ManagePresetsDialog: View { + @ObservedObject var workflow: TargetWorkflowViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Manage Presets").font(.title3).foregroundStyle(Theme.text) + List { + ForEach(workflow.presets) { preset in + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(preset.name).foregroundStyle(Theme.text) + if !preset.description.isEmpty { + Text(preset.description) + .font(.caption).foregroundStyle(.secondary) + } + } + Spacer() + if PresetCatalog.isBuiltIn(preset.id) { + Text("Built-in") + .font(.caption).foregroundStyle(.secondary) + } else { + Button("Export") { workflow.exportPreset(preset) } + .accessibilityIdentifier( + "btnExportPreset-\(preset.id)") + Button("Delete", role: .destructive) { + workflow.deletePreset(preset) + } + .accessibilityIdentifier("btnDeletePreset-\(preset.id)") + } + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("presetRow-\(preset.id)") + } + } + .accessibilityIdentifier("managePresetsList") + .frame(minHeight: 240) + HStack { + Button("Import…") { workflow.importPreset() } + .accessibilityIdentifier("btnImportPreset") + if let selected = workflow.selectedPreset, + !PresetCatalog.isBuiltIn(selected.id) { + Button("Export Active") { workflow.exportPreset(selected) } + .accessibilityIdentifier("btnExportActivePreset") + } + Spacer() + Button("Close") { workflow.showingManagePresets = false } + .accessibilityIdentifier("btnCloseManagePresetsDialog") + } + } + .padding(20) + .frame(width: 480) + .background(Theme.background) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("managePresetsDialog") + } +} diff --git a/Sources/ICCery/Print/ColorSyncSuppressor.swift b/Sources/ICCery/Print/ColorSyncSuppressor.swift new file mode 100644 index 0000000..28862b6 --- /dev/null +++ b/Sources/ICCery/Print/ColorSyncSuppressor.swift @@ -0,0 +1,171 @@ +import AppKit +import ApplicationServices +import ICCeryCore + +/// Private Print Manager SPI: `(PMPrintSession, CFStringRef) -> OSStatus`. +/// The second argument is the mode string — never integer `1` (#188). +typealias ColorMatchingModeFunction = + @convention(c) (PMPrintSession, CFString) -> OSStatus + +/// `PMPrintSettingsToOptions` — public symbol, resolved via dlsym so a +/// missing SDK declaration can't break the build. +typealias PrintSettingsToOptionsFunction = + @convention(c) (PMPrintSettings, UnsafeMutablePointer?>) -> OSStatus + +/// The six-layer unmanaged-printing engine (issue 14, docs/11): +/// +/// ① session binding — done by `PrintPanelService` before calling us. +/// ② private SPI `PMSessionSetColorMatchingMode{Lock,,NoLock}` — +/// resolved by `dlsym(RTLD_DEFAULT,…)`; first `(symbol, mode)` +/// returning `0` wins. +/// ③ `PMPrintSettingsSetValue` both `AP_ColorMatchingMode` and +/// `AP.ColorMatchingMode` = `AP_ApplicationColorMatching`, locked. +/// ④ driver "no colour adjustment" pre-select from `lpoptions -l` +/// keys, unlocked (`detectDriverColorBypass`). +/// ⑤ mirror ③+④ into `NSPrintInfo.printSettings` so the PDE sees them. +/// ⑥ after "Use Settings": `PMPrintSettingsToOptions` → +/// `CupsOptionsFilter` → captured `cupsOptions` + `mediaType`. +/// +/// All layers degrade gracefully — a missing symbol or non-zero status +/// is logged and the next layer still runs. +@MainActor +struct ColorSyncSuppressor { + + /// Injected for tests: symbol → function. Default resolves via + /// `dlsym(RTLD_DEFAULT, …)`. + typealias ModeResolver = (String) -> ColorMatchingModeFunction? + typealias OptionsResolver = () -> PrintSettingsToOptionsFunction? + + var modeResolver: ModeResolver = Self.dlsymMode + var optionsResolver: OptionsResolver = Self.dlsymOptions + var log: (String) -> Void = { AppLogger.shared.log(.info, $0) } + + // MARK: - Layer ② SPI + + /// Walk `ColorMatchingAttempts.attempts` (Lock → plain → NoLock × + /// `AP_ApplicationColorMatching` → `ApplicationColorMatching`); the + /// first call returning `0` wins. `false` when nothing worked. + @discardableResult + func applySPIMode(to session: PMPrintSession) -> Bool { + for attempt in ColorMatchingAttempts.attempts { + guard let function = modeResolver(attempt.symbol) else { + continue + } + let status = function(session, attempt.mode as CFString) + if status == 0 { + log("ColorSync: \(attempt.symbol) accepted " + + "\(attempt.mode)") + return true + } + } + log("ColorSync: no PMSessionSetColorMatchingMode* accepted a " + + "mode — falling back to PMPrintSettingsSetValue") + return false + } + + // MARK: - Layer ③ locked AP_* keys + + /// `PMPrintSettingsSetValue` both key spellings, locked. + @discardableResult + func applyLockedKeys(to settings: PMPrintSettings) -> Int { + var applied = 0 + for key in ColorMatchingAttempts.printSettingsKeys { + let status = PMPrintSettingsSetValue( + settings, + key as CFString, + ColorMatchingAttempts.applicationMatchingValue as CFString, + true) + if status == 0 { applied += 1 } + } + if applied == 0 { + log("ColorSync: PMPrintSettingsSetValue could not lock " + + "AP_ColorMatchingMode") + } + return applied + } + + // MARK: - Layer ④ driver bypass + + /// Pre-select the driver "no colour adjustment" option, unlocked — + /// the PDE may override it. Returns the `(key, value)` applied. + @discardableResult + func applyDriverBypass( + to settings: PMPrintSettings, + optionKeys: Set + ) -> (key: String, value: String)? { + guard let bypass = CupsParsers.detectDriverColorBypass( + optionKeys: optionKeys) + else { return nil } + let status = PMPrintSettingsSetValue( + settings, + bypass.key as CFString, + bypass.value as CFString, + false) + if status != 0 { + log("ColorSync: driver bypass \(bypass.key)=\(bypass.value) " + + "rejected (\(status))") + return nil + } + return bypass + } + + // MARK: - Layer ⑤ NSPrintInfo mirror + + /// Mirror the applied keys into `printSettings` so the PDE pick + /// sees them. + func mirror( + into printInfo: NSPrintInfo, + driverBypass: (key: String, value: String)? + ) { + let settings = printInfo.printSettings + for key in ColorMatchingAttempts.printSettingsKeys { + settings[key as NSString] = ColorMatchingAttempts.applicationMatchingValue as NSString + } + if let driverBypass { + settings[driverBypass.key as NSString] = driverBypass.value as NSString + } + } + + // MARK: - Layer ⑥ capture + + /// `PMPrintSettingsToOptions` → filter → `(cupsOptions, mediaType)`. + /// The malloc'd C string is freed after copying. + func captureOptions( + from settings: PMPrintSettings + ) -> (cupsOptions: String?, mediaType: String?) { + guard let toOptions = optionsResolver() else { + log("ColorSync: PMPrintSettingsToOptions unavailable — " + + "panel options not captured") + return (nil, nil) + } + var raw: UnsafeMutablePointer? + guard toOptions(settings, &raw) == 0, let raw else { + return (nil, nil) + } + defer { free(raw) } + let unfiltered = String(cString: raw) + let filtered = CupsOptionsFilter.filter(unfiltered) + return ( + filtered.isEmpty ? nil : filtered, + CupsParsers.extractMediaType(fromOptionsString: unfiltered) + ) + } + + // MARK: - dlsym + + private static func dlsymMode(_ name: String) -> ColorMatchingModeFunction? { + guard let symbol = dlsym(Self.rtldDefault, name) else { return nil } + return unsafeBitCast(symbol, to: ColorMatchingModeFunction.self) + } + + private static func dlsymOptions() -> PrintSettingsToOptionsFunction? { + guard let symbol = dlsym(Self.rtldDefault, "PMPrintSettingsToOptions") + else { return nil } + return unsafeBitCast(symbol, to: PrintSettingsToOptionsFunction.self) + } + + /// `RTLD_DEFAULT` — `UnsafeMutableRawPointer(bitPattern: -2)`. + private static var rtldDefault: UnsafeMutableRawPointer? { + UnsafeMutableRawPointer(bitPattern: -2) + } +} diff --git a/Sources/ICCery/Print/PrintPanelService.swift b/Sources/ICCery/Print/PrintPanelService.swift new file mode 100644 index 0000000..3436efe --- /dev/null +++ b/Sources/ICCery/Print/PrintPanelService.swift @@ -0,0 +1,186 @@ +import AppKit +import ApplicationServices +import ICCeryCore + +/// Errors raised while preparing the bound print panel. +enum PrintPanelError: LocalizedError { + case sessionBindingFailed(OSStatus) + case noPrinterFound(String) + + var errorDescription: String? { + switch self { + case .sessionBindingFailed(let status): + return "Could not bind the print session to the queue (OSStatus \(status))." + case .noPrinterFound(let name): + return "No printer found for '\(name)'." + } + } +} + +/// Preferences → native `NSPrintPanel` bound to the selected CUPS +/// queue (issue 13, docs/11). +/// +/// This is a **settings-capture** dialog — the default button is +/// "Use Settings", never "Print". It is never System Settings, the +/// CUPS web UI, or an `NSWorkspace` open (#188). Cancel returns `nil` +/// and is not an error. +/// +/// Binding: `PMPrinterCreateFromPrinterID(CUPS queue id)` → +/// `PMSessionSetCurrentPMPrinter` → session default settings/page +/// format. `PMPrinter` is `PMRelease`d on every path. Fallback when PM +/// binding fails: `NSPrinter(name: displayName)` (the `printer-info` +/// label) → `printInfo.printer`. +@MainActor +struct PrintPanelService { + + /// The suppression engine — injectable for tests. + var suppressor = ColorSyncSuppressor() + + /// Resolves the display name (off-panel `lpoptions` fetch) and runs + /// the modal panel. Returns `nil` when the user cancels. + func showProperties( + queue: String, + displayName: String?, + cupsService: CupsService + ) async throws -> PrintPropertiesResult? { + #if DEBUG + if UITestHooks.printPanelStubbed { + return UITestHooks.printPanelResult(forQueue: queue) + } + #endif + // `??` rhs is a non-async @autoclosure — fetch first. + let fetched = try? await cupsService.displayName(for: queue) + let display = displayName ?? fetched + // Layer ④ needs the queue's option keys (lpoptions -l) to pick + // the driver colour-bypass before the panel opens. + let optionKeys = (try? await cupsService.optionKeys(for: queue)) + ?? [] + return try runNativePanel( + queue: queue, displayName: display, optionKeys: optionKeys) + } + + // MARK: - Panel + + private func runNativePanel( + queue: String, + displayName: String?, + optionKeys: Set + ) throws -> PrintPropertiesResult? { + let printInfo = NSPrintInfo() + var pmPrinter: PMPrinter? + var boundViaPM = false + + // ① Bind the session to the selected CUPS queue (docs/11). + if let printer = PMPrinterCreateFromPrinterID(queue as CFString) { + pmPrinter = printer + let session = unsafeBitCast( + printInfo.pmPrintSession(), to: PMPrintSession.self) + let settings = unsafeBitCast( + printInfo.pmPrintSettings(), to: PMPrintSettings.self) + let pageFormat = unsafeBitCast( + printInfo.pmPageFormat(), to: PMPageFormat.self) + + let status = PMSessionSetCurrentPMPrinter(session, printer) + if status != 0 { + PMRelease(Self.pmObject(printer)) + throw PrintPanelError.sessionBindingFailed(status) + } + // Warn-only: defaults keep the panel consistent with the + // queue but are not fatal when they fail. + _ = PMSessionDefaultPrintSettings(session, settings) + _ = PMSessionDefaultPageFormat(session, pageFormat) + boundViaPM = true + } else { + // Fallback: NSPrinter by display name (docs/11 §binding). + guard let displayName, + let nsPrinter = NSPrinter(name: displayName) + else { + throw PrintPanelError.noPrinterFound( + displayName ?? queue) + } + printInfo.printer = nsPrinter + printInfo.setUpPrintOperationDefaultValues() + } + defer { + if let printer = pmPrinter { + PMRelease(Self.pmObject(printer)) + } + } + + // ②–⑤ ColourSync suppression — only on the PM path: the SPI + // and PMPrintSettingsSetValue need a session with a current + // printer to attach to. + var settings = unsafeBitCast( + printInfo.pmPrintSettings(), to: PMPrintSettings.self) + var driverBypass: (key: String, value: String)? + if boundViaPM { + let session = unsafeBitCast( + printInfo.pmPrintSession(), to: PMPrintSession.self) + suppressor.applySPIMode(to: session) // ② + suppressor.applyLockedKeys(to: settings) // ③ + driverBypass = suppressor.applyDriverBypass( // ④ + to: settings, optionKeys: optionKeys) + suppressor.mirror(into: printInfo, driverBypass: driverBypass) // ⑤ + } + + let panel = NSPrintPanel() + panel.options = [ + .showsCopies, .showsPageRange, .showsPaperSize, + .showsOrientation, .showsScaling, .showsPrintSelection, + .showsPageSetupAccessory, .showsPreview, + ] + panel.setDefaultButtonTitle("Use Settings") + + let response = panel.runModal(with: printInfo) + guard response == NSApplication.ModalResponse.OK.rawValue else { + return nil + } + + // ⑥ Capture the user's choices — filtered replay options plus + // the media type they picked. Re-fetch the settings handle so + // we read back what the modal wrote. + var cupsOptions: String? + var mediaType: String? + if boundViaPM { + settings = unsafeBitCast( + printInfo.pmPrintSettings(), to: PMPrintSettings.self) + let captured = suppressor.captureOptions(from: settings) + cupsOptions = captured.cupsOptions + mediaType = captured.mediaType + } + return PrintPropertiesResult( + selectedPrinter: boundViaPM + ? Self.currentPrinterID( + session: unsafeBitCast( + printInfo.pmPrintSession(), to: PMPrintSession.self), + fallback: queue) + : nil, + options: PrintOptions( + mediaType: mediaType, + ppdUncorrectedPassthrough: true, + cupsOptions: cupsOptions)) + } + + // MARK: - PM helpers + + /// `PMPrinter` → `PMObject` for `PMRelease` — the Carbon API wants + /// `UnsafeRawPointer`, Swift imports `PMPrinter` as `OpaquePointer`. + static func pmObject(_ printer: PMPrinter) -> PMObject { + unsafeBitCast(printer, to: PMObject.self) + } + + /// `PMSessionGetCurrentPrinter` → `PMPrinterGetID` → String. + private static func currentPrinterID( + session: PMPrintSession, + fallback: String + ) -> String { + var current: PMPrinter? + guard PMSessionGetCurrentPrinter(session, ¤t) == 0, + let printer = current + else { return fallback } + defer { PMRelease(pmObject(printer)) } + guard let id = PMPrinterGetID(printer) + else { return fallback } + return id.takeUnretainedValue() as String + } +} diff --git a/Sources/ICCery/Print/PrintSessionViewModel.swift b/Sources/ICCery/Print/PrintSessionViewModel.swift new file mode 100644 index 0000000..3eabd2f --- /dev/null +++ b/Sources/ICCery/Print/PrintSessionViewModel.swift @@ -0,0 +1,200 @@ +import Combine +import Foundation +import ICCeryCore + +/// CUPS queue selection, bound print panel, and `lp` spool (issues 12–15, 17 / #85). +@MainActor +final class PrintSessionViewModel: ObservableObject { + let wizard: WizardViewModel + let environment: AppEnvironment + + @Published var printers: [Printer] = [] + @Published var selectedPrinter = "" + @Published var printerCaps = PrinterCapabilities() + @Published var selectedTray: Int? + @Published var selectedMediaType: String? + @Published var printOrientation = "portrait" + @Published var capturedCupsOptions: [String: String] = [:] + @Published var printNotice: Notice? + @Published var isPrinting = false + private var printTask: Task? + + init(wizard: WizardViewModel, environment: AppEnvironment) { + self.wizard = wizard + self.environment = environment + } + + private var printerEnumTask: Task<[Printer]?, Never>? + + func refreshPrinters() { + Task { @MainActor in _ = await enumeratePrinters() } + } + + /// Serialized queue enumeration — `listPrinters` uses fixed process + /// ids, so overlapping calls would throw `duplicateID`. Concurrent + /// callers coalesce onto the in-flight task (#146). + @discardableResult + func enumeratePrinters() async -> [Printer]? { + if let pending = printerEnumTask { return await pending.value } + let task = Task { @MainActor [weak self] () -> [Printer]? in + guard let self else { return nil } + do { + let list = try await self.environment.cupsService.listPrinters() + self.printers = list + if !list.contains(where: { $0.name == self.selectedPrinter }) { + self.selectedPrinter = list.first { $0.isDefault }?.name + ?? list.first?.name ?? "" + } + await self.reloadSelectedCapabilities() + return list + } catch { + self.printNotice = Notice( + kind: .error, + text: "Could not list printers: \(error.localizedDescription)" + ) + return nil + } + } + printerEnumTask = task + let result = await task.value + printerEnumTask = nil + return result + } + + func reloadSelectedCapabilities() async { + guard !selectedPrinter.isEmpty else { + printerCaps = PrinterCapabilities() + return + } + do { + printerCaps = try await environment.cupsService + .capabilities(for: selectedPrinter) + if selectedMediaType == nil { + selectedMediaType = printerCaps.mediaTypes.first?.id + } + if selectedTray == nil { + selectedTray = printerCaps.trays.first?.id + } + } catch { + printerCaps = PrinterCapabilities() + } + } + + func openPrinterPreferences() { + guard !selectedPrinter.isEmpty else { return } + let queue = selectedPrinter + let displayName = printers.first { $0.name == queue }?.displayName + let cups = environment.cupsService + Task { @MainActor in + do { + guard let result = try await PrintPanelService() + .showProperties( + queue: queue, displayName: displayName, + cupsService: cups) + else { + printNotice = Notice( + kind: .info, + text: "Printer properties dialog cancelled.", + autoHideAfter: nil + ) + return + } + if let selected = result.selectedPrinter, + printers.contains(where: { $0.name == selected }), + selected != queue { + selectedPrinter = selected + await reloadSelectedCapabilities() + } + if let captured = result.options.cupsOptions { + capturedCupsOptions[selectedPrinter] = captured + } + if let media = result.options.mediaType { + selectedMediaType = media + } + printNotice = Notice( + kind: .info, + text: "Settings captured for \(selectedPrinter).", + autoHideAfter: nil + ) + } catch { + printNotice = Notice(kind: .error, text: error.localizedDescription) + } + } + } + + func printAllPages(from result: PrinttargResult, pageSize: PageSize) { + guard !isPrinting else { return } + isPrinting = true + let task = Task { @MainActor [weak self] in + guard let self else { return } + // `defer` cannot mutate isolated state under Swift 5.7 + // (Xcode 14.2 / macOS 12 runner), so clear explicitly (#113). + var printed = 0 + for page in result.pages { + do { + try await spool(page, index: page.index, pageSize: pageSize) + printed += 1 + } catch { + printNotice = Notice( + kind: .error, + text: "Print failed on \(page.page.filename): " + + error.localizedDescription + ) + isPrinting = false + self.printTask = nil + return + } + } + printNotice = Notice( + kind: .info, + text: "Sent \(printed) page(s) to \(selectedPrinter).", + autoHideAfter: nil + ) + isPrinting = false + self.printTask = nil + } + printTask = task + } + + func printPage(_ page: GalleryPage, pageSize: PageSize) { + guard !isPrinting else { return } + isPrinting = true + let task = Task { @MainActor [weak self] in + guard let self else { return } + do { + try await spool(page, index: page.index, pageSize: pageSize) + printNotice = Notice( + kind: .info, + text: "Sent \(page.page.filename) to \(selectedPrinter).", + autoHideAfter: nil + ) + } catch { + printNotice = Notice( + kind: .error, + text: "Print failed: \(error.localizedDescription)" + ) + } + isPrinting = false + self.printTask = nil + } + printTask = task + } + + private func spool(_ page: GalleryPage, index: Int, pageSize: PageSize) async throws { + guard !selectedPrinter.isEmpty else { + throw CupsError.noPrinterSelected + } + let options = PrintOptions( + orientation: printOrientation, + paperSize: pageSize == .custom ? nil : pageSize.rawValue, + mediaType: selectedMediaType, + ppdUncorrectedPassthrough: true, + cupsOptions: capturedCupsOptions[selectedPrinter]) + try await environment.cupsService.printTarget( + queue: selectedPrinter, + tiffPath: page.fileURL.path, + options: options, + page: index) + wizard.printerName = selectedPrinter + } +} diff --git a/Sources/ICCery/ProcessLogView.swift b/Sources/ICCery/ProcessLogView.swift new file mode 100644 index 0000000..dfdd216 --- /dev/null +++ b/Sources/ICCery/ProcessLogView.swift @@ -0,0 +1,27 @@ +import SwiftUI + +/// Shared monospaced process-log disclosure used by Stage 1 and Stage 2. +struct ProcessLogView: View { + let lines: [String] + var minHeight: CGFloat = 120 + var maxHeight: CGFloat = 200 + var containerId: String + var logId: String + + var body: some View { + DisclosureGroup("Process log") { + ScrollView { + Text(lines.joined(separator: "\n")) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(Theme.text) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } + .frame(minHeight: minHeight, maxHeight: maxHeight) + .accessibilityIdentifier(logId) + } + .foregroundStyle(Theme.text) + .accessibilityElement(children: .contain) + .accessibilityIdentifier(containerId) + } +} diff --git a/Sources/ICCery/ProcessRunSupport.swift b/Sources/ICCery/ProcessRunSupport.swift new file mode 100644 index 0000000..e396ad5 --- /dev/null +++ b/Sources/ICCery/ProcessRunSupport.swift @@ -0,0 +1,29 @@ +import Foundation + +/// Shared hop for coalesced Argyll log batches (issue #80). +/// The runner invokes the sink off the main actor; this is the single hop back. +enum ProcessRunSupport { + static func logSink( + _ apply: @escaping @MainActor @Sendable ([String]) -> Void + ) -> @Sendable ([String]) -> Void { + { batch in + Task { @MainActor in apply(batch) } + } + } + + /// Wraps a runner call with running/log bookkeeping. + /// `work` stays on the main actor so `T` does not cross isolation + /// (Swift 6: non-Sendable generic return from a nonisolated async fn). + @MainActor + static func runLogged( + setRunning: (Bool) -> Void, + resetLog: () -> Void, + onLog: @escaping @MainActor @Sendable ([String]) -> Void, + work: @MainActor @escaping (@escaping @Sendable ([String]) -> Void) async throws -> T + ) async throws -> T { + setRunning(true) + resetLog() + defer { setRunning(false) } + return try await work(logSink(onLog)) + } +} diff --git a/Sources/ICCery/ProfileWorkflowViewModel.swift b/Sources/ICCery/ProfileWorkflowViewModel.swift new file mode 100644 index 0000000..5903606 --- /dev/null +++ b/Sources/ICCery/ProfileWorkflowViewModel.swift @@ -0,0 +1,483 @@ +import Combine +import Foundation +import SwiftUI +import ICCeryCore + +/// Stage 4/5 workflow: build a profile, verify it, track drift, and install. +@MainActor +final class ProfileWorkflowViewModel: ObservableObject { + + let wizard: WizardViewModel + let environment: AppEnvironment + private let fileDialogs = FileDialogService.shared + + // MARK: - Stage 4 form + + @Published var algorithm: String = "l" // l | x | X | m + @Published var quality: String = "m" // l | m | h | u + @Published var intent: String = "" // usually empty at Stage 4 + @Published var fwaSelection: ColprofFwaSelection = .none + @Published var fwaCustomPath: String = "" + @Published var illuminant: String = "" + @Published var observer: String = "" + @Published var inputViewingCond: String = "" + @Published var outputViewingCond: String = "" + @Published var profileDescription: String = "" + @Published var copyright: String = "" + + // MARK: - Run state + + @Published var isColprofRunning = false + @Published var colprofLog: [String] = [] + @Published var colprofProgress: String? + @Published var createdProfileURL: URL? + /// Path to the `.gam` gamut mesh extracted post-`colprof` (issue #28). + @Published var createdGamutURL: URL? + + // MARK: - Stage 4/5 calibration (issue #24) + + @Published var applyCalibration = false + @Published var calibrationFile: String = "" + + // MARK: - Stage 5 verification (issue #25) + + @Published var profcheckReport: ProfcheckReport? + @Published var profcheckWarning: String? + @Published var isProfcheckRunning = false + + // MARK: - History / drift (issue #26) + + @Published var verificationHistory: [VerificationRecord] = [] + @Published var driftPrinterFilter: String? = nil + @Published var driftAlert: String? + @Published var isHistoryStoreError: String? + + // MARK: - Install (issue #27) + + @Published var installResult: InstallProfileResult? + @Published var showingInstallCollision = false + @Published var installCollisionMessage: String = "" + var pendingInstallOptions: InstallProfileOptions? + + init(wizard: WizardViewModel, environment: AppEnvironment) { + self.wizard = wizard + self.environment = environment + restoreCreatedProfileURL() + } + + /// Restores `createdProfileURL` and `createdGamutURL` from the wizard + /// artefacts or by probing the working directory (#52, #28). + func restoreCreatedProfileURL() { + let cwd = wizard.effectiveWorkingDirectory ?? PathSecurity.resolveSafeCwd(nil) + createdProfileURL = wizard.artefacts.profilePath + ?? ArtefactProbe.resolveProfile(basename: wizard.basename, cwd: cwd) + createdGamutURL = wizard.artefacts.gamPath + ?? ArtefactProbe.artefact(wizard.basename, "gam", cwd) + if let gam = createdGamutURL, !FileManager.default.fileExists(atPath: gam.path) { + createdGamutURL = nil + } + } + + // MARK: - Derived + + var canCreateProfile: Bool { + !wizard.basename.isEmpty && wizard.effectiveWorkingDirectory != nil && !isColprofRunning + } + + var canVerify: Bool { + createdProfileURL != nil && !isProfcheckRunning + } + + var fwaValue: String? { + fwaSelection.presetValue(customPath: fwaCustomPath) + } + + // MARK: - Preset application + + func applyPreset(_ preset: ProfilingPreset?) { + guard let preset else { return } + let config = ColprofConfig( + preset: preset, + basename: wizard.basename, + workingDirectory: wizard.effectiveWorkingDirectory + ) + algorithm = config.algorithm + quality = config.quality + intent = config.intent ?? "" + fwaSelection = ColprofFwaSelection(presetValue: config.fwa) + fwaCustomPath = fwaSelection == .custom ? (config.fwa ?? "") : "" + illuminant = config.illuminant ?? "" + observer = config.observer ?? "" + inputViewingCond = config.inputViewingCond ?? "" + outputViewingCond = config.outputViewingCond ?? "" + profileDescription = "" + copyright = "" + applyCalibration = preset.applyCalibration == true + calibrationFile = preset.calibrationFile ?? "" + } + + /// Stage 4 form values for saving into a custom preset. + func presetSnapshot() -> ( + algorithm: String, + quality: String, + intent: String?, + fwa: String?, + illuminant: String?, + observer: String?, + inputViewingCond: String?, + outputViewingCond: String? + ) { + ( + algorithm: algorithm, + quality: quality, + intent: intent.isEmpty ? nil : intent, + fwa: fwaValue, + illuminant: illuminant.isEmpty ? nil : illuminant, + observer: observer.isEmpty ? nil : observer, + inputViewingCond: inputViewingCond.isEmpty ? nil : inputViewingCond, + outputViewingCond: outputViewingCond.isEmpty ? nil : outputViewingCond + ) + } + + // MARK: - Stage 4: build profile + + func buildColprofConfig() -> ColprofConfig { + let description = profileDescription.isEmpty ? wizard.basename : profileDescription + return ColprofConfig( + algorithm: algorithm, + quality: quality, + intent: intent.isEmpty ? nil : intent, + fwa: fwaValue, + illuminant: illuminant.isEmpty ? nil : illuminant, + observer: observer.isEmpty ? nil : observer, + inputViewingCond: inputViewingCond.isEmpty ? nil : inputViewingCond, + outputViewingCond: outputViewingCond.isEmpty ? nil : outputViewingCond, + description: description, + copyright: copyright.isEmpty ? nil : copyright, + basename: wizard.basename, + workingDirectory: wizard.effectiveWorkingDirectory + ) + } + + func createProfile() { + guard canCreateProfile, let _ = wizard.effectiveWorkingDirectory else { return } + let config = buildColprofConfig() + + colprofProgress = nil + createdProfileURL = nil + createdGamutURL = nil + + let runner = environment.runner + Task { @MainActor [weak self] in + guard let self else { return } + do { + let outcome = try await ProcessRunSupport.runLogged( + setRunning: { self.isColprofRunning = $0 }, + resetLog: { self.colprofLog = [] }, + onLog: { batch in + self.colprofLog.append(contentsOf: batch) + if let last = batch.last { + self.updateProgress(ColprofProgressClassifier.classify(line: last)) + } + } + ) { onLog in + let url = try await runner.runColprof(config: config, onLogBatch: onLog) + + var finalProfileURL = url + + if self.applyCalibration, !self.calibrationFile.isEmpty { + let applyConfig = ApplycalConfig( + calibrationPath: self.calibrationFile, + inputProfileURL: url + ) + assert(!applyConfig.unapply, "applycal unapply is not supported in v2.0") + finalProfileURL = try await runner.runApplycal(config: applyConfig) + self.colprofLog.append("Calibration embedded: \(self.calibrationFile)") + } + + // Gamut extraction is best-effort for Stage 5 / M6 viewer. + var gamutURL: URL? + do { + let gamConfig = IccgamutConfig(profileURL: finalProfileURL) + let url = try await runner.runIccgamut(config: gamConfig, onLogBatch: onLog) + gamutURL = url + self.colprofLog.append("Gamut mesh extracted: \(url.lastPathComponent)") + } catch { + self.wizard.showNotice( + "Gamut extraction skipped: \(error.localizedDescription)", + kind: .info + ) + } + return (profileURL: finalProfileURL, gamutURL: gamutURL) + } + self.createdProfileURL = outcome.profileURL + self.createdGamutURL = outcome.gamutURL + self.wizard.refreshGating() + self.wizard.showNotice("Profile created: \(outcome.profileURL.lastPathComponent)") + self.wizard.go(to: .verifyInstall) + } catch { + self.wizard.showNotice( + "Profile creation failed: \(error.localizedDescription)", + kind: .error + ) + } + } + } + + private func updateProgress(_ progress: ColprofProgress) { + switch progress { + case .gamutMapping: + colprofProgress = "Gamut mapping calculation…" + case .fittingClut: + colprofProgress = "Fitting cLUT grid points…" + case .writingIcc: + colprofProgress = "Writing ICC profile…" + case .unknown: + break + } + } + + // MARK: - Stage 5: verify profile + + var knownPrinters: [String] { + var names = Set() + for record in verificationHistory { + if record.printerName.isEmpty { + names.insert("Unknown") + } else { + names.insert(record.printerName) + } + } + return Array(names).sorted() + } + + func loadHistory() { + Task { @MainActor [weak self] in + guard let self else { return } + do { + self.verificationHistory = try await self.environment.historyStore.load() + self.driftAlert = DriftAlert.compute(from: self.filteredHistory) + } catch { + self.isHistoryStoreError = error.localizedDescription + self.wizard.showNotice( + "Could not load verification history: \(error.localizedDescription)", + kind: .error + ) + } + } + } + + var filteredHistory: [VerificationRecord] { + guard let filter = driftPrinterFilter, !filter.isEmpty else { + return verificationHistory + } + return verificationHistory.filter { $0.printerName == filter } + } + + func verifyProfile() { + guard canVerify, + let cwd = wizard.effectiveWorkingDirectory, + let profileURL = createdProfileURL else { return } + + let ti3URL = ArtefactProbe.artefact(wizard.basename, "ti3", cwd) + let config = ProfcheckConfig(ti3URL: ti3URL, iccURL: profileURL) + + profcheckReport = nil + profcheckWarning = nil + + let runner = environment.runner + Task { @MainActor [weak self] in + guard let self else { return } + do { + let outcome = try await ProcessRunSupport.runLogged( + setRunning: { self.isProfcheckRunning = $0 }, + resetLog: {}, + onLog: { self.colprofLog.append(contentsOf: $0) } + ) { onLog in + let report = try await runner.runProfcheck(config: config, onLogBatch: onLog) + var history: [VerificationRecord]? + if let record = self.makeVerificationRecord(from: report) { + history = try await self.environment.historyStore.append(record) + } + return (report: report, history: history) + } + self.profcheckReport = outcome.report + if let history = outcome.history { + self.verificationHistory = history + self.driftAlert = DriftAlert.compute(from: self.filteredHistory) + } + } catch let error as ArgyllRunnerError where error == .profcheckUnparseable { + self.profcheckWarning = "profcheck output could not be parsed." + self.profcheckReport = ProfcheckReport(warning: self.profcheckWarning) + } catch { + self.profcheckWarning = error.localizedDescription + self.profcheckReport = ProfcheckReport(warning: self.profcheckWarning) + self.wizard.showNotice( + "Verification failed: \(error.localizedDescription)", + kind: .error + ) + } + } + } + + func makeVerificationRecord(from report: ProfcheckReport) -> VerificationRecord? { + guard let avg = report.avgDE, + let max = report.maxDE, + let rms = report.rmsDE, + let status = report.status else { return nil } + + let timestamp = Date() + let id = "vr-\(Int(timestamp.timeIntervalSince1970))-\(Self.nextSeq())" + let printerName = wizard.printerName?.isEmpty == false ? wizard.printerName! : "Unknown" + return VerificationRecord( + id: id, + profileName: createdProfileURL?.lastPathComponent ?? wizard.basename, + printerName: printerName, + avgDE: avg, + maxDE: max, + rmsDE: rms, + patchCount: report.patchCount ?? 0, + status: status, + timestamp: timestamp + ) + } + + private static func nextSeq() -> Int { + Int.random(in: 0..<1_000_000) + } + + func clearHistory() { + Task { @MainActor [weak self] in + guard let self else { return } + do { + try await self.environment.historyStore.clear() + self.verificationHistory = [] + self.driftAlert = nil + } catch { + self.wizard.showNotice( + "Could not clear history: \(error.localizedDescription)", + kind: .error + ) + } + } + } + + // MARK: - Profile install + + func beginInstallProfile() { + guard let sourceURL = createdProfileURL, + let _ = wizard.effectiveWorkingDirectory else { return } + + let settings = environment.settingsStore.load() + let preferSystem = settings.defaultInstallLocation == .system + let options = InstallProfileOptions( + forceOverwrite: !settings.askBeforeOverwriteProfile, + preferSystem: preferSystem, + collisionPolicy: .overwrite, + openColorPanel: settings.openColorPanelAfterInstall + ) + + do { + let config = InstallProfileConfig(sourceURL: sourceURL, options: options) + let destURL = try ProfileInstaller.resolveDestinationURL(for: config) + let collision = FileManager.default.fileExists(atPath: destURL.path) + + if collision && settings.askBeforeOverwriteProfile { + pendingInstallOptions = options + installCollisionMessage = "A profile named \(destURL.lastPathComponent) already exists." + showingInstallCollision = true + return + } + + runInstall(sourceURL: sourceURL, options: options) + } catch { + wizard.showNotice( + "Install failed: \(error.localizedDescription)", + kind: .error + ) + } + } + + func resolveInstallCollision(policy: ProfileCollisionPolicy) { + showingInstallCollision = false + guard let sourceURL = createdProfileURL, + var options = pendingInstallOptions else { return } + + if policy == .cancel { + installResult = InstallProfileResult( + destPath: "", + registered: false, + overwritten: false, + renamed: false, + openedPanel: false, + message: "Install cancelled." + ) + return + } + + options.collisionPolicy = policy + if policy == .overwrite { + options.forceOverwrite = true + } + runInstall(sourceURL: sourceURL, options: options) + } + + private func runInstall(sourceURL: URL, options: InstallProfileOptions) { + let config = InstallProfileConfig(sourceURL: sourceURL, options: options) + Task(priority: .userInitiated) { [weak self] in + do { + let result = try ProfileInstaller.install(config: config) + await MainActor.run { [weak self] in + self?.installResult = result + self?.wizard.showNotice(result.message) + } + } catch { + await MainActor.run { [weak self] in + self?.wizard.showNotice( + "Install failed: \(error.localizedDescription)", + kind: .error + ) + } + } + } + } + + func exportHistory() { + guard let url = fileDialogs.selectCsvSavePath() else { return } + Task { @MainActor [weak self] in + guard let self else { return } + let csv = await self.environment.historyStore.exportCSV() + do { + try csv.write(to: url, atomically: true, encoding: .utf8) + self.wizard.showNotice("History exported: \(url.lastPathComponent)") + } catch { + self.wizard.showNotice( + "Export failed: \(error.localizedDescription)", + kind: .error + ) + } + } + } + + // MARK: - File pickers + + func browseForSpectrumFile() { + let start = wizard.effectiveWorkingDirectory + let url = UITestHooks.isEnabled + ? nil + : fileDialogs.selectSpectrumFile(startingAt: start) + if let url { + fwaSelection = .custom + fwaCustomPath = url.path + } + } + + func browseForCalibrationFile() { + let start = wizard.effectiveWorkingDirectory + let url = fileDialogs.selectCalFile(startingAt: start) + if let url { + calibrationFile = url.path + applyCalibration = true + } + } +} diff --git a/Sources/ICCery/ProjectSession.swift b/Sources/ICCery/ProjectSession.swift new file mode 100644 index 0000000..2e7888b --- /dev/null +++ b/Sources/ICCery/ProjectSession.swift @@ -0,0 +1,689 @@ +import AppKit +import Combine +import Foundation +import ICCeryCore + +/// Project file session (issue #149): `.icceryproj` open/save/new/close, +/// recents, the dirty flag, and the window title. +/// +/// The project is an **index** — disk artefacts still own the stepper +/// (R18). Open writes `WizardState` through the normal setters and ends +/// in the same probe `windowDidBecomeKey` uses; it never auto-runs +/// `targen`/`colprof`/`chartread` and never unlocks a stage the disk +/// does not back. +@MainActor +final class ProjectSession: ObservableObject { + + /// What to do once the dirty alert resolves. + enum PendingAction { + case new + case open(URL) + case close + } + + /// A live session snapshot, compared against the bound project for + /// the dirty flag (title `•`, `btnProjectSave`). + private struct Snapshot: Equatable { + var basename: String + var cwd: String + var printerID: String? + var presetID: String? + var mediaRecipeID: String? + var calibrationURL: String? + } + + let workflow: TargetWorkflowViewModel + let environment: AppEnvironment + private let fileDialogs = FileDialogService.shared + private let recentsStore: RecentProjectsStore + private var cancellables = Set() + + /// Bound project file location; `nil` = no project. + @Published var projectURL: URL? + /// Last saved/opened project payload. + @Published var project: ICCeryProject? + /// Open Recent entries, newest first, missing files pruned. + @Published var recents: [RecentProjectEntry] = [] + /// JSON claimed a finished profile but disk stops earlier — + /// `projectChipStale` + info banner (R18). + @Published var diskBehindNotes = false + /// `ICCery` / `ICCery — {name}` / `ICCery — {name} •`. + @Published var windowTitle = "ICCery" + /// Live session fields differ from the bound project — computed + /// fresh so decision paths (`requestNew`/`requestClose`) never see + /// a stale willSet value. + var isDirty: Bool { + guard let project else { return false } + return liveSnapshot() != snapshot(of: project) + } + + // Flow state for RootView. + @Published var showingNewAlert = false + @Published var showingDirtyAlert = false + @Published var showingRelocateSheet = false + + private var pendingAction: PendingAction? + /// Loaded project whose `cwd` is missing — relocate sheet payload. + @Published private(set) var pendingRelocate: + (project: ICCeryProject, url: URL)? + + init(workflow: TargetWorkflowViewModel, environment: AppEnvironment) { + self.workflow = workflow + self.environment = environment + self.recentsStore = environment.recentProjectsStore + refreshRecents() + + // Dirty / title derive from live fields — observe each source; + // child VMs are not tracked through the parent's + // objectWillChange (R22-style weak sinks). `@Published` fires on + // willSet, so the recompute is deferred one main turn to read + // post-set values. + let wizard = workflow.wizard + let printSession = workflow.print + let media = workflow.media + let profile = workflow.profile + wizard.$basename.sink { [weak self] _ in self?.scheduleRecompute() } + .store(in: &cancellables) + wizard.$workingDirectory.sink { [weak self] _ in self?.scheduleRecompute() } + .store(in: &cancellables) + wizard.$printerName.sink { [weak self] _ in self?.scheduleRecompute() } + .store(in: &cancellables) + printSession?.$selectedPrinter.sink { [weak self] _ in self?.scheduleRecompute() } + .store(in: &cancellables) + workflow.$selectedPresetID.sink { [weak self] _ in self?.scheduleRecompute() } + .store(in: &cancellables) + media?.$selectedRecipeID.sink { [weak self] _ in self?.scheduleRecompute() } + .store(in: &cancellables) + profile.$calibrationFile.sink { [weak self] _ in self?.scheduleRecompute() } + .store(in: &cancellables) + recomputeDerived() + } + + deinit { cancellables.removeAll() } + + // MARK: - Derived + + var isBound: Bool { project != nil } + + /// A chartread / spotread / colprof child is live — project changes + /// wait for the instrument run to finish. + var childSessionLive: Bool { + workflow.measurement.isChartreadRunning + || workflow.spotRead.isRunning + || workflow.profile.isColprofRunning + } + + /// Basename eligible for persistence. A live `CAL_` stem resolves + /// only through the persisted original — never by stripping, so a + /// bare `CAL_` (Force-Quit anomaly, empty persisted original) is + /// refused rather than trusted (R11). + var resolvedBasename: String? { + let live = workflow.wizard.basename + guard CalibrationIdentity.isCalibration(live) else { + return live.isEmpty ? nil : live + } + let original = workflow.wizard.calibrationOriginalBasename + return original.isEmpty ? nil : original + } + + /// ⌘S / `btnProjectSave` enable rule. A `CAL_` live basename stays + /// enabled so the refusal can show its banner. + var canSave: Bool { + isBound && !workflow.wizard.basename.isEmpty + && workflow.wizard.workingDirectory != nil + } + + /// `menuProjectSaveAs` — no binding required. + var canSaveAs: Bool { + !workflow.wizard.basename.isEmpty + && workflow.wizard.workingDirectory != nil + } + + /// `menuProjectReport` — needs a real basename and cwd on disk. + var canReport: Bool { + !workflow.wizard.basename.isEmpty + && workflow.wizard.workingDirectory != nil + } + + private func liveSnapshot() -> Snapshot { + Snapshot( + basename: resolvedBasename ?? "", + cwd: workflow.wizard.workingDirectory?.path ?? "", + printerID: workflow.print.selectedPrinter.isEmpty + ? nil : workflow.print.selectedPrinter, + presetID: workflow.selectedPresetID == "none" + ? nil : workflow.selectedPresetID, + mediaRecipeID: workflow.media.selectedRecipeID == "none" + ? nil : workflow.media.selectedRecipeID, + calibrationURL: workflow.profile.calibrationFile.isEmpty + ? nil : workflow.profile.calibrationFile) + } + + private func snapshot(of project: ICCeryProject) -> Snapshot { + Snapshot( + basename: project.basename, + cwd: project.cwd, + printerID: project.printerID, + presetID: project.presetID, + mediaRecipeID: project.mediaRecipeID, + calibrationURL: project.calibrationURL) + } + + /// Recomputes the window title from live fields. + func recomputeDerived() { + if let project { + windowTitle = "ICCery — \(project.name)" + (isDirty ? " •" : "") + } else { + windowTitle = "ICCery" + } + } + + /// Defer the recompute one main turn — the Combine sinks fire on + /// willSet, before the changed property holds its new value. + private func scheduleRecompute() { + Task { @MainActor [weak self] in self?.recomputeDerived() } + } + + // MARK: - New + + /// ⌘N / `menuProjectNew`. Dirty sessions detour through the dirty + /// alert first; a live child gets a banner instead of the alert. + func requestNew() { + guard !childSessionLive else { + workflow.wizard.showNotice( + "Finish the instrument session before starting a project.", + kind: .warning) + return + } + if isDirty { + pendingAction = .new + showingDirtyAlert = true + return + } + showingNewAlert = true + } + + /// `btnProjectNewConfirm` — unbinds and clears the basename. Empty + /// is the legal "no target yet" state (#60); artefacts and + /// `media_library.json` are never deleted (R18). + func confirmNew() { + showingNewAlert = false + projectURL = nil + project = nil + pendingRelocate = nil + diskBehindNotes = false + workflow.wizard.basename = "" + workflow.targetBasename = "" + workflow.media.selectedRecipeID = "none" + workflow.wizard.sessionMode = .profile + // Re-probe — an empty basename locks stages 2–5. + workflow.wizard.windowDidBecomeKey() + recomputeDerived() + } + + // MARK: - Open + + /// ⌘O / `btnProjectOpen` — `selectProjectFile` (`.icceryproj` + /// only). Cancel is a no-op. + func requestOpen() { + guard !childSessionLive else { + workflow.wizard.showNotice( + "Finish the instrument session before opening a project.", + kind: .warning) + return + } + let url = UITestHooks.isEnabled + ? UITestHooks.projectOpenURL + : fileDialogs.selectProjectFile() + guard let url else { return } + if isDirty { + pendingAction = .open(url) + showingDirtyAlert = true + return + } + open(url) + } + + /// Open from the recents submenu — same path, no open panel. A + /// missing file is dropped with a banner; no relocate is offered. + func openRecent(_ entry: RecentProjectEntry) { + guard !childSessionLive else { + workflow.wizard.showNotice( + "Finish the instrument session before opening a project.", + kind: .warning) + return + } + let url = URL(fileURLWithPath: entry.path) + guard FileManager.default.fileExists(atPath: url.path) else { + Task { try? await recentsStore.remove(path: entry.path) } + refreshRecents() + workflow.wizard.showNotice("Project file is gone.", kind: .warning) + return + } + if isDirty { + pendingAction = .open(url) + showingDirtyAlert = true + return + } + open(url) + } + + /// Shared open path — validates, routes to relocate when `cwd` is + /// gone, then applies. Failures leave live state untouched. + func open(_ url: URL) { + guard let loaded = loadProject(url) else { return } + apply(loaded, from: url) + } + + /// Awaitable open for tests — identical to `open` but completes + /// after apply finishes. + func openAsync(_ url: URL) async { + guard let loaded = loadProject(url) else { return } + await applyAsync(loaded, from: url) + } + + /// Decode + validate + cwd-existence check. Returns the project + /// ready to apply, or nil after presenting an error banner / the + /// relocate sheet. + private func loadProject(_ url: URL) -> ICCeryProject? { + let loaded: ICCeryProject + do { + loaded = try ICCeryProject.load(from: url) + } catch let error as ICCeryProject.ValidationError { + workflow.wizard.showNotice( + error.errorDescription ?? "Could not open project.", + kind: .error) + return nil + } catch { + workflow.wizard.showNotice( + "Could not open project: \(error.localizedDescription)", + kind: .error) + return nil + } + var isDir: ObjCBool = false + guard FileManager.default.fileExists( + atPath: loaded.cwd, isDirectory: &isDir), isDir.boolValue + else { + pendingRelocate = (loaded, url) + showingRelocateSheet = true + return nil + } + return loaded + } + + /// Sync entry — the apply half runs in a Task so `media.apply` + /// (async) can run inside it. + private func apply(_ project: ICCeryProject, from url: URL) { + Task { @MainActor [weak self] in + await self?.applyAsync(project, from: url) + } + } + + /// Apply order (issue #149): + /// 1. cwd + basename on the wizard **and** Stage 1 fields. + /// 2. `mediaRecipeID` → `MediaLibraryViewModel.apply`; else + /// `presetID` → existing preset apply. Missing recipe is not + /// fatal — open still succeeds. + /// 3. Re-probe via the existing window-focus path (#151). + /// 4. Never auto-run `targen` / `colprof` / `chartread`. + private func applyAsync(_ project: ICCeryProject, from url: URL) async { + let cwdURL = URL(fileURLWithPath: project.cwd, isDirectory: true) + + workflow.wizard.setTarget( + basename: project.basename, workingDirectory: cwdURL) + workflow.targetBasename = project.basename + workflow.targetDirectory = cwdURL + workflow.wizard.printerName = project.printerDisplayName + ?? project.printerID + workflow.wizard.profileBasename = project.profileBasename + workflow.wizard.sessionMode = .profile + if let queue = project.printerID, + workflow.print.printers.contains(where: { $0.name == queue }) { + workflow.print.selectedPrinter = queue + } + + var appliedRecipe = false + if let recipeID = project.mediaRecipeID { + if workflow.media.recipes.isEmpty { + await workflow.media.reloadAsync() + } + if let recipe = workflow.media.recipes + .first(where: { $0.id == recipeID }) { + appliedRecipe = await workflow.media.apply(recipe) + } + } + if !appliedRecipe, let presetID = project.presetID, + let preset = workflow.presets + .first(where: { $0.id == presetID }) { + workflow.applyPreset(preset) + } + + // Disk owns the stepper — same probe window focus uses. + workflow.wizard.windowDidBecomeKey() + + // JSON-vs-disk mismatch: notes say the profile is done but the + // artefacts stop earlier — index drift, not gating truth. + let artefacts = workflow.wizard.artefacts + if project.lastVerification != nil && !artefacts.stage4Complete { + let already = diskBehindNotes && projectURL == url + diskBehindNotes = true + if !already { + let last = artefacts.stage3Complete ? ".ti3" + : artefacts.stage2Complete ? ".ti2" + : artefacts.stage1Complete ? ".ti1" : nil + let detail = last.map { "artefacts on disk stop at \($0)." } + ?? "no artefacts found on disk." + workflow.wizard.showNotice( + "Project notes say profile done; \(detail)", + kind: .info) + } + } else { + diskBehindNotes = false + } + + self.project = project + projectURL = url + pushRecent(url: url, name: project.name) + recomputeDerived() + } + + // MARK: - Relocate + + /// `btnProjectRelocate` — pick a new cwd, rewrite the project file + /// atomically, then continue Apply. + func chooseRelocateFolder() { + guard let pending = pendingRelocate else { return } + let url = UITestHooks.isEnabled + ? UITestHooks.projectRelocateURL + : fileDialogs.selectDirectory() + guard let url else { return } + var rewritten = pending.project + rewritten.cwd = url.path + rewritten.updated = Date() + do { + try rewritten.save(to: pending.url) + } catch { + workflow.wizard.showNotice( + "Could not update project: \(error.localizedDescription)", + kind: .error) + return + } + pendingRelocate = nil + showingRelocateSheet = false + apply(rewritten, from: pending.url) + } + + /// `btnProjectRelocateCancel` — aborts the open; live session + /// unchanged. + func cancelRelocate() { + pendingRelocate = nil + showingRelocateSheet = false + } + + // MARK: - Save / Save As + + /// ⌘S / `btnProjectSave` — writes the live session into the bound + /// URL. A live `CAL_` stem refuses unless the persisted original + /// resolves; `CAL_` is never persisted (R11). + func saveProject() { + Task { @MainActor in _ = await saveProjectAsync() } + } + + @discardableResult + func saveProjectAsync() async -> Bool { + guard let url = projectURL, project != nil else { return false } + guard let basename = resolvedBasename else { + refuseUnsavable() + return false + } + return await write(to: url, basename: basename) + } + + /// ⇧⌘S / `menuProjectSaveAs` — always shows the save picker, then + /// binds the chosen URL and pushes recents. + func saveProjectAs() { + guard let basename = resolvedBasename else { + refuseUnsavable() + return + } + guard let cwd = workflow.wizard.workingDirectory else { + refuseUnsavable() + return + } + let url = UITestHooks.isEnabled + ? UITestHooks.projectSaveURL + : fileDialogs.selectProjectSavePath( + basename: basename, startingAt: cwd) + guard let url else { return } + Task { @MainActor in _ = await write(to: url, basename: basename) } + } + + private func refuseUnsavable() { + if CalibrationIdentity.isCalibration(workflow.wizard.basename) { + workflow.wizard.showNotice( + "Finish or exit calibration before saving a project.", + kind: .warning) + } else { + workflow.wizard.showNotice( + "Set a target basename and working folder before saving a project.", + kind: .warning) + } + } + + /// Builds the project payload from live session fields and writes + /// it atomically. On success binds `url`, refreshes recents, and + /// clears the stale-chip flag. Failure → error banner, bound URL + /// unchanged. + @discardableResult + private func write(to url: URL, basename: String) async -> Bool { + guard let cwd = workflow.wizard.workingDirectory, + !cwd.path.isEmpty else { + refuseUnsavable() + return false + } + let stem = workflow.wizard.profileBasename ?? basename + let snapshot = await lastVerificationSnapshot(stem: stem) + ?? project?.lastVerification + let queue = workflow.print.selectedPrinter + let display = workflow.print.printers + .first { $0.name == queue }?.displayName + ?? workflow.wizard.printerName + let existing = project + let name = (existing?.name.isEmpty == false) + ? existing!.name : basename + let updated = ICCeryProject( + name: name, + notes: existing?.notes ?? "", + basename: basename, + cwd: cwd.path, + profileBasename: workflow.wizard.profileBasename, + printerID: queue.isEmpty ? nil : queue, + printerDisplayName: display, + mediaRecipeID: workflow.media.selectedRecipeID == "none" + ? nil : workflow.media.selectedRecipeID, + presetID: workflow.selectedPresetID == "none" + ? nil : workflow.selectedPresetID, + calibrationURL: workflow.profile.calibrationFile.isEmpty + ? nil : workflow.profile.calibrationFile, + lastVerification: snapshot, + updated: Date()) + do { + try updated.save(to: url) + } catch { + workflow.wizard.showNotice( + "Could not save project: \(error.localizedDescription)", + kind: .error) + return false + } + project = updated + projectURL = url + diskBehindNotes = false + pushRecent(url: url, name: updated.name) + workflow.wizard.showNotice("Project saved: \(url.lastPathComponent)") + recomputeDerived() + return true + } + + /// Last `VerificationHistoryStore` record for this profile stem, + /// if any — notes only. + private func lastVerificationSnapshot( + stem: String + ) async -> VerificationSnapshot? { + guard let records = try? await environment.historyStore.load() + else { return nil } + guard let record = records.last(where: { + $0.profileName == stem || $0.profileName == workflow.wizard.basename + }) else { return nil } + return VerificationSnapshot(record: record) + } + + // MARK: - Close + + /// `menuProjectClose` — unbinds; live basename/cwd/artefacts stay. + func requestClose() { + guard isBound else { return } + if isDirty { + pendingAction = .close + showingDirtyAlert = true + return + } + closeProject() + } + + func closeProject() { + projectURL = nil + project = nil + diskBehindNotes = false + recomputeDerived() + } + + // MARK: - Dirty alert + + /// `btnProjectDirtySave` / `btnProjectDirtyDiscard`. Save proceeds + /// to the pending action only when the write succeeded. + func resolveDirty(save: Bool) { + showingDirtyAlert = false + guard let action = pendingAction else { return } + if save { + Task { @MainActor [weak self] in + guard let self else { return } + if await self.saveProjectAsync() { + self.pendingAction = nil + self.proceed(with: action) + } + } + } else { + pendingAction = nil + proceed(with: action) + } + } + + /// `btnProjectDirtyCancel` — abandons the pending action. + func cancelDirty() { + pendingAction = nil + showingDirtyAlert = false + } + + private func proceed(with action: PendingAction) { + switch action { + case .new: + confirmNew() + case .open(let url): + open(url) + case .close: + closeProject() + } + } + + // MARK: - Report + + /// `menuProjectReport` — writes `{cwd}/{basename}-report.md` + /// atomically (generated; overwrites). No picker. + func saveReport() { + guard canReport, let cwd = workflow.wizard.workingDirectory else { + return + } + let basename = workflow.wizard.basename + Task { @MainActor [weak self] in + guard let self else { return } + let stem = self.workflow.wizard.profileBasename ?? basename + let snapshot = await self.lastVerificationSnapshot(stem: stem) + ?? self.project?.lastVerification + let queue = self.workflow.print.selectedPrinter + let display = self.workflow.print.printers + .first { $0.name == queue }?.displayName + ?? self.workflow.wizard.printerName + let recipeName = self.workflow.media.recipes + .first { $0.id == self.workflow.media.selectedRecipeID }?.name + let payload = ICCeryProject( + name: self.project?.name.isEmpty == false + ? self.project!.name : basename, + notes: self.project?.notes ?? "", + basename: basename, + cwd: cwd.path, + profileBasename: self.workflow.wizard.profileBasename, + printerID: queue.isEmpty ? nil : queue, + printerDisplayName: display, + mediaRecipeID: self.workflow.media.selectedRecipeID == "none" + ? nil : self.workflow.media.selectedRecipeID, + presetID: self.workflow.selectedPresetID == "none" + ? nil : self.workflow.selectedPresetID, + calibrationURL: self.workflow.profile.calibrationFile.isEmpty + ? nil : self.workflow.profile.calibrationFile, + lastVerification: snapshot, + updated: Date()) + do { + let written = try ProjectReport.write( + project: payload, + recipeName: recipeName, + artefacts: self.workflow.wizard.artefacts) + self.workflow.wizard.showNotice( + "Wrote \(written.lastPathComponent)") + } catch { + self.workflow.wizard.showNotice( + "Report failed: \(error.localizedDescription)", + kind: .error) + } + } + } + + // MARK: - Recents / reveal + + /// Rebuilds the recents list; entries whose file is gone are + /// dropped here (submenu build), not at launch. Corrupt → `[]`, + /// file kept (R12). + func refreshRecents() { + Task { @MainActor [weak self] in + guard let self else { return } + do { + self.recents = try await self.recentsStore.pruneMissing() + } catch { + self.recents = [] + } + } + } + + private func pushRecent(url: URL, name: String) { + Task { @MainActor [weak self] in + guard let self else { return } + try? await self.recentsStore.add(url: url, name: name) + self.refreshRecents() + } + } + + /// `menuProjectRecentsClear` — wipes `recent_projects.json` only; + /// `.icceryproj` files are never deleted. + func clearRecents() { + Task { @MainActor [weak self] in + try? await self?.recentsStore.clear() + self?.recents = [] + } + } + + /// `btnProjectReveal` — reveals the bound **project file** in + /// Finder, not the cwd. + func revealInFinder() { + guard let projectURL else { return } + NSWorkspace.shared.activateFileViewerSelecting([projectURL]) + } +} diff --git a/Sources/ICCery/ProjectUI.swift b/Sources/ICCery/ProjectUI.swift new file mode 100644 index 0000000..3dfe2fd --- /dev/null +++ b/Sources/ICCery/ProjectUI.swift @@ -0,0 +1,151 @@ +import SwiftUI +import ICCeryCore + +/// File-menu commands for the project file (issue #149). Content of the +/// `CommandGroup(replacing: .newItem)` in `ICCeryApp` — split out so the +/// app body stays small (type-checker budget). Menu ids are distinct +/// from the sidebar chip ids (`menuProject*` vs `btnProject*`). +struct ProjectCommands: View { + @ObservedObject private var project: ProjectSession + + init(workflow: TargetWorkflowViewModel) { + self._project = ObservedObject(wrappedValue: workflow.project) + } + + var body: some View { + Button("New Project") { project.requestNew() } + .keyboardShortcut("n") + .accessibilityIdentifier("menuProjectNew") + Button("Open Project…") { project.requestOpen() } + .keyboardShortcut("o") + .accessibilityIdentifier("menuProjectOpen") + Menu("Open Recent") { + ForEach(project.recents) { entry in + // Names render via Text only (#114); never the raw path. + Button(entry.name) { project.openRecent(entry) } + .accessibilityIdentifier("projectRecent-\(entry.bookmarkHash)") + } + Divider() + Button("Clear Menu") { project.clearRecents() } + .accessibilityIdentifier("menuProjectRecentsClear") + } + .disabled(project.recents.isEmpty) + .accessibilityIdentifier("menuProjectRecents") + Divider() + Button("Save Project") { project.saveProject() } + .keyboardShortcut("s") + .disabled(!project.canSave) + .accessibilityIdentifier("menuProjectSave") + Button("Save Project As…") { project.saveProjectAs() } + .keyboardShortcut("s", modifiers: [.command, .shift]) + .disabled(!project.canSaveAs) + .accessibilityIdentifier("menuProjectSaveAs") + Button("Save Report…") { project.saveReport() } + .disabled(!project.canReport) + .accessibilityIdentifier("menuProjectReport") + Divider() + Button("Close Project") { project.requestClose() } + .disabled(!project.isBound) + .accessibilityIdentifier("menuProjectClose") + } +} + +/// Compact project footer at the bottom of the 270 pt sidebar (issue +/// #149) — never a fourth row of large buttons (R10). +struct ProjectChip: View { + @ObservedObject var project: ProjectSession + @Binding var showingAllHelp: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + if let bound = project.project { + Text(bound.name) + .font(.callout) + .foregroundStyle(Theme.text) + .lineLimit(1) + .accessibilityIdentifier("projectChipName") + Text(URL(fileURLWithPath: bound.cwd).lastPathComponent) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + // The raw path is a tooltip — never the window title. + .help(bound.cwd) + .accessibilityIdentifier("projectChipPath") + if project.diskBehindNotes { + Text("Disk behind project notes") + .font(.caption) + .foregroundStyle(.orange) + .accessibilityIdentifier("projectChipStale") + } + HStack(spacing: 8) { + Button("Show in Finder") { project.revealInFinder() } + .accessibilityIdentifier("btnProjectReveal") + Button("Save") { project.saveProject() } + .disabled(!project.canSave) + .accessibilityIdentifier("btnProjectSave") + Spacer() + } + .font(.caption) + .controlSize(.small) + } else { + HStack(spacing: 8) { + Text("No project") + .font(.callout) + .foregroundStyle(.secondary) + .accessibilityIdentifier("projectChipName") + Spacer() + Button("Open…") { project.requestOpen() } + .controlSize(.small) + .accessibilityIdentifier("btnProjectOpen") + } + } + } + .padding(8) + .background( + RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium) + .fill(Theme.panel) + ) + .overlay( + RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium) + .stroke(Theme.border) + ) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("projectChip") + .helpOverlay( + "A project remembers printer, preset and folder. The stepper still follows files on disk.", + showing: $showingAllHelp) + } +} + +/// `projectRelocateSheet` — shown when an opened project's `cwd` no +/// longer exists (issue #149). Choosing a folder rewrites `cwd` in the +/// project file atomically, then continues Apply; Cancel aborts the +/// open with live state untouched. +struct ProjectRelocateSheet: View { + @ObservedObject var project: ProjectSession + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Missing project folder") + .font(.title3) + .foregroundStyle(Theme.text) + Text( + "The folder for \(project.pendingRelocate?.project.name ?? "this project") is missing. Choose a new working folder." + ) + .foregroundStyle(Theme.text) + HStack { + Spacer() + Button("Cancel") { project.cancelRelocate() } + .accessibilityIdentifier("btnProjectRelocateCancel") + Button("Choose Folder…") { project.chooseRelocateFolder() } + .accessibilityIdentifier("btnProjectRelocate") + } + } + .padding(20) + .frame(width: 420) + .background(Theme.background) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("projectRelocateSheet") + } +} diff --git a/Sources/ICCery/RootView.swift b/Sources/ICCery/RootView.swift new file mode 100644 index 0000000..d30917b --- /dev/null +++ b/Sources/ICCery/RootView.swift @@ -0,0 +1,153 @@ +import AppKit +import SwiftUI +import ICCeryCore + +/// Root layout: 270 pt sidebar + main stage area with the notification +/// banner pinned to the top (docs/21 §Shell). +struct RootView: View { + @ObservedObject var workflow: TargetWorkflowViewModel + /// Observed directly: nested ObservableObjects are not tracked + /// through the parent's `objectWillChange`. + @ObservedObject private var model: WizardViewModel + @ObservedObject private var project: ProjectSession + @State private var showingSettings = false + @State private var showingAbout = false + @State private var showingAllHelp = false + + init(workflow: TargetWorkflowViewModel) { + self.workflow = workflow + self._model = ObservedObject(wrappedValue: workflow.wizard) + self._project = ObservedObject(wrappedValue: workflow.project) + } + + var body: some View { + HStack(spacing: 0) { + SidebarView( + workflow: workflow, + onOpenSettings: { showingSettings = true }, + onOpenAbout: { showingAbout = true }, + showingAllHelp: $showingAllHelp + ) + + Rectangle() + .fill(Theme.border) + .frame(width: 1) + + VStack(spacing: 0) { + if let notice = model.notice { + NoticeBanner(notice: notice, onClose: model.dismissNotice) + } + WizardStageContent(model: model, workflow: workflow) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(minWidth: 1100, minHeight: 700) + .background(Theme.background) + // #151: re-probe artefacts when the window regains focus — + // files deleted in Finder must re-lock stages. + .onReceive( + NotificationCenter.default.publisher( + for: NSWindow.didBecomeKeyNotification + ) + ) { _ in model.windowDidBecomeKey() } + .sheet(isPresented: $showingSettings) { + SettingsView() + } + .sheet(isPresented: $workflow.showingSavePreset) { + SavePresetDialog(workflow: workflow) + } + .sheet(isPresented: $workflow.showingManagePresets) { + ManagePresetsDialog(workflow: workflow) + } + // Media library sheets live on RootView, never inside the + // 270 pt sidebar column (#146). + .sheet(isPresented: $workflow.showingSaveMedia) { + SaveMediaRecipeDialog(workflow: workflow) + } + .sheet( + isPresented: $workflow.showingManageMedia, + onDismiss: { workflow.media.manageDismissed() } + ) { + ManageMediaDialog(workflow: workflow) + } + // Spot-read console sheet (issue #148). Dismiss runs the same + // `q\n` + ~500 ms + kill path as the sheet's Stop button. + .sheet( + isPresented: $workflow.showingSpotRead, + onDismiss: { workflow.spotRead.sheetClosed() } + ) { + SpotReadView(model: workflow.spotRead) + } + .sheet(isPresented: $showingAbout) { + AboutView { showingAbout = false } + } + .sheet(isPresented: Binding( + get: { workflow.wizard.showingGamutViewer }, + set: { workflow.wizard.showingGamutViewer = $0 } + )) { + GamutView( + environment: workflow.environment, + profileGamURL: workflow.wizard.gamutProfileURL, + showingAllHelp: $showingAllHelp) + } + // Project file chrome (issue #149): window title, New confirm + // alert, dirty alert, relocate sheet. Panels never appear from + // a View — UITestHooks inject fixture paths instead. + .onReceive(project.$windowTitle) { title in + for window in NSApp.windows where !(window is NSPanel) { + window.title = title + } + } + .alert("Start a new project?", isPresented: $project.showingNewAlert) { + Button("Cancel", role: .cancel) {} + .accessibilityIdentifier("btnProjectNewCancel") + Button("Start") { project.confirmNew() } + .accessibilityIdentifier("btnProjectNewConfirm") + } message: { + Text("The working folder and targets on disk are not deleted.") + .accessibilityIdentifier("projectNewAlert") + } + .alert( + "Save the current project first?", + isPresented: $project.showingDirtyAlert + ) { + Button("Save") { project.resolveDirty(save: true) } + .accessibilityIdentifier("btnProjectDirtySave") + Button("Don't Save") { project.resolveDirty(save: false) } + .accessibilityIdentifier("btnProjectDirtyDiscard") + Button("Cancel", role: .cancel) { project.cancelDirty() } + .accessibilityIdentifier("btnProjectDirtyCancel") + } + .sheet(isPresented: $project.showingRelocateSheet) { + ProjectRelocateSheet(project: project) + } + } + +} + +/// Content for the active wizard stage. Isolated into its own view so that +/// `WizardViewModel` is tracked via `@ObservedObject` instead of the parent's +/// `TargetWorkflowViewModel`, which does not observe nested `wizard` mutations. +private struct WizardStageContent: View { + @ObservedObject var model: WizardViewModel + @ObservedObject var workflow: TargetWorkflowViewModel + + var body: some View { + switch model.stage { + case .generate: + Stage1View(workflow: workflow) + case .layOutPrint: + Stage2View(workflow: workflow) + case .measure: + Stage3View(model: workflow.measurement) + case .buildProfile: + Stage4View(model: workflow.profile) + case .verifyInstall: + Stage5View(model: workflow.profile) + case .calibrate: + CalibrationView(model: workflow.calibration, wizard: workflow.wizard) + @unknown default: + Stage1View(workflow: workflow) + } + } +} diff --git a/Sources/ICCery/SettingsView.swift b/Sources/ICCery/SettingsView.swift new file mode 100644 index 0000000..6f5295c --- /dev/null +++ b/Sources/ICCery/SettingsView.swift @@ -0,0 +1,167 @@ +import SwiftUI +import ICCeryCore + +/// Settings sheet (issue #5, docs/21 §Settings). Dark-theme Form with +/// the full v1 field set; ΔE validation shows inline under the fields. +struct SettingsView: View { + @StateObject var model = SettingsViewModel() + @Environment(\.dismiss) private var dismiss + + private static let instruments: [(code: String, label: String)] = [ + ("i1", "X-Rite i1Pro / i1Pro 2"), + ("p3", "X-Rite i1Pro 3 / 3 Plus"), + ("CM", "ColorMunki"), + ("SS", "Specbos / Spectraval"), + ("20", "Gretag i1Display 2"), + ("22", "X-Rite i1Display Pro / ColorMunki Display"), + ("41", "Datacolor Spyder 4/5"), + ("51", "Spyder X"), + ] + + var body: some View { + VStack(spacing: 0) { + Form { + Section("Argyll") { + HStack { + TextField( + "Bundled sidecars", + text: Binding( + get: { model.settings.argyllBinaryDir ?? "" }, + set: { + model.settings.argyllBinaryDir = + $0.isEmpty ? nil : $0 + } + ) + ) + Button("Browse…") { + if let dir = FileDialogService.shared.selectDirectory() { + model.settings.argyllBinaryDir = dir.path + } + } + } + Text("Leave empty to use the bundled Argyll tools.") + .font(.caption) + .foregroundStyle(.secondary) + + Picker( + "Default instrument", + selection: Binding( + get: { model.settings.defaultInstrument ?? "" }, + set: { + model.settings.defaultInstrument = + $0.isEmpty ? nil : $0 + } + ) + ) { + Text("None").tag("") + ForEach(Self.instruments, id: \.code) { + Text($0.label).tag($0.code) + } + } + Text("Seeds Spot Read and Stage 3 when the instrument is plugged in. printtarg -i is still chosen on Stage 2.") + .font(.caption) + .foregroundStyle(.secondary) + + Toggle( + "Enable i1Pro 2 LEDs", + isOn: $model.settings.enableI1Pro2Leds + ) + } + + Section("Verification") { + TextField( + "Good ΔE ≤", + value: $model.settings.deltaEGoodMax, + format: .number + ) + .accessibilityIdentifier("settingsDeltaEGood") + + TextField( + "Warning ΔE ≤", + value: $model.settings.deltaEWarningMax, + format: .number + ) + .accessibilityIdentifier("settingsDeltaEWarning") + + Text("Swatch and verify status use these as the green / amber cutoffs. Fail is anything above Warning.") + .font(.caption) + .foregroundStyle(.secondary) + + ForEach(model.validationErrors, id: \.self) { error in + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + } + + Section("Calibration") { + TextField( + "Stale after (days)", + value: $model.settings.calibrationStaleDays, + format: .number + ) + .accessibilityIdentifier("settingsCalStaleDays") + } + + Section("Profile install") { + Picker( + "Install location", + selection: $model.settings.defaultInstallLocation + ) { + Text("User library").tag(InstallLocation.user) + Text("System library").tag(InstallLocation.system) + } + Toggle( + "Ask before overwriting a profile", + isOn: $model.settings.askBeforeOverwriteProfile + ) + Toggle( + "Open ColorSync after install", + isOn: $model.settings.openColorPanelAfterInstall + ) + } + + Section("Logging") { + Picker( + "Log level", + selection: Binding( + get: { model.settings.logLevel }, + set: { model.settings.logLevel = $0 } + ) + ) { + Text("Default").tag(LogLevel?.none) + ForEach(LogLevel.allCases, id: \.self) { + Text($0.rawValue.capitalized).tag(LogLevel?.some($0)) + } + } + HStack { + Button("Open log folder") { model.openLogFolder() } + Button("Copy path") { model.copyLogPath() } + Button("Copy excerpt") { model.copyLogExcerpt() } + } + } + } + .padding(.leading, 45) + + Divider() + + HStack { + if model.savedFlash { + Text("Saved") + .foregroundStyle(.green) + .font(.callout) + } + Spacer() + Button("Cancel") { dismiss() } + .keyboardShortcut(.cancelAction) + Button("Save") { + if model.save() { dismiss() } + } + .keyboardShortcut(.defaultAction) + } + .padding(12) + } + .frame(width: 560, height: 620) + .background(Theme.background) + } +} diff --git a/Sources/ICCery/SettingsViewModel.swift b/Sources/ICCery/SettingsViewModel.swift new file mode 100644 index 0000000..ed480fa --- /dev/null +++ b/Sources/ICCery/SettingsViewModel.swift @@ -0,0 +1,68 @@ +import AppKit +import Combine +import Foundation +import ICCeryCore + +/// Backs the Settings sheet (issue #5). Load → edit → save with +/// validation; the log level is applied live via `LogSink` (#158) and a +/// `settingsDidChange` notification fans out to #20. +@MainActor +final class SettingsViewModel: ObservableObject { + + @Published var settings: AppSettings + @Published var validationErrors: [String] = [] + @Published var savedFlash = false + + private let store: SettingsStore + private let sink: LogSink + + init(store: SettingsStore = SettingsStore(), sink: LogSink = .shared) { + self.store = store + self.sink = sink + self.settings = store.load() + } + + /// Persists after validation. Returns false (and shows inline + /// errors) when the form is invalid. + @discardableResult + func save() -> Bool { + validationErrors = settings.validate() + guard validationErrors.isEmpty else { return false } + do { + try store.save(settings) + sink.applySettings(settings) + savedFlash = true + Task { + try? await Task.sleep(nanoseconds: 1_500_000_000) + savedFlash = false + } + return true + } catch { + validationErrors = ["Could not save settings: \(error.localizedDescription)"] + return false + } + } + + // MARK: - Log helpers + + var logFileURL: URL { AppPaths.logFile } + + func openLogFolder() { + try? FileManager.default.createDirectory( + at: AppPaths.logDir, withIntermediateDirectories: true + ) + NSWorkspace.shared.selectFile( + AppPaths.logFile.path, inFileViewerRootedAtPath: AppPaths.logDir.path + ) + } + + func copyLogPath() { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(AppPaths.logFile.path, forType: .string) + } + + func copyLogExcerpt() { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(sink.tailExcerpt(), forType: .string) + } +} diff --git a/Sources/ICCery/SidebarView.swift b/Sources/ICCery/SidebarView.swift new file mode 100644 index 0000000..4d937dc --- /dev/null +++ b/Sources/ICCery/SidebarView.swift @@ -0,0 +1,285 @@ +import SwiftUI +import ICCeryCore + +/// 270 pt sidebar (docs/21 §Shell): logo, settings/about buttons, preset +/// select, Calibrate Printer + status chip, and the 1–5 stepper. +struct SidebarView: View { + @ObservedObject var workflow: TargetWorkflowViewModel + /// Observed directly: nested ObservableObjects are not tracked + /// through the parent's `objectWillChange`. + @ObservedObject private var model: WizardViewModel + @ObservedObject private var profile: ProfileWorkflowViewModel + @ObservedObject private var media: MediaLibraryViewModel + @ObservedObject private var printSession: PrintSessionViewModel + @ObservedObject private var measurement: MeasurementWorkflowViewModel + @ObservedObject private var project: ProjectSession + var onOpenSettings: () -> Void + var onOpenAbout: () -> Void + @Binding var showingAllHelp: Bool + + init( + workflow: TargetWorkflowViewModel, + onOpenSettings: @escaping () -> Void, + onOpenAbout: @escaping () -> Void, + showingAllHelp: Binding + ) { + self.workflow = workflow + self._model = ObservedObject(wrappedValue: workflow.wizard) + self._profile = ObservedObject(wrappedValue: workflow.profile) + self._media = ObservedObject(wrappedValue: workflow.media) + self._printSession = ObservedObject(wrappedValue: workflow.print) + self._measurement = ObservedObject(wrappedValue: workflow.measurement) + self._project = ObservedObject(wrappedValue: workflow.project) + self.onOpenSettings = onOpenSettings + self.onOpenAbout = onOpenAbout + self._showingAllHelp = showingAllHelp + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + presetBlock + mediaBlock + studioButtons + stepperAndProject + } + .frame(width: Theme.Metrics.sidebarWidth) + .background(Theme.panel) + } + + // Swift 5.7 (Xcode 14.2 CI runner) caps a ViewBuilder body at 10 + // children (#146); these Group blocks are layout-transparent, so + // visual order, ids and the 270 pt column are unchanged. + private var header: some View { + Group { + HStack { + Image("ICCery-logo") + .resizable() + .scaledToFit() + .frame(height: 40) + Spacer() + Button(action: onOpenSettings) { + Image(systemName: "gearshape") + } + .buttonStyle(.plain) + .helpOverlay("Open the Settings dialog.", showing: $showingAllHelp) + .accessibilityIdentifier("openSettingsBtn") + Button(action: onOpenAbout) { + Image(systemName: "info.circle") + } + .buttonStyle(.plain) + .helpOverlay("Open the About dialog.", showing: $showingAllHelp) + .accessibilityIdentifier("openAboutBtn") + Button(action: { showingAllHelp.toggle() }) { + Image(systemName: showingAllHelp ? "questionmark.circle.fill" : "questionmark.circle") + } + .buttonStyle(.plain) + .help("Toggle help overlays") + .accessibilityIdentifier("btnToggleAllHelp") + } + .padding(12) + + Divider().overlay(Theme.border) + } + } + + private var presetBlock: some View { + Group { + // Preset select (`#presetSelect`) — issue #11. Selection + // applies the preset immediately; names render via Text only. + Picker("Preset", selection: Binding( + get: { workflow.selectedPresetID }, + set: { id in + if id == "none" { + workflow.selectedPresetID = "none" + } else if let preset = workflow.presets.first(where: { $0.id == id }) { + workflow.applyPreset(preset) + } + } + )) { + Text("No preset").tag("none") + ForEach(workflow.presets) { preset in + Text(preset.name).tag(preset.id) + } + } + .pickerStyle(.menu) + .accessibilityIdentifier("presetSelect") + .padding(.horizontal, 12) + .padding(.vertical, 8) + + HStack(spacing: 8) { + Button("Save") { workflow.showingSavePreset = true } + .accessibilityIdentifier("btnSavePresetModal") + Button("Manage") { workflow.showingManagePresets = true } + .accessibilityIdentifier("btnOpenPresetsDialog") + Spacer() + } + .padding(.horizontal, 12) + .padding(.bottom, 8) + } + } + + private var mediaBlock: some View { + Group { + // 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) + } + } + + private var studioButtons: some View { + Group { + // Calibrate Printer (`#btnCalibratePrinter`). + Button(action: { model.enterCalibration() }) { + Label("Calibrate Printer", systemImage: "slider.horizontal.3") + .frame(maxWidth: .infinity) + } + .controlSize(.large) + .accessibilityIdentifier("btnCalibratePrinter") + .padding(.horizontal, 12) + + Button(action: { model.openGamut(profileGamURL: workflow.profile.createdGamutURL) }) { + Label("View Gamut", systemImage: "view.3d") + .frame(maxWidth: .infinity) + } + .controlSize(.large) + .helpOverlay( + "View the profile gamut in 3D against sRGB.", + showing: $showingAllHelp) + .accessibilityIdentifier("btnViewGamut") + .padding(.horizontal, 12) + + // Spot Read sheet (`#btnSpotRead`) — issue #148. Enabled + // only with a working folder (#59) and while no Stage 3 + // chartread child is live; opening never kills + // `chartread_{basename}`. + Button(action: { workflow.showingSpotRead = true }) { + Label("Spot Read", systemImage: "eyedropper") + .frame(maxWidth: .infinity) + } + .controlSize(.large) + .disabled(model.workingDirectory == nil || measurement.isChartreadRunning) + .helpOverlay( + model.workingDirectory == nil + ? "Set a working folder in Stage 1 first." + : (measurement.isChartreadRunning + ? "Stop the Stage 3 chart read first." + : "Read a single patch as Lab/XYZ from the instrument."), + showing: $showingAllHelp) + .accessibilityIdentifier("btnSpotRead") + .padding(.horizontal, 12) + } + } + + private var stepperAndProject: some View { + Group { + Divider().overlay(Theme.border) + .padding(.vertical, 8) + + // Stepper 1–5. + VStack(alignment: .leading, spacing: 2) { + ForEach(WizardStage.stepperStages, id: \.self) { stage in + StepperRow( + stage: stage, + isActive: model.stage == stage, + // Artefact gating (issue #4) — disk is truth. + isEnabled: model.isUnlocked(stage) + ) { + model.go(to: stage) + } + } + } + .padding(.horizontal, 6) + + Spacer() + + // Project chip (issue #149) — a compact footer in the + // spacer's bottom, under the stepper. The 270 pt column + // cannot take four more large buttons (R10). + ProjectChip(project: project, showingAllHelp: $showingAllHelp) + .padding(8) + } + } +} + +private struct StepperRow: View { + let stage: WizardStage + let isActive: Bool + let isEnabled: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 10) { + ZStack { + Circle() + .fill(isActive ? Theme.accent : Theme.border) + .frame(width: 26, height: 26) + Text("\(stage.stepperIndex ?? 0)") + .font(.callout.bold()) + .foregroundStyle(isActive ? .white : Theme.text) + } + Label(stage.title, systemImage: stage.symbolName) + .font(.callout) + .foregroundStyle(isActive ? Theme.text : .secondary) + Spacer() + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(!isEnabled) + .opacity(isEnabled ? 1 : 0.45) + .background( + RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium) + .fill(isActive ? Theme.accent.opacity(0.15) : .clear) + ) + } +} diff --git a/Sources/ICCery/SpotReadView.swift b/Sources/ICCery/SpotReadView.swift new file mode 100644 index 0000000..9d5aea1 --- /dev/null +++ b/Sources/ICCery/SpotReadView.swift @@ -0,0 +1,360 @@ +import SwiftUI +import ICCeryCore + +/// Spot Read sheet (issue #148) — one patch Lab/XYZ from the live +/// instrument. A `RootView` sheet, not a wizard stage and not a Stage 3 +/// tab; all identifiers are `spot*` — Stage 3 `chartread` ids are never +/// reused here. +struct SpotReadView: View { + @ObservedObject var model: SpotReadViewModel + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + header + if !model.sidecarAvailable { + missingSidecar + } else { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + instrumentCard + promptLine + transport + lastSampleCard + historySection + } + } + } + footer + } + .padding(16) + .frame(width: 560, height: 640) + .background(Theme.background) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("spotReadView") + .onAppear { model.sheetOpened() } + .onDisappear { model.sheetClosed() } + } + + // MARK: - Header / missing sidecar + + private var header: some View { + HStack(alignment: .firstTextBaseline) { + Text("Spot Read") + .font(.title3) + .foregroundStyle(Theme.text) + Spacer() + if model.isRunning { + ProgressView() + .scaleEffect(0.8) + } + } + } + + private var missingSidecar: some View { + VStack(alignment: .leading, spacing: 12) { + Text("spotread sidecar missing — run fetch-argyll") + .foregroundStyle(Theme.text) + .accessibilityIdentifier("spotSidecarMissing") + Spacer() + } + } + + // MARK: - Instrument card (clones Stage 3 look, own ids) + + private var instrumentCard: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Instrument") + .font(.headline) + .foregroundStyle(Theme.text) + Spacer() + Button(action: { model.detectInstruments() }) { + Image(systemName: "arrow.clockwise") + } + .disabled(!model.canDetect) + .accessibilityIdentifier("btnSpotDetectInstruments") + } + + if let error = model.detectionError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + .accessibilityIdentifier("spotDetectError") + } + + Picker("Instrument", selection: Binding( + get: { instrumentTag }, + set: { newTag in + if newTag.isEmpty { + model.selectedInstrument = .auto + } else if let device = model.instruments.first(where: { "\($0.port)" == newTag }) { + model.selectedInstrument = .device(device) + } + } + )) { + Text("Auto (first available port)").tag("") + ForEach(model.instruments) { device in + Text(device.displayName).tag("\(device.port)") + } + } + .pickerStyle(.menu) + .disabled(model.isRunning) + .accessibilityIdentifier("spotInstrumentSelect") + + if model.defaultMissing { + Text("Saved default instrument not present") + .font(.caption) + .foregroundStyle(.orange) + .accessibilityIdentifier("spotDefaultMissing") + } + + Toggle("Also set as default instrument", isOn: Binding( + get: { model.setAsDefault }, + set: { model.applyDefaultToggle($0) } + )) + .accessibilityIdentifier("spotSetDefault") + + if model.selectedInstrument.isXY { + Text("XY tables use Stage 3. Spot Read is a handheld / reflective probe.") + .font(.caption) + .foregroundStyle(Theme.accent) + .accessibilityIdentifier("spotXYHint") + } + } + .padding(16) + .background(Theme.panel) + } + + private var instrumentTag: String { + switch model.selectedInstrument { + case .auto: + return "" + case .device(let device): + return "\(device.port)" + } + } + + // MARK: - Prompt line + + private var promptLine: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Status") + .font(.headline) + .foregroundStyle(Theme.text) + Spacer() + Text(model.prompt) + .font(.callout) + .foregroundStyle(Theme.text) + .accessibilityIdentifier("spotPrompt") + } + if let error = model.lastError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + .accessibilityIdentifier("spotLastError") + .accessibilityValue(error) + } + if !model.log.isEmpty { + ProcessLogView( + lines: model.log, + minHeight: 60, + maxHeight: 100, + containerId: "spotLogContainer", + logId: "spotLog" + ) + } + } + .padding(16) + .background(Theme.panel) + } + + // MARK: - Transport + + private var transport: some View { + HStack(spacing: 12) { + if !model.isRunning { + Button("Start") { model.start() } + .disabled(!model.canStart) + .accessibilityIdentifier("btnSpotStart") + } else { + switch model.state { + case .calibrating: + Button("Calibrate") { model.calibrate() } + .accessibilityIdentifier("btnSpotCalibrate") + case .awaitingStrip: + Button("Read") { model.trigger() } + .accessibilityIdentifier("btnSpotTrigger") + default: + EmptyView() + } + Button("Stop") { model.stopIfNeeded() } + .accessibilityIdentifier("btnSpotStop") + } + Spacer() + } + .padding(.horizontal, 4) + } + + // MARK: - Last sample + + @ViewBuilder + private var lastSampleCard: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Last sample") + .font(.headline) + .foregroundStyle(Theme.text) + + if let sample = model.displayedSample { + HStack(spacing: 16) { + let rgb = LabColorMath.labToSRGB(sample.lab) + RoundedRectangle(cornerRadius: 4) + .fill(Color(red: rgb.r, green: rgb.g, blue: rgb.b)) + .frame(width: 32, height: 32) + .overlay(RoundedRectangle(cornerRadius: 4).stroke(Theme.border)) + .accessibilityIdentifier("spotSwatch") + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 12) { + Text(String(format: "L* %.1f", sample.lab.l)) + .accessibilityIdentifier("spotLabL") + Text(String(format: "a* %.1f", sample.lab.a)) + .accessibilityIdentifier("spotLabA") + Text(String(format: "b* %.1f", sample.lab.b)) + .accessibilityIdentifier("spotLabB") + } + .font(.callout) + .foregroundStyle(Theme.text) + + if let xyz = sample.xyz { + Text(String(format: "XYZ %.2f %.2f %.2f", xyz.x, xyz.y, xyz.z)) + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityIdentifier("spotXYZ") + } + + HStack(spacing: 8) { + Text(sample.port.map { "\(sample.instrumentName) · port \($0)" } + ?? sample.instrumentName) + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityIdentifier("spotLastInstrument") + + if let de = model.displayedDeltaE { + HStack(spacing: 6) { + Circle() + .fill(deltaEColor) + .frame(width: 8, height: 8) + Text(String(format: "ΔE %.2f", de)) + .font(.caption) + .foregroundStyle(.secondary) + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("spotDeltaE") + } + } + + if model.isDisplayedLabImplausible { + Text("Implausible L*") + .font(.caption) + .foregroundStyle(.orange) + .accessibilityIdentifier("spotLabImplausible") + } + } + Spacer() + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("spotLastSample") + } else { + Text("No readings yet.") + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityIdentifier("spotLastEmpty") + } + } + .padding(16) + .background(Theme.panel) + } + + private var deltaEColor: Color { + switch model.deltaEClassification { + case .good, nil: return .green + case .warning: return .orange + case .bad: return .red + } + } + + // MARK: - History + + @ViewBuilder + private var historySection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("History") + .font(.headline) + .foregroundStyle(Theme.text) + + if model.samples.isEmpty { + Text("No history.") + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityIdentifier("spotHistoryEmpty") + } else { + List { + ForEach(Array(model.samples.enumerated()), id: \.element.id) { index, sample in + historyRow(index: index, sample: sample) + } + } + .frame(minHeight: 120) + .accessibilityIdentifier("spotHistoryTable") + } + } + .padding(16) + .background(Theme.panel) + } + + private func historyRow(index: Int, sample: SpotReadSample) -> some View { + let previous = index + 1 < model.samples.count ? model.samples[index + 1] : nil + let deltaE = previous.map { ColorDifference.deltaE00($0.lab, sample.lab) } + return Button(action: { model.selectFromHistory(sample) }) { + HStack(spacing: 10) { + Text(sample.timestamp, style: .time) + .frame(width: 70, alignment: .leading) + Text(String(format: "%.1f", sample.lab.l)) + .frame(width: 44, alignment: .trailing) + Text(String(format: "%.1f", sample.lab.a)) + .frame(width: 44, alignment: .trailing) + Text(String(format: "%.1f", sample.lab.b)) + .frame(width: 44, alignment: .trailing) + Text(deltaE.map { String(format: "%.2f", $0) } ?? "") + .frame(width: 44, alignment: .trailing) + Text(sample.instrumentName) + .lineLimit(1) + Spacer() + } + .font(.caption) + .foregroundStyle(Theme.text) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier("spotHistoryRow-\(sample.id.uuidString)") + } + + // MARK: - Footer + + private var footer: some View { + HStack(spacing: 12) { + Button("Copy Lab") { model.copyLab() } + .disabled(model.displayedSample == nil) + .accessibilityIdentifier("btnSpotCopyLab") + Button("Export CSV…") { model.exportCsv() } + .disabled(model.samples.isEmpty) + .accessibilityIdentifier("btnSpotExportCsv") + Spacer() + Button("Close") { dismiss() } + .keyboardShortcut(.cancelAction) + .accessibilityIdentifier("btnCloseSpotRead") + } + .padding(.top, 4) + } +} diff --git a/Sources/ICCery/SpotReadViewModel.swift b/Sources/ICCery/SpotReadViewModel.swift new file mode 100644 index 0000000..19d311b --- /dev/null +++ b/Sources/ICCery/SpotReadViewModel.swift @@ -0,0 +1,420 @@ +import AppKit +import Combine +import Foundation +import ICCeryCore + +/// Spot-read console state and interaction (issue #148). +/// +/// Runs the bundled `spotread` sidecar under the single-lease process id +/// `spotread`; Stage 3 `chartread` is untouched. `defaultInstrument` +/// seeds the instrument picker on sheet open — it is never written into +/// `printtarg -i` or `targen` argv (R15). +@MainActor +final class SpotReadViewModel: ObservableObject { + + let workflow: TargetWorkflowViewModel + let environment: AppEnvironment + private let fileDialogs = FileDialogService.shared + + // MARK: - Instrument card + + @Published var instruments: [InstrumentDevice] = [] + @Published var selectedInstrument: InstrumentSelection = .auto + @Published var isDetecting = false + @Published var detectionError: String? + /// `spotDefaultMissing` — set when `defaultInstrument` is saved but + /// no detected device matches it. + @Published var defaultMissing = false + /// `spotSetDefault` toggle state. + @Published var setAsDefault = false + + // MARK: - Session + + @Published var isRunning = false + @Published var state: ChartreadState = .idle + @Published var prompt = "Press Start to open the instrument." + @Published var lastError: String? + @Published var log: [String] = [] + + // MARK: - Samples / history (in-memory, cap 50, newest first) + + @Published private(set) var samples: [SpotReadSample] = [] + @Published private(set) var displayedSample: SpotReadSample? + @Published private(set) var displayedDeltaE: Double? + + private let historyLimit = 50 + private var streamTask: Task? + + init(workflow: TargetWorkflowViewModel, environment: AppEnvironment) { + self.workflow = workflow + self.environment = environment + } + + // MARK: - Derived state + + /// Whether the bundled `spotread` sidecar resolves to an executable. + /// `BinaryResolver` only — never `$PATH`, never `chartread`. + var sidecarAvailable: Bool { + let url = environment.runner.binaryResolver.resolve("spotread") + return environment.runner.binaryResolver.exists(url) + } + + var isChartreadRunning: Bool { workflow.measurement.isChartreadRunning } + + var canStart: Bool { + sidecarAvailable && !isDetecting && !isRunning && !isChartreadRunning + && workflow.wizard.effectiveWorkingDirectory != nil + } + + var canDetect: Bool { !isDetecting && !isRunning } + + var deltaEClassification: SwatchClassification? { + guard let de = displayedDeltaE else { return nil } + let settings = environment.settingsStore.load() + return ColorDifference.classify( + deltaE: de, + goodMax: settings.deltaEGoodMax, + warningMax: settings.deltaEWarningMax) + } + + /// `spotLabImplausible` — L* outside 0…100 still displays, unclamped. + var isDisplayedLabImplausible: Bool { + guard let l = displayedSample?.lab.l else { return false } + return l < 0 || l > 100 + } + + // MARK: - Sheet lifecycle + + /// Called from `SpotReadView.onAppear`. Resets the in-memory session + /// and runs detection once; a missing sidecar gets a wizard notice. + func sheetOpened() { + samples = [] + displayedSample = nil + displayedDeltaE = nil + log = [] + lastError = nil + state = .idle + prompt = "Press Start to open the instrument." + + guard sidecarAvailable else { + workflow.wizard.showNotice( + "spotread sidecar missing — run fetch-argyll", kind: .error) + return + } + detectInstruments() + } + + /// Called from `onDisappear` *and* the sheet's `onDismiss` — clearing + /// the flag alone is not enough; a live child must be quit and + /// killed (R14). + func sheetClosed() { + stopIfNeeded() + samples = [] + displayedSample = nil + displayedDeltaE = nil + log = [] + defaultMissing = false + } + + // MARK: - Detection + + func detectInstruments() { + guard canDetect else { return } + isDetecting = true + detectionError = nil + + Task { @MainActor [weak self] in + guard let self else { return } + // `instlist` is an exclusive lease (#116) — never spawn a + // second one; surface the busy state instead. + if await self.environment.runner.processManager.isRunning(ProcessID.instlist) { + self.detectionError = "Instrument detection is already running." + self.isDetecting = false + return + } + do { + let devices = try await self.environment.runner.detectInstruments() + self.instruments = devices + self.seedDefault(from: devices) + if case .device(let selected) = self.selectedInstrument, + !devices.contains(where: { $0.port == selected.port }) { + self.selectedInstrument = .auto + } + } catch { + self.detectionError = error.localizedDescription + } + self.isDetecting = false + } + } + + /// Seed the picker from `AppSettings.defaultInstrument`; no match → + /// `.auto` + `spotDefaultMissing`. + private func seedDefault(from devices: [InstrumentDevice]) { + guard let code = environment.settingsStore.load().defaultInstrument, + !code.isEmpty else { + defaultMissing = false + return + } + if let match = devices.first(where: { Self.matches(code: code, device: $0) }) { + selectedInstrument = .device(match) + defaultMissing = false + } else { + selectedInstrument = .auto + defaultMissing = true + } + } + + /// Whether an `instlist` device corresponds to a `printtarg -i` / + /// settings instrument code (`i1`, `CM`, `p3`, `SS`, `20`/`22`/`41`/`51`). + static func matches(code: String, device: InstrumentDevice) -> Bool { + let haystack = "\(device.name) \(device.type)".lowercased() + switch code { + case "i1": return haystack.contains("i1pro") && !haystack.contains("i1pro 3") && !haystack.contains("i1pro3") + case "p3": return haystack.contains("i1pro 3") || haystack.contains("i1pro3") + case "CM": return haystack.contains("colormunki") + case "SS": return haystack.contains("specbos") || haystack.contains("spectraval") || haystack.contains("spectroscan") || haystack.contains("spectro scan") + case "20": return haystack.contains("display 2") + case "22": return haystack.contains("display") + case "41": return haystack.contains("spyder 4") || haystack.contains("spyder 5") || haystack.contains("spyder4") || haystack.contains("spyder5") + case "51": return haystack.contains("spyder x") + default: return false + } + } + + /// Reverse of `matches` — most specific codes first. + static func code(for device: InstrumentDevice) -> String? { + for code in ["p3", "51", "41", "22", "20", "CM", "SS", "i1"] + where matches(code: code, device: device) { + return code + } + return nil + } + + /// `spotSetDefault` — writes `AppSettings.defaultInstrument` only. + /// Never touches `printtarg -i` or `targen`. + func applyDefaultToggle(_ on: Bool) { + setAsDefault = on + var settings = environment.settingsStore.load() + if on, case .device(let device) = selectedInstrument { + settings.defaultInstrument = Self.code(for: device) + } else if !on { + settings.defaultInstrument = nil + } + try? environment.settingsStore.save(settings) + } + + // MARK: - Session control + + func start() { + guard sidecarAvailable else { + lastError = "spotread sidecar missing — run fetch-argyll" + return + } + guard !isChartreadRunning else { + lastError = "Stop the Stage 3 chart read first." + return + } + guard let cwd = workflow.wizard.effectiveWorkingDirectory else { + lastError = "Set a working folder in Stage 1 first." + return + } + Task { @MainActor [weak self] in + guard let self else { return } + // `spotread` is an exclusive lease — a second Start while a + // child is live is an error, not a kill + respawn (#116). + if await self.environment.runner.processManager.isRunning(ProcessID.spotread) { + self.lastError = "A spotread session is already running." + return + } + self.begin(config: self.buildConfig(cwd: cwd)) + } + } + + private func buildConfig(cwd: URL) -> SpotReadConfig { + let port: Int? + let name: String + switch selectedInstrument { + case .auto: + port = nil + name = "Auto" + case .device(let device): + port = device.port + name = device.name + } + return SpotReadConfig( + workingDirectory: cwd, + selectedPort: selectedInstrument.chartreadPort, + enableLEDs: environment.settingsStore.load().enableI1Pro2Leds, + isXY: selectedInstrument.isXY, + instrumentName: name, + instrumentPort: port + ) + } + + private func begin(config: SpotReadConfig) { + isRunning = true + state = .idle + lastError = nil + prompt = "Waiting for a reading…" + + let stream = environment.runner.runSpotread(config: config) + streamTask = Task { @MainActor [weak self] in + guard let self else { return } + for await event in stream { + self.handle(event: event) + } + self.isRunning = false + self.state = .idle + if self.lastError == nil { + self.prompt = "Press Start to open the instrument." + } + } + } + + private func handle(event: SpotReadEvent) { + switch event { + case .prompt(let result): + state = result.state + prompt = promptText(for: result.state) + + case .sample(let sample): + let previous = samples.first + samples.insert(sample, at: 0) + if samples.count > historyLimit { + samples.removeLast() + } + displayedSample = sample + displayedDeltaE = previous.map { + ColorDifference.deltaE00($0.lab, sample.lab) + } + + case .log(let batch): + log.append(contentsOf: batch) + + case .exit(let code): + if code != 0 { + lastError = "spotread exited with code \(code)" + } + + case .failed(let error): + lastError = error.localizedDescription + } + } + + private func promptText(for state: ChartreadState) -> String { + switch state { + case .calibrating: + return "Place the instrument on the calibration tile, then Calibrate." + case .awaitingStrip: + return "Place on the patch, then Read." + case .reading, .promptContinue: + return "Waiting for a reading…" + case .warning: + return "Instrument warning — stop and restart if it persists." + case .error: + return "Read error — Stop, then Start again." + default: + return "Waiting for a reading…" + } + } + + // MARK: - Transport + + /// `btnSpotCalibrate` — same bytes Stage 3 sends for calibrate. + func calibrate() { + send(.trigger) + } + + /// `btnSpotTrigger` — the Read key (`" \n"`). + func trigger() { + send(.trigger) + } + + private func send(_ input: ChartreadInput) { + Task { @MainActor [weak self] in + guard let self, self.isRunning else { return } + try? await self.environment.runner.sendSpotreadInput(input) + } + } + + /// `btnSpotStop` / sheet dismiss: `q\n`, ~500 ms, then kill if the + /// child is still live. + func stopIfNeeded() { + guard isRunning else { return } + streamTask?.cancel() + streamTask = nil + let processManager = environment.runner.processManager + Task { @MainActor in + try? await processManager.sendStdin( + id: ProcessID.spotread, bytes: ChartreadInput.quit.bytes) + try? await Task.sleep(nanoseconds: 500_000_000) + await processManager.kill(id: ProcessID.spotread) + } + isRunning = false + state = .idle + prompt = "Press Start to open the instrument." + } + + // MARK: - History / export + + /// Click a history row: copies that sample into the last-sample card. + /// Never re-triggers the instrument. + func selectFromHistory(_ sample: SpotReadSample) { + displayedSample = sample + if let index = samples.firstIndex(of: sample), index + 1 < samples.count { + displayedDeltaE = ColorDifference.deltaE00(samples[index + 1].lab, sample.lab) + } else { + displayedDeltaE = nil + } + } + + /// `btnSpotCopyLab` — `L* a* b*` of the displayed sample as plain + /// text (`50.0 1.2 -3.4`). + func copyLab() { + guard let sample = displayedSample else { return } + let text = String(format: "%.1f %.1f %.1f", sample.lab.l, sample.lab.a, sample.lab.b) + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } + + /// `btnSpotExportCsv` — RFC-4180 via `selectCsvSavePath`. Cancel is + /// a no-op. Rows are newest-first, matching the history list. + func exportCsv() { + guard !samples.isEmpty else { return } + let url = UITestHooks.isEnabled + ? UITestHooks.csvExportURL + : fileDialogs.selectCsvSavePath() + guard let url else { return } + + var out = "timestamp,L,a,b,dE00,instrument,port\r\n" + for (index, sample) in samples.enumerated() { + let deltaE = index + 1 < samples.count + ? String(format: "%.2f", ColorDifference.deltaE00(samples[index + 1].lab, sample.lab)) + : "" + out += "\(csvField(iso8601(sample.timestamp))),\(f1(sample.lab.l)),\(f1(sample.lab.a)),\(f1(sample.lab.b)),\(deltaE),\(csvField(sample.instrumentName)),\(sample.port.map(String.init) ?? "")\r\n" + } + + do { + try out.write(to: url, atomically: true, encoding: .utf8) + workflow.wizard.showNotice("Spot readings exported: \(url.lastPathComponent)") + } catch { + workflow.wizard.showNotice( + "Export failed: \(error.localizedDescription)", kind: .error) + } + } + + private func f1(_ value: Double) -> String { + String(format: "%.1f", value) + } + + private func iso8601(_ date: Date) -> String { + ISO8601DateFormatter().string(from: date) + } + + private func csvField(_ text: String) -> String { + guard text.contains(",") || text.contains("\"") || text.contains("\n") else { + return text + } + return "\"\(text.replacingOccurrences(of: "\"", with: "\"\""))\"" + } +} diff --git a/Sources/ICCery/Stage1View.swift b/Sources/ICCery/Stage1View.swift new file mode 100644 index 0000000..9b40307 --- /dev/null +++ b/Sources/ICCery/Stage1View.swift @@ -0,0 +1,249 @@ +import SwiftUI +import ICCeryCore + +/// Stage 1 — `#stage-1` Generate Target (`targen` → `.ti1`, issue #7, +/// docs/08). All documented element ids are wired as accessibility +/// identifiers so the UI-test contract stays stable. +struct Stage1View: View { + @ObservedObject var workflow: TargetWorkflowViewModel + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + colourSpaceSection + patchSection + targetSection + advancedSection + actionRow + logSection + } + .padding(20) + .frame(maxWidth: .infinity, alignment: .leading) + } + .background(Theme.background) + .accessibilityIdentifier("stage-1") + } + + // MARK: - Colour space (name="colourSpace") + + private var colourSpaceSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Colour space").font(.headline).foregroundStyle(Theme.text) + Picker("Colour space", selection: $workflow.colourSpace) { + Text("RGB (print drivers)").tag(ColourSpace.rgb) + Text("CMYK (RIP output)").tag(ColourSpace.cmyk) + } + .pickerStyle(.segmented) + .accessibilityIdentifier("colourSpace") + } + } + + // MARK: - Patch count + white/black + + private var patchSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Patches").font(.headline).foregroundStyle(Theme.text) + HStack(spacing: 16) { + Picker("Patch count", selection: $workflow.patchPreset) { + ForEach(PatchCountPreset.allCases, id: \.self) { + Text($0.title).tag($0) + } + } + .accessibilityIdentifier("patchCountPreset") + .frame(maxWidth: 220) + + if workflow.patchPreset == .custom { + TextField("Patches", value: $workflow.customPatchCount, format: .number) + .textFieldStyle(.roundedBorder) + .frame(width: 90) + .accessibilityIdentifier("patchCountCustom") + } + } + HStack(spacing: 16) { + Stepper(value: $workflow.whitePatches, in: 0...50) { + Text("White patches: \(workflow.whitePatches)") + } + .accessibilityIdentifier("whitePatches") + Stepper(value: $workflow.blackPatches, in: 0...50) { + Text("Black patches: \(workflow.blackPatches)") + } + .accessibilityIdentifier("blackPatches") + } + .foregroundStyle(Theme.text) + } + } + + // MARK: - Target file / working directory + + private var targetSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Target file").font(.headline).foregroundStyle(Theme.text) + HStack(spacing: 8) { + TextField("Basename (no extension)", text: $workflow.targetBasename) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("targetBasename") + Button("Browse…") { workflow.browseForTargetFile() } + .accessibilityIdentifier("btnBrowse") + Button("Working Dir…") { workflow.browseForWorkingDirectory() } + .accessibilityIdentifier("btnSelectWorkDir") + Button("Open Existing…") { workflow.openExistingTarget() } + .accessibilityIdentifier("btnOpenExisting") + Button("Import Dataset…") { workflow.importMeasurementDataset() } + .accessibilityIdentifier("btn-import-dataset") + } + Text(workflow.targetDirectory?.path ?? "No working directory selected") + .font(.caption) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .accessibilityIdentifier("selectedPathDisplay") + } + } + + // MARK: - Advanced (#targenAdvancedDetails) + + /// UI tests pre-expand the group — XCUI cannot reliably toggle a + /// macOS `DisclosureTriangle` (its click lands on the label). + @State private var advancedExpanded = UITestHooks.isEnabled + + private var advancedSection: some View { + DisclosureGroup("Advanced", isExpanded: $advancedExpanded) { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .top, spacing: 24) { + VStack(alignment: .leading, spacing: 10) { + optionalInt("Grey steps (-g)", + enabled: $workflow.greyStepsEnabled, + value: $workflow.greySteps) + .accessibilityIdentifier("targenGreySteps") + optionalInt("Single-channel steps (-s)", + enabled: $workflow.singleChannelEnabled, + value: $workflow.singleChannelSteps) + .accessibilityIdentifier("targenSingleChannelSteps") + optionalInt("Neutral steps (-n)", + enabled: $workflow.neutralStepsEnabled, + value: $workflow.neutralSteps) + .accessibilityIdentifier("targenNeutralSteps") + optionalDouble("Neutral concentration (-N)", + enabled: $workflow.neutralConcEnabled, + value: $workflow.neutralConcentration, + range: 0.0...1.0) + .accessibilityIdentifier("targenNeutralConcentration") + optionalDouble("OFPS adaptation (-A)", + enabled: $workflow.adaptationEnabled, + value: $workflow.adaptation, + range: 0.0...1.0) + .accessibilityIdentifier("targenAdaptation") + } + VStack(alignment: .leading, spacing: 10) { + HStack { + TextField("Preconditioning profile", + text: Binding( + get: { workflow.preconditioningProfile ?? "" }, + set: { + workflow.preconditioningProfile = + $0.isEmpty ? nil : $0 + })) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("targenPrecondProfile") + Button("…") { workflow.browseForPreconditioningProfile() } + .accessibilityIdentifier("btnBrowsePrecondProfile") + } + Toggle("OFPS high quality (-G)", isOn: $workflow.highQuality) + .accessibilityIdentifier("targenHighQuality") + Picker("Full-spread algorithm", selection: $workflow.algorithm) { + ForEach(FullSpreadAlgorithm.allCases, id: \.self) { + Text($0.displayName).tag($0) + } + } + .accessibilityIdentifier("targenAlgorithm") + if workflow.colourSpace == .cmyk { + optionalInt("Total ink limit (-l)", + enabled: $workflow.inkLimitEnabled, + value: $workflow.totalInkLimit) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("targenInkLimitGroup") + } + optionalDouble("Dark emphasis (-V)", + enabled: $workflow.darkEmphasisEnabled, + value: $workflow.darkEmphasis, + range: 0.0...3.0) + .accessibilityIdentifier("targenDarkEmphasis") + optionalDouble("Device power (-p)", + enabled: $workflow.devicePowerEnabled, + value: $workflow.devicePower, + range: 0.0...3.0) + .accessibilityIdentifier("targenDevicePower") + } + } + } + .foregroundStyle(Theme.text) + .padding(.top, 8) + } + .foregroundStyle(Theme.text) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("targenAdvancedDetails") + } + + private func optionalInt( + _ title: String, + enabled: Binding, + value: Binding + ) -> some View { + HStack { + Toggle(title, isOn: enabled) + .toggleStyle(.checkbox) + if enabled.wrappedValue { + TextField("", value: value, format: .number) + .textFieldStyle(.roundedBorder) + .frame(width: 70) + } + } + } + + private func optionalDouble( + _ title: String, + enabled: Binding, + value: Binding, + range: ClosedRange + ) -> some View { + VStack(alignment: .leading) { + Toggle(title, isOn: enabled) + .toggleStyle(.checkbox) + if enabled.wrappedValue { + HStack { + Slider(value: value, in: range) + Text(value.wrappedValue, format: .number.precision(.fractionLength(2))) + .frame(width: 44) + .monospacedDigit() + } + } + } + } + + // MARK: - Actions + log + + private var actionRow: some View { + HStack { + Button(action: workflow.generateTarget) { + Label(workflow.targenRunning ? "Generating…" : "Generate Target", + systemImage: "square.grid.3x3") + } + .controlSize(.large) + .disabled(!workflow.canGenerate || workflow.targenRunning) + .accessibilityIdentifier("btnGenerate") + if workflow.targenRunning { + ProgressView().controlSize(.small) + } + Spacer() + } + } + + private var logSection: some View { + ProcessLogView( + lines: workflow.targenLog, + minHeight: 120, + maxHeight: 200, + containerId: "targenLogContainer", + logId: "targenLog" + ) + } +} diff --git a/Sources/ICCery/Stage2View.swift b/Sources/ICCery/Stage2View.swift new file mode 100644 index 0000000..e04b162 --- /dev/null +++ b/Sources/ICCery/Stage2View.swift @@ -0,0 +1,406 @@ +import SwiftUI +import ICCeryCore + +/// Stage 2 — `#stage-2` Lay Out & Print (`printtarg` → `.ti2` + TIFFs, +/// issues #9/#10, docs/09). Print controls are visible but inert — +/// real spooling lands in M3. +struct Stage2View: View { + @ObservedObject var workflow: TargetWorkflowViewModel + /// Observed directly: nested ObservableObjects are not tracked + /// through the parent's `objectWillChange`. + @ObservedObject private var printSession: PrintSessionViewModel + @ObservedObject private var wizard: WizardViewModel + + @State private var printGenerationTask: Task? + + init(workflow: TargetWorkflowViewModel) { + self.workflow = workflow + self._printSession = ObservedObject(wrappedValue: workflow.print) + self._wizard = ObservedObject(wrappedValue: workflow.wizard) + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + cmWarning + formSection + labelSection + actionRow + logSection + gallerySection + printPanel + } + .padding(20) + .frame(maxWidth: .infinity, alignment: .leading) + } + .background(Theme.background) + .accessibilityIdentifier("stage-2") + } + + // MARK: - Colour-management warning (#cmWarningBanner) + + private var cmWarning: some View { + HStack(spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + Text("Set your printer driver to “No Colour Adjustment” " + + "(Epson) / “Off (No Colour Adjustment)” (Canon) before printing. " + + "Any driver colour management corrupts the target.") + .font(.callout) + .foregroundStyle(Theme.text) + Spacer() + } + .padding(10) + .background(Color.orange.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium)) + .overlay( + RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium) + .stroke(Color.orange.opacity(0.4)) + ) + .accessibilityIdentifier("cmWarningBanner") + } + + // MARK: - Layout form + + private var formSection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 16) { + Picker("Instrument", selection: $workflow.instrument) { + ForEach(PrintInstrument.allCases, id: \.self) { + Text($0.displayName).tag($0) + } + } + .accessibilityIdentifier("instrumentSelect") + Picker("Page size", selection: $workflow.pageSize) { + ForEach(PageSize.allCases, id: \.self) { + Text($0.rawValue).tag($0) + } + } + .accessibilityIdentifier("pageSizeSelect") + } + if workflow.pageSize == .custom { + HStack(spacing: 8) { + Text("Custom size (mm):") + TextField("W", value: $workflow.customPageW, format: .number) + .textFieldStyle(.roundedBorder).frame(width: 70) + .accessibilityIdentifier("customPageW") + Text("×") + TextField("H", value: $workflow.customPageH, format: .number) + .textFieldStyle(.roundedBorder).frame(width: 70) + .accessibilityIdentifier("customPageH") + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("customPageSizeRow") + } + HStack(spacing: 16) { + Picker("Bit depth", selection: $workflow.bitDepth) { + Text("8-bit TIFF").tag(TiffBitDepth.eight) + Text("16-bit TIFF").tag(TiffBitDepth.sixteen) + } + Stepper("DPI: \(workflow.tiffDpi)", + value: $workflow.tiffDpi, in: 72...600, step: 1) + .accessibilityIdentifier("tiffDpi") + } + HStack(spacing: 16) { + Picker("Layout order", selection: $workflow.layoutOrder) { + ForEach(LayoutOrder.allCases, id: \.self) { + Text($0.displayName).tag($0) + } + } + .accessibilityIdentifier("printtargLayoutOrder") + if workflow.layoutOrder == .customSeed { + HStack { + Text("Seed:") + TextField("", value: $workflow.customSeed, format: .number) + .textFieldStyle(.roundedBorder).frame(width: 80) + .accessibilityIdentifier("printtargCustomSeed") + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("printtargCustomSeedGroup") + } + } + } + .foregroundStyle(Theme.text) + } + + // MARK: - Label (#btnToggleLabelEdit / #targetLabelPreview) + + private var labelSection: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Chart label").font(.headline).foregroundStyle(Theme.text) + Spacer() + Button(workflow.labelIsCustom ? "Use automatic label" : "Edit label…") { + workflow.labelIsCustom.toggle() + } + .accessibilityIdentifier("btnToggleLabelEdit") + } + HStack(spacing: 12) { + TextField("Printer", text: $workflow.metaPrinter) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("targetMetadataPrinter") + TextField("Ink set", text: $workflow.metaInkSet) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("targetMetadataInkSet") + } + HStack(spacing: 12) { + TextField("Driver paper", text: $workflow.metaDriverPaper) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("targetMetadataDriverPaper") + TextField("Actual paper", text: $workflow.metaActualPaper) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("targetMetadataActualPaper") + } + if workflow.labelIsCustom { + TextField("Custom label", text: $workflow.customLabel) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("targetLabelPreview") + } else { + Text(workflow.automaticLabel) + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityIdentifier("targetLabelPreview") + } + } + } + + // MARK: - Actions + log + + private var actionRow: some View { + HStack { + Button(action: workflow.createLayout) { + Label(workflow.printtargRunning ? "Creating layout…" : "Create Layout", + systemImage: "rectangle.grid.2x2") + } + .controlSize(.large) + .disabled(workflow.printtargRunning || workflow.wizard.basename.isEmpty) + .accessibilityIdentifier("btnCreateLayout") + if workflow.printtargRunning { + ProgressView().controlSize(.small) + } + Spacer() + } + } + + private var logSection: some View { + ProcessLogView( + lines: workflow.printtargLog, + minHeight: 100, + maxHeight: 180, + containerId: "printtargLogContainer", + logId: "printtargLog" + ) + } + + // MARK: - TIFF gallery (#tiffGallery) — host-side PNG only (#58) + + @ViewBuilder + private var gallerySection: some View { + if let result = workflow.printtargResult { + VStack(alignment: .leading, spacing: 8) { + Text("Target pages — \(result.manifest.pages.count) page(s), " + + "\(result.manifest.pages.reduce(0) { $0 + $1.patches }) patches") + .font(.headline).foregroundStyle(Theme.text) + .accessibilityIdentifier("galleryInfo") + LazyVGrid( + columns: [GridItem(.adaptive(minimum: 220))], + spacing: 12 + ) { + ForEach(result.pages) { page in + GalleryPageView(page: page, workflow: workflow) + } + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("galleryGrid") + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("tiffGallery") + } + } + + // MARK: - Raw print panel (#rawPrintPanel) — unmanaged lp path + + private var printPanel: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 12) { + Text("Print").font(.headline).foregroundStyle(Theme.text) + if let notice = workflow.print.printNotice { + Image(systemName: notice.kind == .error + ? "xmark.circle.fill" : "info.circle.fill") + .foregroundStyle(notice.kind == .error + ? .red : .blue) + .accessibilityIdentifier("printNotificationIcon") + .accessibilityValue(notice.kind.accessibilityValue) + Text(notice.text) + .font(.caption) + .foregroundStyle(notice.kind == .error + ? .red : .secondary) + .accessibilityIdentifier("printNotificationText") + .accessibilityValue(notice.text) + } + Spacer() + } + .accessibilityElement(children: .contain) + + // Printer row: select + status + refresh + Preferences. + HStack(spacing: 10) { + Picker("Printer", selection: $workflow.print.selectedPrinter) { + ForEach(workflow.print.printers, id: \.name) { printer in + Text(printer.displayName ?? printer.name) + .tag(printer.name) + } + } + .frame(maxWidth: 320) + .accessibilityIdentifier("printerSelect") + .onChange(of: workflow.print.selectedPrinter) { _ in + workflow.print.selectedTray = nil + workflow.print.selectedMediaType = nil + Task { @MainActor in await workflow.print.reloadSelectedCapabilities() } + } + if let selected = workflow.print.printers + .first(where: { $0.name == workflow.print.selectedPrinter }) { + Text(selected.status.rawValue) + .font(.caption).foregroundStyle(.secondary) + .padding(.horizontal, 8).padding(.vertical, 3) + .background(Theme.background) + .clipShape(Capsule()) + .accessibilityIdentifier("printerStatusBadge") + } + Button(action: workflow.print.refreshPrinters) { + Image(systemName: "arrow.clockwise") + } + .help("Refresh printer list") + .accessibilityIdentifier("btnRefreshPrinters") + Button(action: workflow.print.openPrinterPreferences) { + Image(systemName: "gearshape") + } + .help("Printer properties — bound NSPrintPanel") + .disabled(workflow.print.selectedPrinter.isEmpty) + .accessibilityIdentifier("btnPrinterProperties") + } + + // Tray / media / orientation — from queue capabilities. + HStack(spacing: 14) { + if !workflow.print.printerCaps.trays.isEmpty { + Picker("Tray", selection: $workflow.print.selectedTray) { + ForEach(workflow.print.printerCaps.trays, id: \.id) { + Text($0.name).tag(Optional($0.id)) + } + } + .frame(maxWidth: 200) + .accessibilityIdentifier("printerTraySelect") + } + if !workflow.print.printerCaps.mediaTypes.isEmpty { + Picker("Media", selection: $workflow.print.selectedMediaType) { + ForEach(workflow.print.printerCaps.mediaTypes, id: \.id) { + Text($0.name).tag(Optional($0.id)) + } + } + .frame(maxWidth: 240) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("mediaTypeGroup") + .accessibilityIdentifier("printerMediaTypeSelect") + } + HStack(spacing: 0) { + Button("Portrait") { workflow.print.printOrientation = "portrait" } + .buttonStyle(.bordered) + .tint(workflow.print.printOrientation == "portrait" ? .accentColor : .gray) + .accessibilityIdentifier("btnOrientPortrait") + Button("Landscape") { workflow.print.printOrientation = "landscape" } + .buttonStyle(.bordered) + .tint(workflow.print.printOrientation == "landscape" ? .accentColor : .gray) + .accessibilityIdentifier("btnOrientLandscape") + } + Spacer() + } + + HStack(spacing: 8) { + Button(action: { + if let result = workflow.printtargResult { + workflow.print.printAllPages(from: result, pageSize: workflow.pageSize) + } + }) { + Label(workflow.print.isPrinting ? "Printing…" : "Print All", + systemImage: "printer") + } + .controlSize(.large) + .disabled(workflow.print.isPrinting + || workflow.printtargResult == nil + || workflow.print.selectedPrinter.isEmpty) + .accessibilityIdentifier("btnPrintAll") + Spacer() + Button("Advance to Stage 3") { workflow.advanceToStage3() } + .accessibilityIdentifier("btnAdvanceToStage3") + .disabled(workflow.printtargResult == nil + || !workflow.wizard.isUnlocked(.measure)) + } + } + .padding(12) + .background(Theme.panel) + .clipShape(RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium)) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("rawPrintPanel") + .onAppear { schedulePrinterRefresh() } + .onChange(of: workflow.printtargResult?.pages.count) { _ in + schedulePrinterRefresh() + } + } + + /// Auto-enumerates printers once a manifest exists and whenever it + /// changes (e.g. resume from .ti2). The explicit task handle keeps + /// a superseded run from racing the next one. + private func schedulePrinterRefresh() { + printGenerationTask?.cancel() + printGenerationTask = Task { @MainActor in + if workflow.print.printers.isEmpty, workflow.printtargResult != nil { + workflow.print.refreshPrinters() + } + } + } +} + +/// One gallery cell: PNG preview + per-page Print button. +private struct GalleryPageView: View { + let page: GalleryPage + @ObservedObject var workflow: TargetWorkflowViewModel + /// Observed directly: `print` is a nested ObservableObject and its + /// `isPrinting`/`selectedPrinter` changes drive this cell's button. + @ObservedObject private var printSession: PrintSessionViewModel + + init(page: GalleryPage, workflow: TargetWorkflowViewModel) { + self.page = page + self.workflow = workflow + self._printSession = ObservedObject(wrappedValue: workflow.print) + } + + var body: some View { + VStack(spacing: 6) { + if let png = page.previewPNG, let image = NSImage(data: png) { + Image(nsImage: image) + .resizable() + .scaledToFit() + .frame(maxHeight: 240) + } else { + ZStack { + Rectangle().fill(Theme.panel).frame(height: 160) + Text(page.previewError ?? "No preview") + .font(.caption).foregroundStyle(.secondary) + } + } + Text(page.page.filename) + .font(.caption).foregroundStyle(Theme.text) + Text("\(page.page.patches) patches · " + + "\(Int(page.page.widthMm))×\(Int(page.page.heightMm)) mm") + .font(.caption2).foregroundStyle(.secondary) + Button("Print") { workflow.print.printPage(page, pageSize: workflow.pageSize) } + .disabled(workflow.print.isPrinting + || workflow.print.selectedPrinter.isEmpty) + .accessibilityIdentifier("btnPrintPage-\(page.index)") + } + .padding(8) + .background(Theme.panel) + .clipShape(RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium)) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("galleryPage-\(page.index)") + } +} diff --git a/Sources/ICCery/Stage3View.swift b/Sources/ICCery/Stage3View.swift new file mode 100644 index 0000000..8e1b1d8 --- /dev/null +++ b/Sources/ICCery/Stage3View.swift @@ -0,0 +1,389 @@ +import Combine +import SwiftUI +import ICCeryCore + +/// Stage 3 — measurement, live swatches, and multi-pass averaging. +struct Stage3View: View { + @ObservedObject var model: MeasurementWorkflowViewModel + /// `model.basename`/`model.workingDirectory` delegate to `wizard`; + /// observe it directly so header updates propagate. + @ObservedObject private var wizard: WizardViewModel + + init(model: MeasurementWorkflowViewModel) { + self.model = model + self._wizard = ObservedObject(wrappedValue: model.wizard) + } + + var body: some View { + VStack(spacing: 0) { + header + ScrollView { + VStack(alignment: .leading, spacing: 16) { + instrumentSection + chartreadControlsSection + xyTableSection + swatchGridSection + averagingSection + } + .padding(20) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Theme.background) + .onAppear { model.discoverPassSnapshots() } + .onReceive(NotificationCenter.default.publisher(for: SettingsStore.settingsDidChange)) { _ in + model.loadSettings() + model.recomputeSwatches() + } + } + + // MARK: - Header + + @ViewBuilder + private var header: some View { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 4) { + if model.resumedFromTi2 { + Label("Resumed from .ti2", systemImage: "arrow.uturn.right") + .font(.callout) + .foregroundStyle(Theme.accent) + .accessibilityIdentifier("stage3LoadedTargetBanner") + } + Text(model.basename) + .font(.title3) + .foregroundStyle(Theme.text) + .accessibilityIdentifier("stage3TargetBasename") + Text("Measure the printed chart with a spectrophotometer.") + .font(.callout) + .foregroundStyle(.secondary) + .accessibilityIdentifier("stage3TargetMeta") + } + + Spacer() + + if model.isChartreadRunning { + ProgressView() + .scaleEffect(0.8) + .accessibilityIdentifier("readProgress") + } + + if let badge = targetBadge { + Text(badge) + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Theme.border) + .cornerRadius(4) + .foregroundStyle(Theme.text) + .accessibilityIdentifier("stage3TargetBadge") + } + } + .padding(16) + .background(Theme.panel) + } + + private var targetBadge: String? { + if model.isFinished { return "All strips read" } + if model.isChartreadRunning { return "Reading" } + if !model.passSnapshots.isEmpty { return "\(model.passSnapshots.count) pass(es)" } + return nil + } + + // MARK: - Instrument detection + + @ViewBuilder + private var instrumentSection: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Instrument") + .font(.headline) + .foregroundStyle(Theme.text) + Spacer() + Button(action: { model.detectInstruments() }) { + Image(systemName: "arrow.clockwise") + } + .disabled(!model.canDetect) + .accessibilityIdentifier("btnDetectInstruments") + } + + if model.detectionError != nil { + Text(model.detectionError ?? "") + .font(.caption) + .foregroundStyle(.red) + } + + Picker("Instrument", selection: Binding( + get: { instrumentTag }, + set: { newTag in + if newTag.isEmpty { + model.selectedInstrument = .auto + } else if let device = model.instruments.first(where: { "\($0.port)" == newTag }) { + model.selectedInstrument = .device(device) + } + } + )) { + Text("Auto (first available port)").tag("") + ForEach(model.instruments) { device in + Text(device.displayName).tag("\(device.port)") + } + } + .pickerStyle(.menu) + .accessibilityIdentifier("chartreadInstrumentSelect") + + if model.selectedInstrument.isXY { + Text("XY table workflow selected.") + .font(.caption) + .foregroundStyle(Theme.accent) + .accessibilityIdentifier("xyTableHint") + } + } + .padding(16) + .background(Theme.panel) + } + + private var instrumentTag: String { + switch model.selectedInstrument { + case .auto: + return "" + case .device(let device): + return "\(device.port)" + } + } + + // MARK: - Chartread controls + + @ViewBuilder + private var chartreadControlsSection: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Status") + .font(.headline) + .foregroundStyle(Theme.text) + Spacer() + Text(model.currentPrompt ?? "Press Start to begin reading.") + .font(.callout) + .foregroundStyle(Theme.text) + .accessibilityIdentifier("chartreadPrompt") + } + + if model.showRemoveSheetNotice { + Text("Please remove last sheet from table.") + .font(.caption) + .foregroundStyle(Theme.accent) + } + + if let notice = model.chartreadNotice { + Text(notice.text) + .font(.caption) + .foregroundStyle(notice.kind.tint) + .accessibilityIdentifier("chartreadLastError") + .accessibilityValue(notice.text) + } + + controlButtons + + if !model.chartreadLog.isEmpty { + ProcessLogView( + lines: model.chartreadLog, + containerId: "chartreadLogContainer", + logId: "chartreadLog" + ) + } + } + .padding(16) + .background(Theme.panel) + } + + @ViewBuilder + private var controlButtons: some View { + HStack(spacing: 12) { + if !model.isChartreadRunning { + Button("Start Read") { + model.startRead() + } + .disabled(!model.canStartRead) + .accessibilityIdentifier("btnStartRead") + } + + if model.isChartreadRunning { + switch model.chartreadState { + case .calibrating: + Button("Calibrate") { model.calibrate() } + .accessibilityIdentifier("btnCalibrate") + case .awaitingStrip: + Button("Trigger") { model.calibrate() } + .accessibilityIdentifier("btnTrigger") + Button("Done & Save") { model.doneAndSave() } + .accessibilityIdentifier("btnDoneReadEarly") + case .tablePlaceSheet, .tableAlign, .promptContinue, .warning: + Button(continueTitle) { model.accept() } + .accessibilityIdentifier("btnAccept") + case .error: + Button("Retry") { model.retry() } + .accessibilityIdentifier("btnRetry") + case .allStripsRead: + Button("Done & Save") { model.doneAndSave() } + .accessibilityIdentifier("btnDoneRead") + default: + EmptyView() + } + + Button("Cancel") { model.cancelRead() } + .accessibilityIdentifier("btnCancel") + } + } + } + + private var continueTitle: String { + if let key = model.requestedWarningKey { + return "Continue (send '\(key.uppercased())')" + } + return "Continue" + } + + // MARK: - XY table badges + + @ViewBuilder + private var xyTableSection: some View { + if model.selectedInstrument.isXY { + HStack(spacing: 8) { + xyStep("Place", active: model.xyStep == .place, id: "xyStepPlace") + xyStep("Align", active: model.xyStep == .align, id: "xyStepAlign") + xyStep("Scan", active: model.xyStep == .scan, id: "xyStepScan") + xyStep("Remove", active: model.xyStep == .remove, id: "xyStepRemove") + } + .padding(12) + .background(Theme.panel) + .accessibilityIdentifier("xyTablePanel") + } + } + + private func xyStep(_ label: String, active: Bool, id: String) -> some View { + Text(label) + .font(.caption) + .fontWeight(active ? .bold : .regular) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(active ? Theme.accent : Theme.border) + .foregroundStyle(active ? Color.white : Theme.text) + .cornerRadius(4) + .accessibilityIdentifier(id) + } + + // MARK: - Swatch grid + + @ViewBuilder + private var swatchGridSection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Swatches") + .font(.headline) + .foregroundStyle(Theme.text) + Spacer() + statsView + } + + ScrollView([.horizontal, .vertical]) { + VStack(alignment: .leading, spacing: 2) { + ForEach(model.swatchRows) { row in + HStack(spacing: 2) { + Text(row.rowId) + .font(.caption) + .foregroundStyle(.secondary) + .frame(width: 24) + ForEach(row.patches) { swatch in + SwatchPatchView(swatch: swatch) + } + } + } + } + .padding(8) + } + .frame(minHeight: 120, maxHeight: 360) + .background(Theme.panel) + .accessibilityIdentifier("swatchGrid") + } + .padding(16) + .background(Theme.background) + } + + @ViewBuilder + private var statsView: some View { + let patches = model.swatchRows.flatMap(\.patches) + let valid = patches.compactMap(\.deltaE) + let avg = valid.isEmpty ? nil : valid.reduce(0, +) / Double(valid.count) + let max = valid.max() ?? 0 + + HStack(spacing: 12) { + if let avg = avg { + Text("avg ΔE \(String(format: "%.2f", avg))") + .font(.caption) + .foregroundStyle(.secondary) + } + Text("max ΔE \(String(format: "%.2f", max))") + .font(.caption) + .foregroundStyle(.secondary) + Text("\(patches.count) patches") + .font(.caption) + .foregroundStyle(.secondary) + } + .accessibilityIdentifier("readStats") + } + + // MARK: - Averaging + + @ViewBuilder + private var averagingSection: some View { + if !model.passSnapshots.isEmpty || model.isFinished { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Averaging") + .font(.headline) + .foregroundStyle(Theme.text) + Spacer() + Text("\(model.passSnapshots.count) pass(es)") + .font(.caption) + .foregroundStyle(Theme.text) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Theme.border) + .cornerRadius(4) + .accessibilityIdentifier("passCounterBadge") + } + + ForEach(model.passSnapshots, id: \.lastPathComponent) { url in + Text(url.lastPathComponent) + .font(.caption) + .foregroundStyle(.secondary) + } + .accessibilityIdentifier("passesList") + + HStack(spacing: 12) { + Button("Measure Another Sheet") { + model.measureAnotherSheet() + } + .disabled(!model.isFinished || model.isChartreadRunning) + .accessibilityIdentifier("btnMeasureAnotherSheet") + + Button("Finish & Average") { + model.finishAndAverage() + } + .disabled(!model.canFinish || model.isFinishing) + .accessibilityIdentifier("btnFinishAndAverage") + } + + if let notice = model.finishNotice { + Text(notice.text) + .font(.caption) + .foregroundStyle(notice.kind == .error ? .red : .green) + .accessibilityIdentifier("chartreadFinishNotice") + .accessibilityValue(notice.kind.accessibilityValue) + } + } + .padding(16) + .background(Theme.panel) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("chartreadAveragingPanel") + } + } +} diff --git a/Sources/ICCery/Stage4View.swift b/Sources/ICCery/Stage4View.swift new file mode 100644 index 0000000..676db3e --- /dev/null +++ b/Sources/ICCery/Stage4View.swift @@ -0,0 +1,194 @@ +import SwiftUI +import ICCeryCore + +/// Stage 4 — build an ICC/ICM profile from the canonical `.ti3`. +struct Stage4View: View { + @ObservedObject var model: ProfileWorkflowViewModel + /// Header reads `model.wizard.basename`; observe the nested + /// ObservableObject directly. + @ObservedObject private var wizard: WizardViewModel + + init(model: ProfileWorkflowViewModel) { + self.model = model + self._wizard = ObservedObject(wrappedValue: model.wizard) + } + + var body: some View { + VStack(spacing: 0) { + header + ScrollView { + VStack(alignment: .leading, spacing: 16) { + formSection + runSection + } + .padding(20) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Theme.background) + .onAppear { model.restoreCreatedProfileURL() } + } + + // MARK: - Header + + @ViewBuilder + private var header: some View { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 4) { + Text(model.wizard.basename) + .font(.title3) + .foregroundStyle(Theme.text) + .accessibilityIdentifier("stage4TargetBasename") + Text("Build the ICC profile from the measured .ti3.") + .font(.callout) + .foregroundStyle(.secondary) + .accessibilityIdentifier("stage4TargetMeta") + } + + Spacer() + + if let progress = model.colprofProgress, model.isColprofRunning { + Text(progress) + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Theme.border) + .cornerRadius(4) + .foregroundStyle(Theme.text) + .accessibilityIdentifier("colprofProgress") + } + } + .padding(16) + .background(Theme.panel) + } + + // MARK: - Form + + @ViewBuilder + private var formSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Profile settings") + .font(.headline) + .foregroundStyle(Theme.text) + + HStack(spacing: 16) { + Picker("Algorithm", selection: $model.algorithm) { + Text("Lab cLUT").tag("l") + Text("XYZ cLUT").tag("x") + Text("Display XYZ+matrix").tag("X") + Text("Matrix").tag("m") + } + .accessibilityIdentifier("colprofAlgorithm") + + Picker("Quality", selection: $model.quality) { + Text("Low").tag("l") + Text("Medium").tag("m") + Text("High").tag("h") + Text("Ultra").tag("u") + } + .accessibilityIdentifier("colprofQuality") + } + + Picker("FWA / OBA compensation", selection: $model.fwaSelection) { + ForEach(ColprofFwaSelection.allCases, id: \.self) { selection in + Text(selection.displayName).tag(selection) + } + } + .accessibilityIdentifier("colprofFwa") + + if model.fwaSelection == .custom { + HStack { + TextField("Custom .sp spectrum path", text: $model.fwaCustomPath) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("colprofFwaCustomPath") + Button("Browse…") { model.browseForSpectrumFile() } + .accessibilityIdentifier("btnBrowseFwaSp") + } + } + + HStack(spacing: 16) { + TextField("Illuminant", text: $model.illuminant) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("colprofIlluminant") + + TextField("Observer", text: $model.observer) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("colprofObserver") + } + + HStack(spacing: 16) { + TextField("Input viewing condition", text: $model.inputViewingCond) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("colprofInputViewCond") + + TextField("Output viewing condition", text: $model.outputViewingCond) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("colprofOutputViewCond") + } + + Text("Use 'none' to skip a viewing condition.") + .font(.caption) + .foregroundStyle(.secondary) + + TextField("Description", text: $model.profileDescription) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("colprofDescription") + + TextField("Copyright", text: $model.copyright) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("colprofCopyright") + + // Nested VStack keeps the parent at the Swift 5.7 ViewBuilder + // 10-child limit (Xcode 14.2 / macOS 12 CI runner, #111). + VStack(alignment: .leading, spacing: 12) { + Toggle("Apply calibration curve", isOn: $model.applyCalibration) + .accessibilityIdentifier("colprofApplyCalibration") + + if model.applyCalibration { + HStack { + TextField("Calibration .cal file", text: $model.calibrationFile) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("colprofCalibrationFile") + Button("Browse…") { model.browseForCalibrationFile() } + .accessibilityIdentifier("btnBrowseCalibrationFile") + } + } + } + } + .padding(16) + .background(Theme.panel) + } + + // MARK: - Run controls + + @ViewBuilder + private var runSection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 12) { + Button("Create Profile") { + model.createProfile() + } + .disabled(!model.canCreateProfile) + .accessibilityIdentifier("btnCreateProfile") + + if model.isColprofRunning { + ProgressView() + .scaleEffect(0.8) + .accessibilityIdentifier("colprofProgressIndicator") + } + + Spacer() + } + + if !model.colprofLog.isEmpty { + ProcessLogView( + lines: model.colprofLog, + containerId: "colprofLogContainer", + logId: "colprofLog" + ) + } + } + .padding(16) + .background(Theme.panel) + } +} diff --git a/Sources/ICCery/Stage5View.swift b/Sources/ICCery/Stage5View.swift new file mode 100644 index 0000000..c38eaf7 --- /dev/null +++ b/Sources/ICCery/Stage5View.swift @@ -0,0 +1,289 @@ +import SwiftUI +import ICCeryCore + +/// Stage 5 — verify the generated profile, track drift, and install. +struct Stage5View: View { + @ObservedObject var model: ProfileWorkflowViewModel + /// Header/buttons read `model.wizard.*`; observe the nested + /// ObservableObject directly. + @ObservedObject private var wizard: WizardViewModel + + init(model: ProfileWorkflowViewModel) { + self.model = model + self._wizard = ObservedObject(wrappedValue: model.wizard) + } + + var body: some View { + VStack(spacing: 0) { + header + ScrollView { + VStack(alignment: .leading, spacing: 16) { + verifySection + if let report = model.profcheckReport { + resultSection(report: report) + } + historySection + } + .padding(20) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Theme.background) + .onAppear { + model.restoreCreatedProfileURL() + model.loadHistory() + } + .alert("Install profile", isPresented: $model.showingInstallCollision) { + Button("Overwrite", role: .destructive) { + model.resolveInstallCollision(policy: .overwrite) + } + .accessibilityIdentifier("profileOverwriteBtn") + Button("Rename") { + model.resolveInstallCollision(policy: .rename) + } + .accessibilityIdentifier("profileRenameBtn") + Button("Cancel", role: .cancel) { + model.resolveInstallCollision(policy: .cancel) + } + .accessibilityIdentifier("profileCancelCollisionBtn") + } message: { + Text(model.installCollisionMessage) + .accessibilityIdentifier("profileInstallCollisionMessage") + } + } + + // MARK: - Header + + @ViewBuilder + private var header: some View { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 4) { + Text(model.wizard.basename) + .font(.title3) + .foregroundStyle(Theme.text) + .accessibilityIdentifier("stage5TargetBasename") + Text("Verify the profile and compare against historical results.") + .font(.callout) + .foregroundStyle(.secondary) + .accessibilityIdentifier("stage5TargetMeta") + } + + Spacer() + + if let alert = model.driftAlert { + Text(alert) + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.red.opacity(0.2)) + .cornerRadius(4) + .foregroundStyle(.red) + .accessibilityIdentifier("driftAlert") + } + + if let warning = model.profcheckWarning, !warning.isEmpty { + Text("⚠ \(warning)") + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.red.opacity(0.2)) + .cornerRadius(4) + .foregroundStyle(.red) + .accessibilityIdentifier("profcheckWarningBanner") + } + } + .padding(16) + .background(Theme.panel) + } + + // MARK: - Verify + + @ViewBuilder + private var verifySection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 12) { + Button("Verify Profile") { + model.verifyProfile() + } + .disabled(!model.canVerify) + .accessibilityIdentifier("btnVerifyProfile") + + if model.isProfcheckRunning { + ProgressView() + .scaleEffect(0.8) + .accessibilityIdentifier("profcheckProgressIndicator") + } + + Spacer() + + if let profileURL = model.createdProfileURL { + Text(profileURL.lastPathComponent) + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityIdentifier("stage5ProfilePath") + } + } + + if !model.colprofLog.isEmpty { + DisclosureGroup("Log") { + VStack(alignment: .leading) { + ForEach(model.colprofLog, id: \.self) { line in + Text(line) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + } + } + } + .foregroundStyle(Theme.text) + .accessibilityIdentifier("profcheckLogContainer") + } + } + .padding(16) + .background(Theme.panel) + } + + // MARK: - Result cards + + @ViewBuilder + private func resultSection(report: ProfcheckReport) -> some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Verification result") + .font(.headline) + .foregroundStyle(Theme.text) + + Spacer() + + Button("View Gamut") { + model.wizard.openGamut(profileGamURL: model.createdGamutURL) + } + .disabled(model.createdGamutURL == nil) + .accessibilityIdentifier("btnViewGamut") + + Button("Install Profile") { model.beginInstallProfile() } + .disabled(model.createdProfileURL == nil) + .accessibilityIdentifier("btnInstallProfile") + } + + HStack(spacing: 16) { + metricCard(title: "Avg ΔE", value: report.avgDE) + metricCard(title: "Max ΔE", value: report.maxDE) + metricCard(title: "RMS", value: report.rmsDE) + metricCard(title: "Patches", value: report.patchCount.map(Double.init)) + } + + if let status = report.status { + HStack { + Text("Status") + Spacer() + Text(status.displayName) + .fontWeight(.semibold) + .foregroundStyle(statusColor(status)) + .accessibilityIdentifier("profcheckStatus") + } + } + } + .padding(16) + .background(Theme.panel) + } + + @ViewBuilder + private func metricCard(title: String, value: Double?) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + Text(value.map { String(format: "%.2f", $0) } ?? "—") + .font(.title3) + .foregroundStyle(Theme.text) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + // MARK: - History + + @ViewBuilder + private var historySection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("History & drift") + .font(.headline) + .foregroundStyle(Theme.text) + + HStack { + Picker("Printer", selection: Binding( + get: { model.driftPrinterFilter ?? "" }, + set: { model.driftPrinterFilter = $0.isEmpty ? nil : $0 } + )) { + Text("All").tag("") + ForEach(model.knownPrinters, id: \.self) { printer in + Text(printer.isEmpty ? "Unknown" : printer).tag(printer) + } + } + .accessibilityIdentifier("driftPrinterFilter") + .frame(width: 200) + + Spacer() + + Button("Export CSV") { model.exportHistory() } + .accessibilityIdentifier("btnExportHistory") + + Button("Clear") { model.clearHistory() } + .accessibilityIdentifier("btnClearHistory") + } + + driftChart + + if !model.filteredHistory.isEmpty { + Table(of: VerificationRecord.self) { + TableColumn("Date") { record in + Text(record.timestamp.formatted(date: .numeric, time: .shortened)) + } + TableColumn("Profile") { record in + Text(record.profileName) + } + TableColumn("Avg") { record in + Text(String(format: "%.2f", record.avgDE)) + } + TableColumn("Max") { record in + Text(String(format: "%.2f", record.maxDE)) + } + TableColumn("RMS") { record in + Text(String(format: "%.2f", record.rmsDE)) + } + TableColumn("Status") { record in + Text(record.status.displayName) + .foregroundStyle(statusColor(record.status)) + } + } rows: { + ForEach(model.filteredHistory) { record in + TableRow(record) + } + } + .frame(minHeight: 120) + .accessibilityIdentifier("verificationHistoryTable") + } else { + Text("No verification records yet.") + .font(.callout) + .foregroundStyle(.secondary) + } + } + .padding(16) + .background(Theme.panel) + } + + @ViewBuilder + private var driftChart: some View { + let records = model.filteredHistory.sorted { $0.timestamp < $1.timestamp } + DriftChartView(records: records) + .frame(height: 160) + .accessibilityIdentifier("driftChart") + } + + private func statusColor(_ status: VerificationStatus) -> Color { + switch status { + case .excellent, .good: return .green + case .acceptable: return .yellow + case .poor: return .red + } + } +} diff --git a/Sources/ICCery/SwatchPatchView.swift b/Sources/ICCery/SwatchPatchView.swift new file mode 100644 index 0000000..650bb92 --- /dev/null +++ b/Sources/ICCery/SwatchPatchView.swift @@ -0,0 +1,75 @@ +import SwiftUI +import ICCeryCore + +private extension DisplayRGB { + var color: Color { + Color(red: r, green: g, blue: b) + } +} + +/// One swatch in the live grid, with a 135° intended/measured diagonal split. +struct SwatchPatchView: View { + let swatch: Swatch + + private var indicatorColor: Color { + switch swatch.classification { + case .good: return .green + case .warning: return .yellow + case .bad: return .red + } + } + + var body: some View { + ZStack { + // Background: measured + swatch.measured.color + .clipShape(DiagonalClip(side: .bottomRight)) + + // Foreground: intended + swatch.intended.color + .clipShape(DiagonalClip(side: .topLeft)) + + // Classification dot + Circle() + .fill(indicatorColor) + .frame(width: 6, height: 6) + .offset(x: 6, y: 6) + } + .frame(width: 32, height: 32) + .overlay( + Rectangle() + .stroke(Color.primary.opacity(0.2), lineWidth: 0.5) + ) + .accessibilityIdentifier("swatch-\(swatch.rowId)\(swatch.loc)") + .accessibilityLabel("\(swatch.loc) intended \(String(format: "%.0f", swatch.intended.r * 255)), measured \(String(format: "%.0f", swatch.measured.r * 255))") + } +} + +/// 135° diagonal clipping: top-left or bottom-right triangle. +/// +/// A 135° line from the top-right corner to the bottom-left corner gives +/// top-left and bottom-right triangles. +private enum DiagonalSide { + case topLeft + case bottomRight +} + +private struct DiagonalClip: Shape { + let side: DiagonalSide + + func path(in rect: CGRect) -> Path { + var path = Path() + switch side { + case .topLeft: + path.move(to: CGPoint(x: rect.minX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY)) + case .bottomRight: + path.move(to: CGPoint(x: rect.maxX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) + path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY)) + } + path.closeSubpath() + return path + } +} diff --git a/Sources/ICCery/TargetWorkflowViewModel.swift b/Sources/ICCery/TargetWorkflowViewModel.swift new file mode 100644 index 0000000..2e9af7f --- /dev/null +++ b/Sources/ICCery/TargetWorkflowViewModel.swift @@ -0,0 +1,545 @@ +import Combine +import Foundation +import ICCeryCore + +/// Stage 1/2 form state, runner orchestration, resume flow, and preset +/// application (issues #7–#11). +/// +/// `wizard` stays authoritative for persisted identity + disk gating; +/// this model owns the editable form, logs, gallery, and preset state. +/// All process work runs through `ArgyllRunner` off `@MainActor`; only +/// coalesced log batches and completion hop back. +@MainActor +final class TargetWorkflowViewModel: ObservableObject { + + let wizard: WizardViewModel + let environment: AppEnvironment + private let fileDialogs = FileDialogService.shared + + // MARK: - Stage 1 form (targen) + + @Published var colourSpace: ColourSpace = .rgb { + didSet { + guard colourSpace != oldValue else { return } + // CMYK black patches default to 0, RGB to 4 (docs/08). + blackPatches = colourSpace == .cmyk ? 0 : 4 + } + } + @Published var patchPreset: PatchCountPreset = .standard800 + /// `#patchCountCustom` — used when `patchPreset == .custom`. + @Published var customPatchCount = 2500 + @Published var whitePatches = 4 + @Published var blackPatches = 4 + + // Advanced — each optional flag is enabled + value, so an untouched + // control emits nothing (#advanced fields are opt-in). + @Published var greyStepsEnabled = false + @Published var greySteps = 5 + @Published var singleChannelEnabled = false + @Published var singleChannelSteps = 5 + @Published var neutralStepsEnabled = false + @Published var neutralSteps = 3 + @Published var neutralConcEnabled = false + @Published var neutralConcentration = 0.50 + @Published var preconditioningProfile: String? + @Published var highQuality = false + @Published var adaptationEnabled = false + @Published var adaptation = 0.10 + @Published var algorithm: FullSpreadAlgorithm = .ofps + @Published var inkLimitEnabled = false + @Published var totalInkLimit = 320 + @Published var darkEmphasisEnabled = false + @Published var darkEmphasis = 1.0 + @Published var devicePowerEnabled = false + @Published var devicePower = 1.0 + + /// `#targetBasename` — no placeholder is ever invented (#60). + @Published var targetBasename = "" + /// `#selectedPathDisplay` / resolved cwd. + @Published var targetDirectory: URL? + + // MARK: - Stage 2 form (printtarg) + + @Published var instrument: PrintInstrument = .i1 + @Published var pageSize: PageSize = .a4 + @Published var customPageW = 210.0 + @Published var customPageH = 297.0 + @Published var bitDepth: TiffBitDepth = .eight + /// `#tiffDpi` — two-way bound; presets can change it (150-DPI draft + /// regression must be visible here). + @Published var tiffDpi = 300 + @Published var layoutOrder: LayoutOrder = .deterministic + @Published var customSeed = 1 + @Published var labelIsCustom = false + @Published var customLabel = "" + @Published var metaPrinter = "" + @Published var metaInkSet = "" + @Published var metaDriverPaper = "" + @Published var metaActualPaper = "" + + // MARK: - Run state + + @Published var targenRunning = false + @Published var targenLog: [String] = [] + @Published var printtargRunning = false + @Published var printtargLog: [String] = [] + @Published var printtargResult: PrinttargResult? + /// Sticky until the target changes: `.ti2` resume landed us on + /// Stage 3 (`#stage3LoadedTargetBanner` data). + @Published var resumedFromTi2 = false + + // MARK: - Presets + + @Published var presets: [ProfilingPreset] = [] + @Published var selectedPresetID = "none" + @Published var showingSavePreset = false + @Published var showingManagePresets = false + @Published var savePresetName = "" + @Published var savePresetDesc = "" + + // MARK: - Media library (issue #146) + + @Published var showingSaveMedia = false + @Published var showingManageMedia = false + + // MARK: - Spot read (issue #148) + + /// `RootView` sheet binding for the spot-read console. + @Published var showingSpotRead = false + + /// Stage 3 measurement workflow, owned at the app level so it persists + /// across stage switches and can observe settings changes. + @Published var measurement: MeasurementWorkflowViewModel + /// Stage 4/5 profile workflow, owned at the app level so it persists + /// across stage switches and can observe preset values. + @Published var profile: ProfileWorkflowViewModel + /// Stage 0 calibration workflow. + @Published var calibration: CalibrationViewModel! + /// Stage 2 unmanaged print session. + @Published var print: PrintSessionViewModel! + /// Media recipe library — needs a complete `self`. + @Published var media: MediaLibraryViewModel! + /// Spot-read console — needs `wizard` / `measurement`. + @Published var spotRead: SpotReadViewModel! + /// Project file session (issue #149), created last — needs `media` + /// for recipe apply and `spotRead` for the live-child check. + @Published var project: ProjectSession! + + init(environment: AppEnvironment = .live()) { + self.environment = environment + self.wizard = WizardViewModel(stateStore: environment.stateStore) + self.measurement = MeasurementWorkflowViewModel( + wizard: wizard, + environment: environment + ) + self.profile = ProfileWorkflowViewModel( + wizard: wizard, + environment: environment + ) + self.print = PrintSessionViewModel(wizard: wizard, environment: environment) + self.calibration = nil + self.calibration = CalibrationViewModel( + workflow: self, + profile: self.profile, + environment: environment + ) + self.media = MediaLibraryViewModel( + workflow: self, + environment: environment + ) + self.spotRead = SpotReadViewModel( + workflow: self, + environment: environment + ) + self.project = ProjectSession( + workflow: self, + environment: environment + ) + reloadPresets() + } + + // MARK: - Derived + + var effectivePatchCount: Int { + patchPreset.patchCount ?? customPatchCount + } + + var canGenerate: Bool { + PathSecurity.isValidBasename(targetBasename) && targetDirectory != nil + } + + var labelMetadata: TargetLabelMetadata { + TargetLabelMetadata( + printer: metaPrinter, inkSet: metaInkSet, + driverPaper: metaDriverPaper, actualPaper: metaActualPaper) + } + + /// `#targetLabelPreview` — live preview of the automatic label. + var automaticLabel: String { + PrinttargLabel.automatic( + basename: wizard.basename.isEmpty ? "target" : wizard.basename, + metadata: labelMetadata) + } + + var selectedPreset: ProfilingPreset? { + presets.first { $0.id == selectedPresetID } + } + + // MARK: - Stage 1: generate + + func buildTargenConfig() -> TargenConfig { + TargenConfig( + colourSpace: colourSpace, + patchCount: effectivePatchCount, + whitePatches: whitePatches, + blackPatches: blackPatches, + greySteps: greyStepsEnabled ? greySteps : nil, + singleChannelSteps: singleChannelEnabled ? singleChannelSteps : nil, + neutralSteps: neutralStepsEnabled ? neutralSteps : nil, + neutralConcentration: neutralConcEnabled ? neutralConcentration : nil, + preconditioningProfile: preconditioningProfile, + ofpsHighQuality: highQuality ? true : nil, + ofpsAdaptation: adaptationEnabled ? adaptation : nil, + fullSpreadAlgorithm: algorithm == .ofps ? nil : algorithm, + totalInkLimit: inkLimitEnabled ? totalInkLimit : nil, + darkEmphasis: darkEmphasisEnabled ? darkEmphasis : nil, + devicePower: devicePowerEnabled ? devicePower : nil, + basename: targetBasename, + workingDirectory: targetDirectory + ) + } + + func browseForTargetFile() { + let url = UITestHooks.isEnabled + ? UITestHooks.saveTargetURL + : fileDialogs.selectTargetFile() + guard let url else { return } + targetBasename = url.deletingPathExtension().lastPathComponent + targetDirectory = url.deletingLastPathComponent() + } + + func browseForWorkingDirectory() { + let url = UITestHooks.isEnabled + ? UITestHooks.workDirURL + : fileDialogs.selectDirectory() + if let url { targetDirectory = url } + } + + func browseForPreconditioningProfile() { + if let url = fileDialogs.selectProfileFile() { + preconditioningProfile = url.path + } + } + + func generateTarget() { + guard canGenerate, !targenRunning else { return } + let config = buildTargenConfig() + resumedFromTi2 = false + let runner = environment.runner + Task { @MainActor in + do { + let url = try await ProcessRunSupport.runLogged( + setRunning: { self.targenRunning = $0 }, + resetLog: { self.targenLog = [] }, + onLog: { self.targenLog.append(contentsOf: $0) } + ) { onLog in + try await runner.runTargen(config: config, onLogBatch: onLog) + } + wizard.setTarget( + basename: config.basename, + workingDirectory: config.workingDirectory) + wizard.refreshGating() + wizard.showNotice("Target generated: \(url.lastPathComponent)") + wizard.go(to: .layOutPrint) + } catch { + wizard.showNotice( + "targen failed: \(error.localizedDescription)", kind: .error) + } + } + } + + // MARK: - Issue 8: resume an existing target + + /// `#btn-import-dataset` — open a measured dataset, write a canonical + /// `.ti3` to the working directory, and set the target (issue #30). + func importMeasurementDataset() { + let url = UITestHooks.isEnabled + ? UITestHooks.datasetImportURL + : fileDialogs.selectDatasetFile() + guard let url else { return } + importMeasurementDataset(from: url) + } + + /// Test seam (issue #80): unit tests pass missing or malformed URLs + /// directly instead of mutating the global environment. + func importMeasurementDataset(from url: URL) { + do { + let dataset = try CGATSParser.parse(url: url) + guard let directory = targetDirectory ?? wizard.effectiveWorkingDirectory else { + wizard.showNotice("Choose a working directory before importing.", kind: .warning) + return + } + + let stem = url.deletingPathExtension().lastPathComponent + let output = directory.appendingPathComponent("\(stem).ti3") + try CGATSWriter.write(dataset, to: output) + + wizard.setTarget(basename: stem, workingDirectory: directory) + wizard.refreshGating() + wizard.showNotice("Imported \(dataset.samples.count) patches from \(url.lastPathComponent)") + + if wizard.isUnlocked(.verifyInstall) { + wizard.go(to: .verifyInstall) + } else if wizard.isUnlocked(.buildProfile) { + wizard.go(to: .buildProfile) + } else { + wizard.showNotice("Imported dataset is not ready for profiling.", kind: .warning) + } + } catch { + wizard.showNotice("Import failed: \(error.localizedDescription)", kind: .error) + } + } + + /// `#btnOpenExisting` — open `.ti1`/`.ti2` (open dialog, #103). + /// `.ti1` → Stage 2; `.ti2` → Stage 3 with the resume notice, but + /// only when the sibling `.ti1` exists so the artefact gate holds. + func openExistingTarget() { + let url = UITestHooks.isEnabled + ? UITestHooks.existingTargetURL + : fileDialogs.selectExistingTarget() + guard let url else { return } + + let stem = url.deletingPathExtension().lastPathComponent + let dir = url.deletingLastPathComponent() + guard PathSecurity.isValidBasename(stem) else { + wizard.showNotice("Invalid target name.", kind: .error) + return + } + + switch url.pathExtension.lowercased() { + case "ti1": + wizard.setTarget(basename: stem, workingDirectory: dir) + wizard.refreshGating() + resumedFromTi2 = false + measurement.resumedFromTi2 = false + wizard.go(to: .layOutPrint) + case "ti2": + let header = Ti2Header.parse(url) + guard header.hasSiblingTi1 else { + wizard.showNotice( + "Cannot resume \(stem).ti2 — the sibling \(stem).ti1 is missing.", + kind: .error) + return + } + wizard.setTarget(basename: stem, workingDirectory: dir) + wizard.refreshGating() + resumedFromTi2 = true + measurement.resumedFromTi2 = true + wizard.showNotice("Resumed from .ti2", kind: .info, autoHideAfter: nil) + wizard.go(to: .measure) + default: + wizard.showNotice( + "Not a target file — choose a .ti1 or .ti2.", kind: .error) + } + } + + // MARK: - Stage 2: create layout + + func buildPrinttargConfig() -> PrinttargConfig { + PrinttargConfig( + instrument: instrument, + pageSize: pageSize, + customPageWidth: customPageW, + customPageHeight: customPageH, + bitDepth: bitDepth, + dpi: tiffDpi, + layoutOrder: layoutOrder, + customSeed: customSeed, + label: PrinttargLabel.resolved( + customLabel: labelIsCustom ? customLabel : nil, + basename: wizard.basename, + metadata: labelMetadata), + calibrationFile: profile.applyCalibration ? profile.calibrationFile : nil, + calibrationEmbedOnly: false, + basename: wizard.basename, + workingDirectory: wizard.effectiveWorkingDirectory + ) + } + + func createLayout() { + guard wizard.isUnlocked(.layOutPrint), !printtargRunning else { return } + let config = buildPrinttargConfig() + printtargResult = nil + let runner = environment.runner + Task { @MainActor in + do { + let result = try await ProcessRunSupport.runLogged( + setRunning: { self.printtargRunning = $0 }, + resetLog: { self.printtargLog = [] }, + onLog: { self.printtargLog.append(contentsOf: $0) } + ) { onLog in + try await runner.runPrinttarg(config: config, onLogBatch: onLog) + } + printtargResult = result + wizard.refreshGating() + wizard.showNotice( + "Layout created — \(result.manifest.pages.count) page(s) ready.") + } catch { + wizard.showNotice( + "printtarg failed: \(error.localizedDescription)", kind: .error) + } + } + } + + /// `#btnAdvanceToStage3` — manual advance once `.ti2` exists. + func advanceToStage3() { + wizard.refreshGating() + wizard.go(to: .measure) + } + + // MARK: - Presets + + func reloadPresets() { + presets = environment.presetStore.all() + } + + /// Applies every Stage 1/2 field of the preset to the live form + /// (bidirectional — the draft preset's dpi=150 must be visible). + func applyPreset(_ preset: ProfilingPreset) { + let targen = TargenConfig(preset: preset, basename: targetBasename, workingDirectory: targetDirectory) + applyTargenForm(targen) + // Stage 4 state (incl. calibration) is applied before Stage 2 so + // the layout config receives the preset's calibration path, not + // stale live state (#82). + profile.applyPreset(preset) + let printtarg = PrinttargConfig( + preset: preset, + basename: wizard.basename, + workingDirectory: wizard.effectiveWorkingDirectory, + calibrationFile: profile.applyCalibration && !profile.calibrationFile.isEmpty + ? profile.calibrationFile : nil + ) + applyPrinttargForm(printtarg) + selectedPresetID = preset.id + } + + private func applyTargenForm(_ config: TargenConfig) { + colourSpace = config.colourSpace + patchPreset = PatchCountPreset(rawValue: "\(config.patchCount)") ?? .custom + customPatchCount = config.patchCount + whitePatches = config.whitePatches + blackPatches = config.blackPatches + greySteps = config.greySteps ?? 5 + greyStepsEnabled = config.greySteps != nil + singleChannelSteps = config.singleChannelSteps ?? 5 + singleChannelEnabled = config.singleChannelSteps != nil + neutralSteps = config.neutralSteps ?? 3 + neutralStepsEnabled = config.neutralSteps != nil + neutralConcentration = config.neutralConcentration ?? 0.50 + neutralConcEnabled = config.neutralConcentration != nil + preconditioningProfile = config.preconditioningProfile + highQuality = config.ofpsHighQuality == true + adaptation = config.ofpsAdaptation ?? 0.10 + adaptationEnabled = config.ofpsAdaptation != nil + algorithm = config.fullSpreadAlgorithm ?? .ofps + totalInkLimit = config.totalInkLimit ?? 320 + inkLimitEnabled = config.totalInkLimit != nil + darkEmphasis = config.darkEmphasis ?? 1.0 + darkEmphasisEnabled = config.darkEmphasis != nil + devicePower = config.devicePower ?? 1.0 + devicePowerEnabled = config.devicePower != nil + } + + private func applyPrinttargForm(_ config: PrinttargConfig) { + instrument = config.instrument + pageSize = config.pageSize + customPageW = config.customPageWidth + customPageH = config.customPageHeight + bitDepth = config.bitDepth + tiffDpi = config.dpi + layoutOrder = config.layoutOrder + customSeed = config.customSeed + } + + /// Snapshot of the live Stage 1/2 form as a custom preset. + func saveCurrentAsPreset() { + let name = savePresetName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { + wizard.showNotice("Preset needs a name.", kind: .warning) + return + } + let preset = ProfilingPreset( + id: "custom-\(UUID().uuidString.lowercased())", + name: name, + description: savePresetDesc.trimmingCharacters(in: .whitespacesAndNewlines), + targen: buildTargenConfig(), + printtarg: buildPrinttargConfig(), + colprof: profile.buildColprofConfig(), + calibrationFile: profile.calibrationFile.isEmpty ? nil : profile.calibrationFile, + applyCalibration: profile.applyCalibration ? true : nil + ) + do { + try environment.presetStore.saveCustom(preset) + reloadPresets() + selectedPresetID = preset.id + showingSavePreset = false + savePresetName = "" + savePresetDesc = "" + wizard.showNotice("Preset saved: \(preset.name)") + } catch { + wizard.showNotice( + "Could not save preset: \(error.localizedDescription)", kind: .error) + } + } + + func deletePreset(_ preset: ProfilingPreset) { + do { + if try environment.presetStore.deleteCustom(id: preset.id) { + if selectedPresetID == preset.id { selectedPresetID = "none" } + reloadPresets() + } else { + wizard.showNotice("Built-in presets cannot be deleted.", kind: .warning) + } + } catch { + wizard.showNotice( + "Could not delete preset: \(error.localizedDescription)", kind: .error) + } + } + + func importPreset() { + let url = UITestHooks.isEnabled + ? UITestHooks.presetImportURL + : fileDialogs.selectPresetFile() + guard let url else { return } + do { + let data = try Data(contentsOf: url) + let preset = try environment.presetStore.import(data) + try environment.presetStore.saveCustom(preset) + reloadPresets() + selectedPresetID = preset.id + wizard.showNotice("Preset imported: \(preset.name)") + } catch { + wizard.showNotice( + "Import failed: \(error.localizedDescription)", kind: .error) + } + } + + func exportPreset(_ preset: ProfilingPreset) { + let url = UITestHooks.isEnabled + ? UITestHooks.presetExportURL + : fileDialogs.selectPresetSavePath(name: preset.id) + guard let url else { return } + do { + try environment.presetStore.export(preset) + .write(to: url, options: .atomic) + wizard.showNotice("Preset exported: \(url.lastPathComponent)") + } catch { + wizard.showNotice( + "Export failed: \(error.localizedDescription)", kind: .error) + } + } + + static func parseCustomPage(_ raw: String) -> (Double, Double)? { + PageSize.parseCustom(raw) + } +} diff --git a/Sources/ICCery/Theme.swift b/Sources/ICCery/Theme.swift new file mode 100644 index 0000000..c65ca0c --- /dev/null +++ b/Sources/ICCery/Theme.swift @@ -0,0 +1,21 @@ +import SwiftUI + +/// Design tokens carried over from the v1 stylesheet (docs/21 §Design tokens). +enum Theme { + static let background = Color(red: 0x1e / 255, green: 0x1e / 255, blue: 0x1e / 255) + static let panel = Color(red: 0x25 / 255, green: 0x25 / 255, blue: 0x26 / 255) + static let text = Color(red: 0xd4 / 255, green: 0xd4 / 255, blue: 0xd4 / 255) + static let accent = Color(red: 0x00 / 255, green: 0x7a / 255, blue: 0xcc / 255) + static let border = Color(red: 0x33 / 255, green: 0x33 / 255, blue: 0x33 / 255) + /// v1 window/titlebar backing colour (docs/02 §Window contract). + static let windowChrome = Color(red: 0x1a / 255, green: 0x1a / 255, blue: 0x22 / 255) + + enum Metrics { + static let sidebarWidth: CGFloat = 270 + static let buttonSmall: CGFloat = 28 + static let buttonMedium: CGFloat = 36 + static let buttonLarge: CGFloat = 40 + static let cornerSmall: CGFloat = 4 + static let cornerMedium: CGFloat = 6 + } +} diff --git a/Sources/ICCery/WizardViewModel.swift b/Sources/ICCery/WizardViewModel.swift new file mode 100644 index 0000000..bbd0aa1 --- /dev/null +++ b/Sources/ICCery/WizardViewModel.swift @@ -0,0 +1,231 @@ +import Combine +import Foundation +import ICCeryCore + +/// Wizard state machine + artefact gating (issue #4, docs/06). +/// +/// `wizardState` fields (`currentStage`, `basename`, `cwd`, +/// `printerName`, `sessionMode`, `profileBasename`, +/// `calibrationOriginalBasename`) are persisted to +/// `wizard_state.json`; unlocks come from `ArtefactProbe.verify` — +/// navigation is disk, not buttons. +@MainActor +final class WizardViewModel: ObservableObject { + + // MARK: - wizardState fields (persisted) + + @Published var stage: WizardStage { + didSet { if stage != oldValue { persist() } } + } + /// `wizardState.basename` — empty until a real artefact names it (#60). + @Published var basename: String { + didSet { if basename != oldValue { refreshGating(); persist() } } + } + /// `wizardState.cwd` — resolved via `resolveSafeCwd` (#59). + @Published var workingDirectory: URL? { + didSet { if workingDirectory != oldValue { refreshGating(); persist() } } + } + @Published var printerName: String? { + didSet { if printerName != oldValue { persist() } } + } + @Published var sessionMode: SessionMode { + didSet { if sessionMode != oldValue { persist() } } + } + /// `profileBasename` may differ after a `.ti3` import (#94). + @Published var profileBasename: String? { + didSet { if profileBasename != oldValue { persist() } } + } + /// Pre-`CAL_` basename, persisted so relaunch/Force Quit can restore it (#29). + @Published var calibrationOriginalBasename: String { + didSet { if calibrationOriginalBasename != oldValue { persist() } } + } + + // MARK: - Ephemeral + + /// Banner notice currently displayed (`#wizardNotification`). + @Published var notice: Notice? + /// Current artefact probe result; recomputed on `refreshGating()`. + @Published private(set) var artefacts = StageArtefacts() + /// Whether the 3D gamut viewer sheet is open (issue #28). + @Published var showingGamutViewer = false + /// Optional `.gam` URL to show alongside the sRGB reference. + @Published var gamutProfileURL: URL? + + private let stateStore: WizardStateStore + private var noticeDismissTask: Task? + + init(stateStore: WizardStateStore = WizardStateStore()) { + self.stateStore = stateStore + let s = stateStore.load() + self.stage = s.stage + self.basename = s.basename + self.workingDirectory = s.cwd.isEmpty ? nil : URL(fileURLWithPath: s.cwd) + self.printerName = s.printerName + self.sessionMode = s.sessionMode + self.profileBasename = s.profileBasename + self.calibrationOriginalBasename = s.calibrationOriginalBasename + // A Force Quit mid-calibration leaves a CAL_ basename behind; restore + // the original before the UI can do anything with it (#29). + if CalibrationIdentity.isCalibration(basename), !calibrationOriginalBasename.isEmpty { + let identity = CalibrationIdentity.parse( + liveBasename: basename, + persistedOriginal: calibrationOriginalBasename + ) + basename = identity.originalBasename + calibrationOriginalBasename = "" + sessionMode = .profile + stage = .generate + } + refreshGating() + // A restored stage may have been locked since (#151). + if !WizardGating.isUnlocked(stage, artefacts: artefacts), stage != .calibrate { + stage = WizardGating.deepestUnlocked(artefacts: artefacts) + } + } + + // MARK: - Gating + + /// `isUnlocked` for the sidebar stepper. + func isUnlocked(_ stage: WizardStage) -> Bool { + WizardGating.isUnlocked(stage, artefacts: artefacts) + } + + /// `true` while Stage 0 (printer calibration) is shown. + var isCalibrating: Bool { stage == .calibrate } + + /// Re-probes the artefact directory and re-locks (#151). Called on + /// window focus, stage entry, and basename/cwd changes. + func refreshGating() { + guard !basename.isEmpty, let dir = effectiveWorkingDirectory else { + artefacts = StageArtefacts() + return + } + artefacts = ArtefactProbe.verify(basename: basename, cwd: dir) + } + + /// `setTarget(basename, cwd)` — validates the basename (no `/`, `\`, + /// `..`; no placeholders — #60) and resolves the cwd (#59). + func setTarget(basename: String, workingDirectory: URL?) { + do { + self.basename = try PathSecurity.sanitizeBasename(basename) + } catch { + showNotice("Invalid target name.", kind: .error) + return + } + self.workingDirectory = PathSecurity.resolveSafeCwd(workingDirectory) + } + + /// cwd never stays empty once a basename exists (#59). + var effectiveWorkingDirectory: URL? { + if let workingDirectory { return workingDirectory } + return basename.isEmpty ? nil : PathSecurity.resolveSafeCwd(nil) + } + + // MARK: - Navigation + + /// `navigateToStage(n)` — refuses locked forward moves with a + /// warning banner; backward is always allowed (docs/06). + /// + /// If the live basename has a `CAL_` prefix, only `.calibrate`, + /// `.layOutPrint`, and `.measure` are allowed; any other target is + /// refused and the original basename is restored (#29). + func go(to target: WizardStage) { + guard target != .calibrate else { enterCalibration(); return } + if CalibrationIdentity.isCalibration(basename) { + guard !calibrationOriginalBasename.isEmpty else { + showNotice( + "Cannot leave calibration — the original target name is missing.", + kind: .warning + ) + return + } + if target == .generate || target == .buildProfile || target == .verifyInstall { + restoreCalibration() + return + } + } + if WizardGating.canNavigate(to: target, from: stage, artefacts: artefacts) { + stage = target + } else { + showNotice( + "Stage \(target.stepperIndex ?? 0) is locked — the required artefact is missing.", + kind: .warning + ) + } + } + + func enterCalibration() { + if !basename.isEmpty, !CalibrationIdentity.isCalibration(basename), calibrationOriginalBasename.isEmpty { + calibrationOriginalBasename = basename + } + sessionMode = .calibration + stage = .calibrate + } + + func exitCalibration() { + sessionMode = .profile + stage = .generate + } + + /// Restore the original profile basename and leave calibration mode. + func restoreCalibration() { + if !calibrationOriginalBasename.isEmpty { + basename = calibrationOriginalBasename + calibrationOriginalBasename = "" + } + sessionMode = .profile + } + + /// Open the 3D gamut viewer (issue #28). + func openGamut(profileGamURL: URL? = nil) { + self.gamutProfileURL = profileGamURL + showingGamutViewer = true + } + + /// Window-focus hook (#151): files deleted in Finder re-lock stages. + /// If the current stage re-locked, fall back to the deepest unlocked. + func windowDidBecomeKey() { + refreshGating() + if stage != .calibrate, + !WizardGating.isUnlocked(stage, artefacts: artefacts) { + stage = WizardGating.deepestUnlocked(artefacts: artefacts) + } + } + + // MARK: - Notice + + func showNotice(_ text: String, kind: Notice.Kind = .info, autoHideAfter: TimeInterval? = 6) { + noticeDismissTask?.cancel() + let notice = Notice(kind: kind, text: text, autoHideAfter: autoHideAfter) + self.notice = notice + if let delay = notice.autoHideAfter { + noticeDismissTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + guard !Task.isCancelled else { return } + if self?.notice?.id == notice.id { + self?.notice = nil + } + } + } + } + + func dismissNotice() { + noticeDismissTask?.cancel() + notice = nil + } + + // MARK: - Persistence + + private func persist() { + let state = WizardState( + currentStage: stage.rawValue, + basename: basename, + cwd: workingDirectory?.path ?? "", + printerName: printerName, + sessionMode: sessionMode, + profileBasename: profileBasename, + calibrationOriginalBasename: calibrationOriginalBasename + ) + try? stateStore.save(state) + } +} diff --git a/Tests/ICCeryCoreTests/AppPathsTests.swift b/Tests/ICCeryCoreTests/AppPathsTests.swift new file mode 100644 index 0000000..130e0d8 --- /dev/null +++ b/Tests/ICCeryCoreTests/AppPathsTests.swift @@ -0,0 +1,25 @@ +import XCTest +import Foundation +@testable import ICCeryCore + +final class AppPathsTests: XCTestCase { + func testAppDataDirUsesBundleID() { + XCTAssertTrue(AppPaths.appDataDir.path.contains("Library/Application Support/com.gronod.iccery2")) + } + + func testLogFileIsUnderLibraryLogs() { + XCTAssertEqual(AppPaths.logFile.lastPathComponent, "iccery.log") + XCTAssertTrue(AppPaths.logFile.path.contains("Library/Logs/com.gronod.iccery2")) + } + + func testBundledArgyllDirIsInsideResources() { + XCTAssertEqual(AppPaths.bundledArgyllDir.lastPathComponent, "Argyll") + } +} + +final class WizardStageTests: XCTestCase { + func testStepperOrderIsOneThroughFive() { + XCTAssertEqual(WizardStage.stepperStages.map(\.stepperIndex), [1, 2, 3, 4, 5]) + XCTAssertNil(WizardStage.calibrate.stepperIndex) + } +} diff --git a/Tests/ICCeryCoreTests/ApplycalArgsTests.swift b/Tests/ICCeryCoreTests/ApplycalArgsTests.swift new file mode 100644 index 0000000..b4cb09f --- /dev/null +++ b/Tests/ICCeryCoreTests/ApplycalArgsTests.swift @@ -0,0 +1,27 @@ +import Foundation +import XCTest +@testable import ICCeryCore + +final class ApplycalArgsTests: XCTestCase { + + func testApplyArgv() throws { + let config = ApplycalConfig( + calibrationPath: "/tmp/cal.cal", + inputProfileURL: URL(fileURLWithPath: "/tmp/profile.icc") + ) + let args = try ApplycalArgs.build(config: config) + XCTAssertEqual(args, ["-v", "-a", "/tmp/cal.cal", "/tmp/profile.icc"]) + } + + func testUnapplyEmittedWhenConfigSet() throws { + let config = ApplycalConfig( + calibrationPath: "/tmp/cal.cal", + inputProfileURL: URL(fileURLWithPath: "/tmp/profile.icc"), + unapply: true + ) + let args = try ApplycalArgs.build(config: config) + // Builder emits -u only when the caller explicitly sets unapply. + // The UI layer never passes unapply: true in v2.0. + XCTAssertEqual(args, ["-v", "-u", "/tmp/cal.cal", "/tmp/profile.icc"]) + } +} diff --git a/Tests/ICCeryCoreTests/ApproximateLabTests.swift b/Tests/ICCeryCoreTests/ApproximateLabTests.swift new file mode 100644 index 0000000..e0e65c3 --- /dev/null +++ b/Tests/ICCeryCoreTests/ApproximateLabTests.swift @@ -0,0 +1,39 @@ +import Foundation +import XCTest +@testable import ICCeryCore + +/// ``ApproximateLab`` sanity tests (issue #147). +/// +/// These are loose sanity checks on a fixed-matrix approximation — not +/// ColorSync goldens. +final class ApproximateLabTests: XCTestCase { + + func testWhiteMapsToHighLStar() { + let lab = ApproximateLab.srgb8ToLab(r: 255, g: 255, b: 255) + XCTAssertGreaterThan(lab.l, 95) + XCTAssertEqual(lab.a, 0, accuracy: 2) + XCTAssertEqual(lab.b, 0, accuracy: 2) + } + + func testBlackMapsToZeroLStar() { + let lab = ApproximateLab.srgb8ToLab(r: 0, g: 0, b: 0) + XCTAssertEqual(lab.l, 0, accuracy: 1) + } + + func testPureRedIsChromatic() { + let lab = ApproximateLab.srgb8ToLab(r: 255, g: 0, b: 0) + // sRGB red ≈ Lab D50 (54, 81, 70) — loose bounds only. + XCTAssertGreaterThan(lab.l, 40) + XCTAssertLessThan(lab.l, 65) + XCTAssertGreaterThan(lab.a, 60) + XCTAssertGreaterThan(lab.b, 40) + } + + func testMidGreyIsNeutral() { + let lab = ApproximateLab.srgb8ToLab(r: 128, g: 128, b: 128) + XCTAssertGreaterThan(lab.l, 45) + XCTAssertLessThan(lab.l, 65) + XCTAssertEqual(lab.a, 0, accuracy: 1) + XCTAssertEqual(lab.b, 0, accuracy: 1) + } +} diff --git a/Tests/ICCeryCoreTests/ArgsBuilderTests.swift b/Tests/ICCeryCoreTests/ArgsBuilderTests.swift new file mode 100644 index 0000000..05596c3 --- /dev/null +++ b/Tests/ICCeryCoreTests/ArgsBuilderTests.swift @@ -0,0 +1,80 @@ +import XCTest +import Foundation +@testable import ICCeryCore + +final class ArgsBuilderTests: XCTestCase { + + // MARK: - option + + func testOptionNil() { + XCTAssertEqual(ArgsBuilder.option("-f", nil), []) + } + + func testOptionPresent() { + XCTAssertEqual(ArgsBuilder.option("-f", "abc"), ["-f", "abc"]) + XCTAssertEqual(ArgsBuilder.option("-f", ""), ["-f", ""]) + XCTAssertEqual(ArgsBuilder.option("-f", " padded "), ["-f", " padded "]) + } + + // MARK: - optionIfNonEmpty + + func testOptionIfNonEmptyNilEmpty() { + XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", nil), []) + XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", ""), []) + } + + func testOptionIfNonEmptyWhitespace() { + XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", " "), []) + XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", " \t\n "), []) + } + + func testOptionIfNonEmptyTrims() { + XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", " label "), ["-d", "label"]) + XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", "\tcal.cal\n"), ["-d", "cal.cal"]) + } + + // MARK: - optionUnlessApprox + + func testOptionUnlessApproxNil() { + XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", nil, skip: 0.50), []) + } + + func testOptionUnlessApproxExactSkip() { + XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", 0.50, skip: 0.50), []) + XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-V", 1.0, skip: 1.0), []) + } + + func testOptionUnlessApproxWithinEpsilon() { + XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", 0.5005, skip: 0.50), []) + XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-V", 0.9995, skip: 1.0), []) + } + + func testOptionUnlessApproxOutsideEpsilon() { + XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", 0.75, skip: 0.50), ["-N", "0.75"]) + XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-V", 1.50, skip: 1.0), ["-V", "1.50"]) + XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", 0.498, skip: 0.50), ["-N", "0.50"]) + } + + func testOptionUnlessApproxPOSIX() { + // 1234.5 must never produce a grouping separator or comma decimal. + XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-p", 1234.5, skip: 1.0), ["-p", "1234.50"]) + XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-p", 2.0, skip: 1.0), ["-p", "2.00"]) + } + + func testOptionUnlessApproxCustom() { + XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-x", 1.005, skip: 1.0, epsilon: 0.01), []) + XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-x", 1.5, skip: 1.0, format: "%.1f"), ["-x", "1.5"]) + } + + // MARK: - flag + + func testFlagTrue() { + XCTAssertEqual(ArgsBuilder.flag("-G", when: true), ["-G"]) + XCTAssertEqual(ArgsBuilder.flag("-r", when: true), ["-r"]) + } + + func testFlagFalse() { + XCTAssertEqual(ArgsBuilder.flag("-G", when: false), []) + XCTAssertEqual(ArgsBuilder.flag("-r", when: false), []) + } +} diff --git a/Tests/ICCeryCoreTests/ArgyllRunnerCalibrationTests.swift b/Tests/ICCeryCoreTests/ArgyllRunnerCalibrationTests.swift new file mode 100644 index 0000000..cf89008 --- /dev/null +++ b/Tests/ICCeryCoreTests/ArgyllRunnerCalibrationTests.swift @@ -0,0 +1,123 @@ +import Foundation +import XCTest +@testable import ICCeryCore + +final class ArgyllRunnerCalibrationTests: XCTestCase { + + private func makeRunner(processManager: ProcessManager = ProcessManager()) -> ArgyllRunner { + let binDir = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("ICCeryUITests/Fixtures/bin") + return ArgyllRunner( + processManager: processManager, + binaryResolver: BinaryResolver(overrideDir: binDir) + ) + } + + private func makeTestDir() throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("calibration-test-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root + } + + func testCalibrationTargenProducesTi1() async throws { + let testRoot = try makeTestDir() + let runner = makeRunner() + let config = CalibrationTargenConfig( + colourSpace: .rgb, + steps: 21, + basename: "demo", + workingDirectory: testRoot + ) + + let url = try await runner.runCalibrationTargen(config: config) + + XCTAssertEqual(url.lastPathComponent, "CAL_demo.ti1") + XCTAssertTrue(FileManager.default.fileExists(atPath: url.path)) + try? FileManager.default.removeItem(at: testRoot) + } + + func testCalibrationTargenProcessId() async throws { + let testRoot = try makeTestDir() + let pm = ProcessManager() + let runner = makeRunner(processManager: pm) + let events = pm.events() + // Subscribed before spawn; the exit event is emitted before + // runCalibrationTargen returns, so this always terminates. + let sawExit = Task { + for await event in events { + guard event.id == "targen_CAL_foo" else { continue } + if case .exit = event { return true } + } + return false + } + let config = CalibrationTargenConfig( + colourSpace: .rgb, + steps: 21, + basename: "foo", + workingDirectory: testRoot + ) + + let url = try await runner.runCalibrationTargen(config: config) + + XCTAssertEqual(url.lastPathComponent, "CAL_foo.ti1") + let sawExitEvent = await sawExit.value + XCTAssertTrue(sawExitEvent) + try? FileManager.default.removeItem(at: testRoot) + } + + func testPrintcalProducesCal() async throws { + let testRoot = try makeTestDir() + let runner = makeRunner() + let output = testRoot.appendingPathComponent("CAL_demo.cal") + let config = PrintcalConfig( + ti3Basename: "CAL_demo", + workingDirectory: testRoot, + outputURL: output + ) + + let url = try await runner.runPrintcal(config: config) + + XCTAssertEqual(url.lastPathComponent, "CAL_demo.cal") + XCTAssertTrue(FileManager.default.fileExists(atPath: url.path)) + try? FileManager.default.removeItem(at: testRoot) + } + + func testPrintcalFailureThrows() async throws { + let testRoot = try makeTestDir() + defer { try? FileManager.default.removeItem(at: testRoot) } + + // Per-test mock printcal that always fails — no global + // environment mutation, no shared fixture changes. + let binDir = try makeTestDir() + defer { try? FileManager.default.removeItem(at: binDir) } + let mockURL = binDir.appendingPathComponent("printcal") + try """ + #!/bin/sh + echo "printcal mock failure" >&2 + exit 1 + """.write(to: mockURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], ofItemAtPath: mockURL.path) + + let runner = ArgyllRunner( + processManager: ProcessManager(), + binaryResolver: BinaryResolver(bundledRoot: binDir, overrideDir: binDir) + ) + let output = testRoot.appendingPathComponent("CAL_demo.cal") + let config = PrintcalConfig( + ti3Basename: "CAL_demo", + workingDirectory: testRoot, + outputURL: output + ) + + await assertAsyncThrows(expectedType: ArgyllRunnerError.self) { + _ = try await runner.runPrintcal(config: config) + } errorHandler: { error in + XCTAssertEqual(error, .toolFailed( + tool: "printcal", code: 1, logs: ["printcal mock failure\n"])) + } + } +} diff --git a/Tests/ICCeryCoreTests/ArgyllRunnerColprofTests.swift b/Tests/ICCeryCoreTests/ArgyllRunnerColprofTests.swift new file mode 100644 index 0000000..1769fc4 --- /dev/null +++ b/Tests/ICCeryCoreTests/ArgyllRunnerColprofTests.swift @@ -0,0 +1,79 @@ +import Foundation +import XCTest +@testable import ICCeryCore + +final class LogHolder: @unchecked Sendable { + private let lock = NSLock() + private var _lines: [String] = [] + + func append(_ batch: [String]) { + lock.lock() + _lines.append(contentsOf: batch) + lock.unlock() + } + + var lines: [String] { + lock.lock() + defer { lock.unlock() } + return _lines + } +} + +final class ArgyllRunnerColprofTests: XCTestCase { + + func testColprofProducesIcc() async throws { + let binDir = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("ICCeryUITests/Fixtures/bin") + let testRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("colprof-test-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: testRoot, withIntermediateDirectories: true) + + let runner = ArgyllRunner( + processManager: ProcessManager(), + binaryResolver: BinaryResolver(overrideDir: binDir) + ) + + let holder = LogHolder() + let config = ColprofConfig(basename: "testrun", workingDirectory: testRoot) + let url = try await runner.runColprof(config: config) { batch in + holder.append(batch) + } + + XCTAssertEqual(url.lastPathComponent, "testrun.icc") + XCTAssertTrue(FileManager.default.fileExists(atPath: url.path)) + XCTAssertTrue(holder.lines.contains { $0.contains("Gamut mapping") }) + + try? FileManager.default.removeItem(at: testRoot) + } + + func testColprofFailureThrowsToolFailed() async throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("colprof-fail-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let mockURL = dir.appendingPathComponent("colprof") + try """ + #!/bin/sh + echo "colprof broke" >&2 + exit 4 + """.write(to: mockURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], ofItemAtPath: mockURL.path) + + let runner = ArgyllRunner( + processManager: ProcessManager(), + binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir) + ) + let config = ColprofConfig(basename: "failrun", workingDirectory: dir) + + await assertAsyncThrows(expectedType: ArgyllRunnerError.self) { + try await runner.runColprof(config: config) + } errorHandler: { error in + XCTAssertEqual(error, .toolFailed( + tool: "colprof", code: 4, logs: ["colprof broke"])) + } + } +} diff --git a/Tests/ICCeryCoreTests/ArgyllRunnerStreamingLoopTests.swift b/Tests/ICCeryCoreTests/ArgyllRunnerStreamingLoopTests.swift new file mode 100644 index 0000000..083401a --- /dev/null +++ b/Tests/ICCeryCoreTests/ArgyllRunnerStreamingLoopTests.swift @@ -0,0 +1,158 @@ +import Foundation +import XCTest +@testable import ICCeryCore + +/// Focused contracts for the shared `runStreamingTool` loop (#79). +/// +/// Every test uses a per-test temporary directory, unique basenames, +/// and a fresh `ProcessManager` — no shared UI fixture scripts and no +/// process-environment mutation. +final class ArgyllRunnerStreamingLoopTests: XCTestCase { + + private func makeTempDir() throws -> URL { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("runner-loop-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + private func writeMock(_ name: String, _ body: String, in dir: URL) throws { + let url = dir.appendingPathComponent(name) + try body.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], ofItemAtPath: url.path) + } + + private func makeRunner(binDir: URL) -> ArgyllRunner { + ArgyllRunner( + processManager: ProcessManager(), + binaryResolver: BinaryResolver(bundledRoot: binDir, overrideDir: binDir)) + } + + func testNonZeroExitThrowsToolFailed() async throws { + let dir = try makeTempDir() + defer { try? FileManager.default.removeItem(at: dir) } + try writeMock("targen", """ + #!/bin/sh + echo "Generating patches..." + echo "targen: too few patches" >&2 + exit 3 + """, in: dir) + let runner = makeRunner(binDir: dir) + let config = TargenConfig( + colourSpace: .rgb, patchCount: 800, whitePatches: 4, + blackPatches: 4, basename: "fail", workingDirectory: dir) + + do { + _ = try await runner.runTargen(config: config) + XCTFail("Expected toolFailed") + } catch let error as ArgyllRunnerError { + guard case .toolFailed(let tool, let code, let logs) = error else { + XCTFail("Expected toolFailed, got \(error)") + return + } + XCTAssertEqual(tool, "targen") + XCTAssertEqual(code, 3) + XCTAssertTrue(logs.contains("Generating patches...")) + XCTAssertTrue(logs.contains("targen: too few patches")) + } + } + + func testZeroExitMissingArtefact() async throws { + let dir = try makeTempDir() + defer { try? FileManager.default.removeItem(at: dir) } + try writeMock("targen", """ + #!/bin/sh + echo "done but wrote nothing" + exit 0 + """, in: dir) + let runner = makeRunner(binDir: dir) + let expectedPath = dir.appendingPathComponent("gone.ti1").path + let config = TargenConfig( + colourSpace: .rgb, patchCount: 800, whitePatches: 4, + blackPatches: 4, basename: "gone", workingDirectory: dir) + + await assertAsyncThrows(expectedType: ArgyllRunnerError.self) { + try await runner.runTargen(config: config) + } errorHandler: { error in + XCTAssertEqual(error, .missingArtefact(expectedPath)) + } + } + + func testImmediateExitDeliversLine() async throws { + let dir = try makeTempDir() + defer { try? FileManager.default.removeItem(at: dir) } + try writeMock("targen", """ + #!/bin/sh + last="" + for arg in "$@"; do last="$arg"; done + echo "only line" + touch "$last.ti1" + exit 0 + """, in: dir) + let runner = makeRunner(binDir: dir) + let config = TargenConfig( + colourSpace: .rgb, patchCount: 800, whitePatches: 4, + blackPatches: 4, basename: "quick", workingDirectory: dir) + + let holder = LogHolder() + let url = try await runner.runTargen(config: config) { batch in + holder.append(batch) + } + XCTAssertEqual(url.lastPathComponent, "quick.ti1") + XCTAssertTrue(FileManager.default.fileExists(atPath: url.path)) + XCTAssertTrue(holder.lines.contains("only line")) + } + + func testColprofPartialLineFlush() async throws { + let dir = try makeTempDir() + defer { try? FileManager.default.removeItem(at: dir) } + // The fragment is printed without a newline, then the mock sleeps + // past the 500 ms partial-line flush interval before writing the + // artefact and exiting — so the tail is delivered mid-run. + try writeMock("colprof", """ + #!/bin/sh + last="" + for arg in "$@"; do last="$arg"; done + printf 'Doing gamut mapping' + sleep 2 + touch "$last.icc" + exit 0 + """, in: dir) + let runner = makeRunner(binDir: dir) + let config = ColprofConfig(basename: "frag", workingDirectory: dir) + + let holder = LogHolder() + let url = try await runner.runColprof(config: config) { batch in + holder.append(batch) + } + XCTAssertEqual(url.lastPathComponent, "frag.icc") + XCTAssertTrue(FileManager.default.fileExists(atPath: url.path)) + XCTAssertTrue(holder.lines.contains("Doing gamut mapping")) + } + + func testToolDescriptions() { + let cases: [(tool: String, expected: String)] = [ + (tool: "chartread", expected: "Chartread failed: boom"), + (tool: "average", expected: "Averaging failed: boom"), + (tool: "colprof", expected: "Profile creation failed: boom"), + (tool: "printcal", expected: "Calibration curve computation failed: boom"), + (tool: "applycal", expected: "Apply calibration failed: boom"), + (tool: "iccgamut", expected: "Gamut extraction failed: boom"), + (tool: "profcheck", expected: "Profile verification failed: boom"), + ] + for (tool, expected) in cases { + let error = ArgyllRunnerError.toolFailed(tool: tool, code: 1, logs: ["boom"]) + XCTAssertEqual(error.errorDescription, expected) + } + } + + func testGenericFallbacks() { + let unknown = ArgyllRunnerError.toolFailed(tool: "targen", code: 7, logs: ["boom"]) + XCTAssertEqual(unknown.errorDescription, "Process exited with code 7") + + let emptyLogs = ArgyllRunnerError.toolFailed(tool: "colprof", code: 2, logs: []) + XCTAssertEqual(emptyLogs.errorDescription, + "Profile creation failed: exited with code 2") + } +} diff --git a/Tests/ICCeryCoreTests/ArtefactFilesTests.swift b/Tests/ICCeryCoreTests/ArtefactFilesTests.swift new file mode 100644 index 0000000..f5c7346 --- /dev/null +++ b/Tests/ICCeryCoreTests/ArtefactFilesTests.swift @@ -0,0 +1,212 @@ +import XCTest +import Foundation +import ImageIO +import UniformTypeIdentifiers +@testable import ICCeryCore + +private func tempURL(_ name: String) -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-af-\(UUID().uuidString)") + .appendingPathComponent(name) +} + +final class Ti2HeaderTests: XCTestCase { + func testParsesKeywordsAndSibling() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-ti2-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try """ + CTI2 + TARGET_INSTRUMENT "i1iO" + NUMBER_OF_FIELDS 9 + NUMBER_OF_SETS 800 + NUMBER_OF_PAGES 3 + BEGIN_DATA_FORMAT + SAMPLE_ID RGB_R + END_DATA_FORMAT + """.write(to: dir.appendingPathComponent("job.ti2"), atomically: true, encoding: .utf8) + try "CGATS".write( + to: dir.appendingPathComponent("job.ti1"), atomically: true, encoding: .utf8 + ) + + let h = Ti2Header.parse(dir.appendingPathComponent("job.ti2")) + XCTAssertEqual(h.instrument, "i1iO") + XCTAssertEqual(h.patchCount, 800) + XCTAssertEqual(h.pageCount, 3) + XCTAssertTrue(h.hasSiblingTi1) + } + + func testMissingFileYieldsEmptyHeader() { + let h = Ti2Header.parse(URL(fileURLWithPath: "/nonexistent/x.ti2")) + XCTAssertTrue(h.instrument == nil && h.patchCount == nil && !h.hasSiblingTi1) + } + + func testNumberOfFieldsIsNotPatchCount() throws { + let url = tempURL("t.ti2") + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true + ) + try "NUMBER_OF_FIELDS 9\nNUMBER_OF_SETS 52\nBEGIN_DATA\n".write( + to: url, atomically: true, encoding: .utf8 + ) + XCTAssertEqual(Ti2Header.parse(url).patchCount, 52) + } +} + +final class TiffPreviewTests: XCTestCase { + /// Builds a real 2000×1000 TIFF in a temp dir via ImageIO. + private func makeTiff(width: Int = 2000, height: Int = 1000) throws -> URL { + let url = tempURL("big.tif") + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true + ) + let colorSpace = CGColorSpace(name: CGColorSpace.sRGB)! + let ctx = CGContext( + data: nil, width: width, height: height, + bitsPerComponent: 8, bytesPerRow: width * 4, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + )! + ctx.setFillColor(CGColor(red: 0.5, green: 0.5, blue: 0.5, alpha: 1)) + ctx.fill(CGRect(x: 0, y: 0, width: width, height: height)) + let image = ctx.makeImage()! + + guard let dest = CGImageDestinationCreateWithURL( + url as CFURL, UTType.tiff.identifier as CFString, 1, nil + ) else { throw CocoaError(.fileWriteUnknown) } + CGImageDestinationAddImage(dest, image, nil) + guard CGImageDestinationFinalize(dest) else { throw CocoaError(.fileWriteUnknown) } + return url + } + + func testProducesCappedPNG() throws { + let tiff = try makeTiff() + let png = TiffPreview.previewPNG(tiff: tiff) + XCTAssertNotNil(png) + // PNG magic + XCTAssertEqual(png!.prefix(8), Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])) + // Verify the cap by decoding the thumbnail header. + let src = CGImageSourceCreateWithData(png! as CFData, nil)! + let img = CGImageSourceCreateImageAtIndex(src, 0, nil)! + XCTAssertTrue(max(img.width, img.height) <= TiffPreview.maxEdge) + XCTAssertEqual(img.width, 1200) + } + + func testNonTiffReturnsNil() throws { + let url = tempURL("not-tiff.txt") + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true + ) + try "hello".write(to: url, atomically: true, encoding: .utf8) + XCTAssertNil(TiffPreview.previewPNG(tiff: url)) + } +} + +final class ArtefactFilesTests: XCTestCase { + func testBase64RoundTrip() throws { + let url = tempURL("a.txt") + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true + ) + try "hello".write(to: url, atomically: true, encoding: .utf8) + let b64 = try ArtefactFiles.readBase64(url) + XCTAssertEqual(Data(base64Encoded: b64), Data("hello".utf8)) + } + + func testDefaultWorkingDirExists() { + XCTAssertTrue(FileManager.default.fileExists( + atPath: ArtefactFiles.defaultWorkingDirectory().path + )) + } +} + +final class ArtefactProbeProfileTests: XCTestCase { + private func makeDir() throws -> URL { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("probe-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + // MARK: Basename probe matrix (#69) + + func testOnlyIcc() throws { + let dir = try makeDir() + let icc = dir.appendingPathComponent("job.icc") + try Data("icc".utf8).write(to: icc) + XCTAssertEqual(ArtefactProbe.resolveProfile(basename: "job", cwd: dir)?.path, icc.path) + } + + func testOnlyIcm() throws { + let dir = try makeDir() + let icm = dir.appendingPathComponent("job.icm") + try Data("icm".utf8).write(to: icm) + XCTAssertEqual(ArtefactProbe.resolveProfile(basename: "job", cwd: dir)?.path, icm.path) + } + + func testIcmWins() throws { + let dir = try makeDir() + try Data("icc".utf8).write(to: dir.appendingPathComponent("job.icc")) + let icm = dir.appendingPathComponent("job.icm") + try Data("icm".utf8).write(to: icm) + let url = ArtefactProbe.resolveProfile(basename: "job", cwd: dir) + XCTAssertEqual(url?.path, icm.path) + } + + func testNeitherExists() throws { + let dir = try makeDir() + XCTAssertNil(ArtefactProbe.resolveProfile(basename: "job", cwd: dir)) + } + + // MARK: Explicit URL matrix (#69 / #83) + + func testExplicitIccWins() throws { + let dir = try makeDir() + let icc = dir.appendingPathComponent("job.icc") + try Data("icc".utf8).write(to: icc) + try Data("icm".utf8).write(to: dir.appendingPathComponent("job.icm")) + XCTAssertEqual(ArtefactProbe.resolveProfile(icc).path, icc.path) + } + + func testExplicitIcmWins() throws { + let dir = try makeDir() + try Data("icc".utf8).write(to: dir.appendingPathComponent("job.icc")) + let icm = dir.appendingPathComponent("job.icm") + try Data("icm".utf8).write(to: icm) + XCTAssertEqual(ArtefactProbe.resolveProfile(icm).path, icm.path) + } + + func testFlipExtension() throws { + let dir = try makeDir() + let icc = dir.appendingPathComponent("job.icc") + let icm = dir.appendingPathComponent("job.icm") + try Data("icm".utf8).write(to: icm) + let resolved = ArtefactProbe.resolveProfile(icc) + XCTAssertEqual(resolved.path, icm.path) + } + + func testFlipToIcc() throws { + let dir = try makeDir() + let icc = dir.appendingPathComponent("job.icc") + let icm = dir.appendingPathComponent("job.icm") + try Data("icc".utf8).write(to: icc) + XCTAssertEqual(ArtefactProbe.resolveProfile(icm).path, icc.path) + } + + func testMissingBoth() throws { + let dir = try makeDir() + let icc = dir.appendingPathComponent("job.icc") + XCTAssertEqual(ArtefactProbe.resolveProfile(icc).path, icc.path) + } + + func testUnrelatedExtension() throws { + let dir = try makeDir() + let mpp = dir.appendingPathComponent("job.mpp") + let icc = dir.appendingPathComponent("job.icc") + try Data("icc".utf8).write(to: icc) + // Even though a sibling .icc exists, a missing .mpp stays .mpp. + XCTAssertEqual(ArtefactProbe.resolveProfile(mpp).path, mpp.path) + let txt = dir.appendingPathComponent("job.txt") + XCTAssertEqual(ArtefactProbe.resolveProfile(txt).path, txt.path) + } +} diff --git a/Tests/ICCeryCoreTests/BinaryResolverTests.swift b/Tests/ICCeryCoreTests/BinaryResolverTests.swift new file mode 100644 index 0000000..06c1c5f --- /dev/null +++ b/Tests/ICCeryCoreTests/BinaryResolverTests.swift @@ -0,0 +1,82 @@ +import XCTest +import Foundation +@testable import ICCeryCore + +final class BinaryResolverTests: XCTestCase { + + private func makeTree(_ body: (URL) throws -> Void) throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-resolver-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try body(root) + return root + } + + private func touch(_ url: URL, executable: Bool = true) throws { + FileManager.default.createFile(atPath: url.path, contents: Data()) + if executable { + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], ofItemAtPath: url.path + ) + } + } + + func testOverrideDirWinsWhenFileExists() throws { + let override = try makeTree { root in + try touch(root.appendingPathComponent("targen")) + } + let bundled = try makeTree { _ in } + let r = BinaryResolver(bundledRoot: bundled, overrideDir: override) + XCTAssertEqual(r.resolve("targen"), override.appendingPathComponent("targen")) + } + + func testOverrideFallsThroughWhenMissing() throws { + let override = try makeTree { _ in } + let bundled = try makeTree { root in + let dir = root.appendingPathComponent("macos-universal") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try touch(dir.appendingPathComponent("instlist")) + } + let r = BinaryResolver(bundledRoot: bundled, overrideDir: override) + XCTAssertTrue(r.resolve("targen").path.contains("macos-universal/targen")) + } + + func testUniversalPreferredWhenMarkerPresent() throws { + let bundled = try makeTree { root in + for dir in ["macos-universal", "macos-x86_64"] { + let d = root.appendingPathComponent(dir) + try FileManager.default.createDirectory(at: d, withIntermediateDirectories: true) + try touch(d.appendingPathComponent("instlist")) + } + } + let r = BinaryResolver(bundledRoot: bundled) + XCTAssertEqual(r.platformDir(), "macos-universal") + } + + func testFallsBackToArchDir() throws { + let bundled = try makeTree { root in + let d = root.appendingPathComponent("macos-x86_64") + try FileManager.default.createDirectory(at: d, withIntermediateDirectories: true) + try touch(d.appendingPathComponent("instlist")) + } + let r = BinaryResolver( + bundledRoot: bundled, + archDirs: ["macos-universal", "macos-x86_64"] + ) + XCTAssertEqual(r.platformDir(), "macos-x86_64") + } + + func testMissingEverythingReturnsConstructedPath() throws { + let bundled = try makeTree { _ in } + let r = BinaryResolver(bundledRoot: bundled) + // v1 semantic: path is returned; spawn surfaces the error. + XCTAssertTrue(r.resolve("targen").path.hasSuffix("macos-universal/targen")) + XCTAssertFalse(r.exists(r.resolve("targen"))) + } + + func testMockAndGamutPaths() throws { + let r = BinaryResolver(bundledRoot: URL(fileURLWithPath: "/x")) + XCTAssertEqual(r.mock("chartread").path, "/x/mocks/chartread.mock") + XCTAssertEqual(r.referenceGamut("sRGB.gam").path, "/x/reference_gamuts/sRGB.gam") + } +} diff --git a/Tests/ICCeryCoreTests/CGATSParserTests.swift b/Tests/ICCeryCoreTests/CGATSParserTests.swift new file mode 100644 index 0000000..8eb48d4 --- /dev/null +++ b/Tests/ICCeryCoreTests/CGATSParserTests.swift @@ -0,0 +1,121 @@ +import Foundation +import XCTest +@testable import ICCeryCore + +final class CGATSParserTests: XCTestCase { + + private static let canonicalCTI3 = """ + CTI3 + DESCRIPTOR "Sample target" + COLOR_REP "RGB" + DEVICE_CLASS "DISPLAY" + NUMBER_OF_FIELDS 11 + NUMBER_OF_SETS 2 + BEGIN_DATA_FORMAT + SAMPLE_ID\tSAMPLE_LOC\tRGB_R\tRGB_G\tRGB_B\tXYZ_X\tXYZ_Y\tXYZ_Z\tLAB_L\tLAB_A\tLAB_B + END_DATA_FORMAT + BEGIN_DATA + 1\tA1\t50.0\t0.0\t0.0\t20.0\t10.0\t5.0\t50.0\t60.0\t30.0 + 2\tA2\t0.0\t50.0\t0.0\t10.0\t30.0\t5.0\t60.0\t-50.0\t40.0 + END_DATA + """ + + func testParseCTI3() throws { + let dataset = try CGATSParser.parse(Self.canonicalCTI3) + XCTAssertEqual(dataset.format, .cti3) + XCTAssertEqual(dataset.samples.count, 2) + XCTAssertEqual(dataset.colorRep, "RGB") + XCTAssertEqual(dataset.deviceClass, "DISPLAY") + XCTAssertEqual(dataset.samples[0].id, "1") + XCTAssertEqual(dataset.samples[0].loc, "A1") + XCTAssertEqual(dataset.samples[1].values["RGB_G"], "50.0000") + } + + func testRoundTrip() throws { + let first = try CGATSParser.parse(Self.canonicalCTI3) + let text = try CGATSWriter.write(first) + let second = try CGATSParser.parse(text) + XCTAssertEqual(second.format, first.format) + XCTAssertEqual(second.samples.count, first.samples.count) + XCTAssertEqual(second.colorRep, first.colorRep) + XCTAssertEqual(second.deviceClass, first.deviceClass) + } + + func testParseCSV() throws { + let csv = """ + SAMPLE_ID,SAMPLE_LOC,RGB_R,RGB_G,RGB_B,XYZ_X,XYZ_Y,XYZ_Z,LAB_L,LAB_A,LAB_B + 1,A1,50,0,0,20,10,5,50,60,30 + 2,A2,0,50,0,10,30,5,60,-50,40 + """ + let dataset = try CGATSParser.parse(csv, sourceURL: URL(fileURLWithPath: "/tmp/sample.csv")) + XCTAssertEqual(dataset.format, .csv) + XCTAssertEqual(dataset.samples.count, 2) + XCTAssertEqual(dataset.samples[0].values["RGB_R"], "50.0000") + } + + func testConverts255To100() throws { + let rgb = """ + CTI3 + COLOR_REP RGB + NUMBER_OF_FIELDS 6 + NUMBER_OF_SETS 1 + BEGIN_DATA_FORMAT + SAMPLE_ID RGB_R RGB_G RGB_B XYZ_X XYZ_Y + END_DATA_FORMAT + BEGIN_DATA + 1 255 128 0 50 25 + END_DATA + """ + let dataset = try CGATSParser.parse(rgb) + XCTAssertEqual(dataset.samples[0].values["RGB_R"], "100.0000") + XCTAssertEqual(dataset.samples[0].values["RGB_G"], "50.1961") + } + + func testSynthesizesMetadata() throws { + let cmyk = """ + CTI3 + NUMBER_OF_FIELDS 6 + NUMBER_OF_SETS 1 + BEGIN_DATA_FORMAT + SAMPLE_ID CMYK_C CMYK_M CMYK_Y CMYK_K LAB_L + END_DATA_FORMAT + BEGIN_DATA + 1 50 50 50 50 50 + END_DATA + """ + let dataset = try CGATSParser.parse(cmyk) + XCTAssertEqual(dataset.colorRep, "CMYK") + XCTAssertEqual(dataset.deviceClass, "PRINTER") + } + + func testRejectsEmpty() { + XCTAssertThrowsError(try CGATSParser.parse("")) + } + + func testRejectsArity() { + let bad = """ + CTI3 + NUMBER_OF_FIELDS 2 + NUMBER_OF_SETS 1 + BEGIN_DATA_FORMAT + SAMPLE_ID RGB_R + END_DATA_FORMAT + BEGIN_DATA + 1 + END_DATA + """ + XCTAssertThrowsError(try CGATSParser.parse(bad)) + } + + func testWriterFormat() throws { + let dataset = try CGATSParser.parse(Self.canonicalCTI3) + let text = try CGATSWriter.write(dataset) + XCTAssertTrue(text.contains("CTI3")) + XCTAssertTrue(text.contains("BEGIN_DATA_FORMAT")) + XCTAssertTrue(text.contains("BEGIN_DATA")) + XCTAssertTrue(text.contains("END_DATA")) + XCTAssertTrue(text.contains("COLOR_REP")) + XCTAssertTrue(text.contains("DEVICE_CLASS")) + XCTAssertTrue(text.contains("\t")) + } +} diff --git a/Tests/ICCeryCoreTests/CalibrationIdentityTests.swift b/Tests/ICCeryCoreTests/CalibrationIdentityTests.swift new file mode 100644 index 0000000..2f2da4b --- /dev/null +++ b/Tests/ICCeryCoreTests/CalibrationIdentityTests.swift @@ -0,0 +1,67 @@ +import Foundation +import XCTest +@testable import ICCeryCore + +/// Issue #83 — canonical `CAL_` / original-stem pairing. +final class CalibrationIdentityTests: XCTestCase { + func testLivePlain() { + let id = CalibrationIdentity.parse(liveBasename: "foo", persistedOriginal: "") + XCTAssertEqual(id.originalBasename, "foo") + XCTAssertEqual(id.calibrationBasename, "CAL_foo") + } + + func testLivePlainIgnoresPersisted() { + let id = CalibrationIdentity.parse(liveBasename: "foo", persistedOriginal: "bar") + XCTAssertEqual(id.originalBasename, "foo") + XCTAssertEqual(id.calibrationBasename, "CAL_foo") + } + + func testLiveCalPersisted() { + let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "foo") + XCTAssertEqual(id.originalBasename, "foo") + XCTAssertEqual(id.calibrationBasename, "CAL_foo") + } + + func testLiveCalNoPersist() { + let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "") + XCTAssertEqual(id.originalBasename, "foo") + XCTAssertEqual(id.calibrationBasename, "CAL_foo") + } + + func testPersistedWins() { + let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "bar") + XCTAssertEqual(id.originalBasename, "bar") + XCTAssertEqual(id.calibrationBasename, "CAL_bar") + } + + func testEmptyLiveWithPersisted() { + let id = CalibrationIdentity.parse(liveBasename: "", persistedOriginal: "foo") + XCTAssertTrue(id.originalBasename.isEmpty) + XCTAssertTrue(id.calibrationBasename.isEmpty) + } + + func testEmptyLive() { + let id = CalibrationIdentity.parse(liveBasename: "", persistedOriginal: "") + XCTAssertTrue(id.originalBasename.isEmpty) + XCTAssertTrue(id.calibrationBasename.isEmpty) + } + + func testAlreadyPrefixed() { + XCTAssertEqual(CalibrationIdentity.prefix("CAL_foo"), "CAL_foo") + XCTAssertEqual(CalibrationIdentity.prefix("foo"), "CAL_foo") + let id = CalibrationIdentity.parse(liveBasename: "CAL_CAL_foo", persistedOriginal: "") + XCTAssertEqual(id.originalBasename, "CAL_foo") + XCTAssertEqual(id.calibrationBasename, "CAL_foo") + } + + func testPrefixEmpty() { + XCTAssertTrue(CalibrationIdentity.prefix("").isEmpty) + XCTAssertEqual(CalibrationIdentity.strip("foo"), "foo") + XCTAssertEqual(CalibrationIdentity.strip("CAL_foo"), "foo") + } + + func testProcessIdMatches() { + let cal = CalibrationIdentity.prefix("foo") + XCTAssertEqual(ProcessID.targen(cal), "targen_CAL_foo") + } +} diff --git a/Tests/ICCeryCoreTests/CalibrationStoreTests.swift b/Tests/ICCeryCoreTests/CalibrationStoreTests.swift new file mode 100644 index 0000000..509d71f --- /dev/null +++ b/Tests/ICCeryCoreTests/CalibrationStoreTests.swift @@ -0,0 +1,65 @@ +import Foundation +import XCTest +@testable import ICCeryCore + +final class CalibrationStoreTests: XCTestCase { + + 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 + """ + + func testParseCal() async throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("test_\(UUID().uuidString).cal") + try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8) + + let store = CalibrationStore(staleDays: 30) + try await store.load(url: url) + + let data = await store.data + XCTAssertEqual(data?.colorRep, "RGB") + XCTAssertEqual(data?.descriptor, "Test printer") + XCTAssertEqual(data?.maxTac, 300) + XCTAssertEqual(data?.curves.count, 3) + + let r = data?.curves.first { $0.channel == "R" } + XCTAssertEqual(r?.output, [0, 64, 255]) + } + + func testStaleCalibration() async throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("stale_\(UUID().uuidString).cal") + try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8) + + let store = CalibrationStore(staleDays: 0) + try await store.load(url: url) + let stale = await store.isStale(comparedTo: "Other") + XCTAssertEqual(stale, true) + } + + func testPrinterMismatch() async throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("mismatch_\(UUID().uuidString).cal") + try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8) + + let store = CalibrationStore(staleDays: 9999) + try await store.load(url: url) + await store.setPrinterName("Printer A") + let stale = await store.isStale(comparedTo: "Printer B") + XCTAssertEqual(stale, true) + } +} diff --git a/Tests/ICCeryCoreTests/CalibrationTargenArgsTests.swift b/Tests/ICCeryCoreTests/CalibrationTargenArgsTests.swift new file mode 100644 index 0000000..1739444 --- /dev/null +++ b/Tests/ICCeryCoreTests/CalibrationTargenArgsTests.swift @@ -0,0 +1,52 @@ +import Foundation +import XCTest +@testable import ICCeryCore + +final class CalibrationTargenArgsTests: XCTestCase { + + func testRgbBaseline() throws { + let config = CalibrationTargenConfig( + colourSpace: .rgb, + steps: 21, + whitePatches: 4, + basename: "demo", + workingDirectory: URL(fileURLWithPath: "/tmp") + ) + let args = try CalibrationTargenArgs.build(config: config) + XCTAssertEqual(args, ["-v", "-d", "2", "-s", "21", "-g", "21", "-e", "4", "-f", "0", "CAL_demo"]) + } + + func testCmykWithOptions() throws { + let config = CalibrationTargenConfig( + colourSpace: .cmyk, + steps: 25, + whitePatches: 4, + includeNeutralEmphasis: true, + inkLimit: 320, + basename: "printer", + workingDirectory: URL(fileURLWithPath: "/tmp") + ) + let args = try CalibrationTargenArgs.build(config: config) + XCTAssertEqual(args, ["-v", "-d", "4", "-s", "25", "-g", "25", "-e", "4", "-f", "0", "-n", "25", "-l", "320", "CAL_printer"]) + } + + func testRejectsBadSteps() { + let config = CalibrationTargenConfig(steps: 5, basename: "demo") + XCTAssertThrowsError(try CalibrationTargenArgs.build(config: config)) + } + + func testRejectsBadInkLimit() { + let config = CalibrationTargenConfig( + colourSpace: .cmyk, + inkLimit: 500, + basename: "demo" + ) + XCTAssertThrowsError(try CalibrationTargenArgs.build(config: config)) + } + + func testNoDoublePrefix() throws { + let config = CalibrationTargenConfig(basename: "CAL_test") + let args = try CalibrationTargenArgs.build(config: config) + XCTAssertEqual(args.last, "CAL_test") + } +} diff --git a/Tests/ICCeryCoreTests/ColprofArgsTests.swift b/Tests/ICCeryCoreTests/ColprofArgsTests.swift new file mode 100644 index 0000000..653e674 --- /dev/null +++ b/Tests/ICCeryCoreTests/ColprofArgsTests.swift @@ -0,0 +1,63 @@ +import Foundation +import XCTest +@testable import ICCeryCore + +final class ColprofArgsTests: XCTestCase { + + func testDefaults() throws { + let config = ColprofConfig(basename: "target") + let args = try ColprofArgs.build(config: config) + XCTAssertEqual(args, ["-v", "-a", "l", "-q", "m", "target"]) + } + + func testFwaBareFlag() throws { + let config = ColprofConfig(fwa: "", basename: "target") + let args = try ColprofArgs.build(config: config) + XCTAssertEqual(args, ["-v", "-a", "l", "-q", "m", "-f", "target"]) + } + + func testFwaD50() throws { + let config = ColprofConfig(fwa: "D50", basename: "target") + let args = try ColprofArgs.build(config: config) + XCTAssertTrue(args.contains("-f")) + XCTAssertTrue(args.contains("D50")) + XCTAssertEqual(args.last, "target") + } + + func testFwaNoneOmitted() throws { + let config = ColprofConfig(fwa: "none", basename: "target") + let args = try ColprofArgs.build(config: config) + XCTAssertFalse(args.contains("-f")) + } + + func testViewingCondNoneSkipped() throws { + let config = ColprofConfig( + inputViewingCond: "none", + outputViewingCond: "mt", + basename: "target" + ) + let args = try ColprofArgs.build(config: config) + XCTAssertFalse(args.contains("-c")) + XCTAssertTrue(args.contains("-d")) + XCTAssertTrue(args.contains("mt")) + } + + func testDescriptionFallback() throws { + let config = ColprofConfig(description: "", basename: "target") + let args = try ColprofArgs.build(config: config) + XCTAssertFalse(args.contains("-D")) + } + + func testCopyright() throws { + let config = ColprofConfig(copyright: "Gronod 2026", basename: "target") + let args = try ColprofArgs.build(config: config) + XCTAssertTrue(args.contains("-C")) + XCTAssertTrue(args.contains("Gronod 2026")) + } + + func testNoProgressJsonFlag() throws { + let config = ColprofConfig(basename: "target") + let args = try ColprofArgs.build(config: config) + XCTAssertFalse(args.contains("-u")) + } +} diff --git a/Tests/ICCeryCoreTests/ColprofProgressTests.swift b/Tests/ICCeryCoreTests/ColprofProgressTests.swift new file mode 100644 index 0000000..66f7b31 --- /dev/null +++ b/Tests/ICCeryCoreTests/ColprofProgressTests.swift @@ -0,0 +1,20 @@ +import Foundation +import XCTest +@testable import ICCeryCore + +final class ColprofProgressTests: XCTestCase { + + func testGamutMapping() { + XCTAssertEqual(ColprofProgressClassifier.classify(line: "Gamut mapping calculation in progress"), .gamutMapping) + } + + func testFitting() { + XCTAssertEqual(ColprofProgressClassifier.classify(line: "Fitting cLUT grid points"), .fittingClut) + XCTAssertEqual(ColprofProgressClassifier.classify(line: "clut table"), .fittingClut) + } + + func testWriting() { + XCTAssertEqual(ColprofProgressClassifier.classify(line: "Writing ICC profile header"), .writingIcc) + XCTAssertEqual(ColprofProgressClassifier.classify(line: "icc profile written"), .writingIcc) + } +} diff --git a/Tests/ICCeryCoreTests/CupsOptionsFilterTests.swift b/Tests/ICCeryCoreTests/CupsOptionsFilterTests.swift new file mode 100644 index 0000000..b4fe1ba --- /dev/null +++ b/Tests/ICCeryCoreTests/CupsOptionsFilterTests.swift @@ -0,0 +1,136 @@ +import XCTest +import Foundation +@testable import ICCeryCore +@testable import ICCery +import AppKit +import ApplicationServices + +/// Issue 14 — PMPrintSettingsToOptions capture filter (docs/11 layer ⑥). +final class CupsOptionsFilterTests: XCTestCase { + + func testDropsReserved() { + let raw = "AP_ColorMatchingMode=AP_ApplicationColorMatching " + + "AP.ColorMatchingMode=AP_ApplicationColorMatching " + + "com.apple.print.JobTicket.PMTotalSidesImaged=0 " + + "collate=true copies=1 job-sheets=none,none " + + "pserrorhandler-requested=standard " + + "MediaType=PhotographicGlossy" + XCTAssertEqual(CupsOptionsFilter.filter(raw), "MediaType=PhotographicGlossy") + } + + func testKeepsRelevant() { + let raw = "InputSlot=Rear PageSize=A4 CNIJIntent2=4 " + + "Resolution=600x600dpi Duplex=None" + XCTAssertEqual(CupsOptionsFilter.filter(raw), raw) + } + + func testKeepsUnknown() { + let raw = "VendorFooBar=baz MediaType=Plain" + XCTAssertEqual(CupsOptionsFilter.filter(raw), raw) + } + + func testDropsEmpty() { + let raw = "=noval MediaType= InputSlot=Rear" + // "MediaType=" has an empty value → dropped; "=noval" empty key. + XCTAssertEqual(CupsOptionsFilter.filter(raw), "InputSlot=Rear") + } + + func testExtractMedia() { + XCTAssertEqual(CupsParsers.extractMediaType( + fromOptionsString: "MediaType=Photo EPIJ_Medi=1"), "Photo") + XCTAssertEqual(CupsParsers.extractMediaType( + fromOptionsString: "EPIJ_Medi=7"), "7") + XCTAssertNil(CupsParsers.extractMediaType( + fromOptionsString: "PageSize=A4")) + } +} + +/// Issue 14 — the dlsym attempt order and first-success semantics. +/// `@convention(c)` closures can't capture, so recording goes through +/// a file-scope recorder keyed by global state; no private symbols are +/// touched. +@MainActor +final class ColorSyncSuppressorTests: XCTestCase { + + /// Fake PMPrintSession — the injected resolver never dereferences it. + private var fakeSession: PMPrintSession { + unsafeBitCast(UnsafeMutableRawPointer(bitPattern: 0xdead)!, to: PMPrintSession.self) + } + + /// Call log — static since `@convention(c)` can't capture. The + /// resolver sets `currentSymbol` right before each call, so the C + /// function records (symbol, mode) without capturing `name`. + private static var recorded: [(String, String)] = [] + private static var currentSymbol = "" + private static var succeeding: (String, String)? + private static var missing: Set = [] + + private func makeSuppressor() -> ColorSyncSuppressor { + var s = ColorSyncSuppressor() + s.log = { _ in } + s.modeResolver = { name in + if Self.missing.contains(name) { return nil } + Self.currentSymbol = name + // `Self` inside a @convention(c) closure is a dynamic-Self + // capture — spell the (final) class name instead. + return { _, modeArg in + ColorSyncSuppressorTests.recorded.append( + (ColorSyncSuppressorTests.currentSymbol, modeArg as String)) + if let ok = ColorSyncSuppressorTests.succeeding, + ColorSyncSuppressorTests.currentSymbol == ok.0, + (modeArg as String) == ok.1 { + return 0 + } + return 1 + } + } + return s + } + + func testAttemptOrder() { + Self.recorded = [] + Self.succeeding = nil + Self.missing = ["PMSessionSetColorMatchingModeLock"] + let s = makeSuppressor() + XCTAssertEqual(s.applySPIMode(to: fakeSession), false) + // Lock is unresolvable → skipped; the rest plays out in order. + XCTAssertEqual(Self.recorded.map { "\($0.0)|\($0.1)" }, ColorMatchingAttempts.attempts + .filter { $0.symbol != "PMSessionSetColorMatchingModeLock" } + .map { "\($0.symbol)|\($0.mode)" }) + } + + func testFirstZeroWins() { + Self.recorded = [] + Self.succeeding = ("PMSessionSetColorMatchingModeLock", + "AP_ApplicationColorMatching") + Self.missing = [] + let s = makeSuppressor() + XCTAssertTrue(s.applySPIMode(to: fakeSession)) + XCTAssertEqual(Self.recorded.map { "\($0.0)|\($0.1)" }, [ + "PMSessionSetColorMatchingModeLock|AP_ApplicationColorMatching", + ]) + } + + func testModeFallback() { + Self.recorded = [] + Self.succeeding = ("PMSessionSetColorMatchingModeLock", + "ApplicationColorMatching") + Self.missing = [] + let s = makeSuppressor() + XCTAssertTrue(s.applySPIMode(to: fakeSession)) + XCTAssertEqual(Self.recorded[0].0, "PMSessionSetColorMatchingModeLock") + XCTAssertEqual(Self.recorded[0].1, "AP_ApplicationColorMatching") + XCTAssertEqual(Self.recorded[1].0, "PMSessionSetColorMatchingModeLock") + XCTAssertEqual(Self.recorded[1].1, "ApplicationColorMatching") + XCTAssertEqual(Self.recorded.count, 2) + } + + func testAllMissing() { + Self.recorded = [] + Self.succeeding = nil + Self.missing = Set(ColorMatchingAttempts.symbols) + let s = makeSuppressor() + XCTAssertEqual(s.applySPIMode(to: fakeSession), false) + XCTAssertTrue(Self.recorded.isEmpty) + } +} diff --git a/Tests/ICCeryCoreTests/CupsParserTests.swift b/Tests/ICCeryCoreTests/CupsParserTests.swift new file mode 100644 index 0000000..a4be264 --- /dev/null +++ b/Tests/ICCeryCoreTests/CupsParserTests.swift @@ -0,0 +1,134 @@ +import XCTest +import Foundation +@testable import ICCeryCore + +/// Issue 12 — CUPS enumeration parsers on recorded fixtures +/// (docs/10–11). No live `lpstat`/`lpoptions` is spawned here. +final class CupsParsersTests: XCTestCase { + + // Recorded on an Epson XP-55 + Canon Pro9500 host. + private let lpstatE = """ + Canon_Pro9500_II_series_XPS + Epson_XP_55_LPD + EPSON_XP_55_Series + """ + + private let lpstatP = """ + printer Canon_Pro9500_II_series_XPS is idle. enabled since Mon Sep 7 22:51:30 2026 + printer Epson_XP_55_LPD now printing Epson_XP_55_LPD-42. enabled since Mon Sep 7 21:50:25 2026 + printer EPSON_XP_55_Series disabled since Tue Sep 8 09:00:00 2026 - + Paused + """ + + private let lpoptionsP = """ + device-uri=ipp://EPSON%20XP-55%20Series._ipp._tcp.local./ printer-info='EPSON XP-55 Series' printer-location printer-make-and-model='EPSON EPSON XP-55 Series' printer-type=16781340 + """ + + private let lpoptionsL = """ + PageSize/Media Size: 3.5x5 4x6 5x7 8x10 *A4 A5 B5 Letter Legal Custom.WIDTHxHEIGHT + InputSlot/Media Source: Auto *Main Photo Rear + MediaType/Media Type: *Stationery PhotographicHighGloss Photographic PhotographicMatte Envelope + ColorModel/Output Mode: *RGB Gray + Duplex/Duplex: *None DuplexNoTumble DuplexTumble + cupsPrintQuality/cupsPrintQuality: Draft *Normal High + """ + + func testDestinations() { + XCTAssertEqual(CupsParsers.lpstatDestinations(lpstatE), [ + "Canon_Pro9500_II_series_XPS", + "Epson_XP_55_LPD", + "EPSON_XP_55_Series", + ]) + XCTAssertEqual(CupsParsers.lpstatDestinations(""), []) + } + + func testStatuses() { + let s = CupsParsers.lpstatStatuses(lpstatP) + XCTAssertEqual(s["Canon_Pro9500_II_series_XPS"], .idle) + XCTAssertEqual(s["Epson_XP_55_LPD"], .printing) + XCTAssertEqual(s["EPSON_XP_55_Series"], .stopped) + } + + func testDefaultDestination() { + XCTAssertEqual(CupsParsers.lpstatDefault( + "system default destination: Canon_Pro9500_II_series_XPS\n"), "Canon_Pro9500_II_series_XPS") + XCTAssertNil(CupsParsers.lpstatDefault("no system default destination\n")) + } + + func testDisplayName() { + XCTAssertEqual(CupsParsers.lpoptionsDisplayName(lpoptionsP), "EPSON XP-55 Series") + XCTAssertNil(CupsParsers.lpoptionsDisplayName("printer-type=42\n")) + } + + func testOptionListings() { + let listings = CupsParsers.lpoptionsList(lpoptionsL) + XCTAssertEqual(listings.count, 6) + + let page = listings[0] + XCTAssertEqual(page.key, "PageSize") + XCTAssertEqual(page.label, "Media Size") + XCTAssertEqual(page.defaultChoice, "A4") + XCTAssertTrue(page.choices.contains("Custom.WIDTHxHEIGHT")) + XCTAssertFalse(page.choices.contains("*A4")) + + let slot = listings[1] + XCTAssertEqual(slot.key, "InputSlot") + XCTAssertEqual(slot.choices, ["Auto", "Main", "Photo", "Rear"]) + XCTAssertEqual(slot.defaultChoice, "Main") + } + + func testCapabilities() { + let service = CupsService() + let listings = CupsParsers.lpoptionsList(lpoptionsL) + let caps = service.capabilities(from: listings, ppd: nil) + + XCTAssertEqual(caps.trays, [ + PrinterTray(id: 1, name: "Auto"), + PrinterTray(id: 2, name: "Main"), + PrinterTray(id: 3, name: "Photo"), + PrinterTray(id: 4, name: "Rear"), + ]) + XCTAssertEqual(caps.paperSizes.first, PrinterPaperSize(id: 1, name: "3.5x5")) + XCTAssertEqual(caps.paperSizes.count, 10) + XCTAssertEqual(caps.mediaTypes.map(\.id), [ + "Stationery", "PhotographicHighGloss", "Photographic", + "PhotographicMatte", "Envelope", + ]) + XCTAssertTrue(caps.supportsOrientation) + } + + func testPpdLabels() { + let ppd = """ + *CNIJMediaType 42/Photo Paper Plus Semi-gloss: "<>" + *CNIJMediaType 0/Plain Paper: "" + *en_US.CNIJMediaType 13/Envelope: "" + """ + let labels = CupsParsers.ppdChoiceLabels(ppd, key: "CNIJMediaType") + XCTAssertEqual(labels["42"], "Photo Paper Plus Semi-gloss") + XCTAssertEqual(labels["0"], "Plain Paper") + XCTAssertEqual(labels["13"], "Envelope") + } + + func testMediaTypeKey() { + XCTAssertEqual(CupsParsers.detectMediaTypeKey( + optionKeys: ["MediaType", "CNIJMediaType"]), "CNIJMediaType") + XCTAssertEqual(CupsParsers.detectMediaTypeKey( + optionKeys: ["PageSize", "MediaType"]), "MediaType") + XCTAssertNil(CupsParsers.detectMediaTypeKey(optionKeys: ["PageSize"])) + } + + func testDriverBypass() { + func pair(_ keys: Set) -> String? { + CupsParsers.detectDriverColorBypass(optionKeys: keys) + .map { "\($0.key)=\($0.value)" } + } + XCTAssertEqual(pair(["CNIJIntent2", "CNIJIntent"]), "CNIJIntent2=4") + XCTAssertEqual(pair(["CNIJIntent"]), "CNIJIntent=4") + XCTAssertEqual(pair(["EPIJ_CCor", "EPIJ_CMat"]), "EPIJ_CCor=0") + XCTAssertEqual(pair(["EPIJ_CMat"]), "EPIJ_CMat=3") + XCTAssertEqual(pair(["StpColorCorrection"]), "StpColorCorrection=Uncorrected") + XCTAssertEqual(pair(["ColorCorrection"]), "ColorCorrection=Uncorrected") + XCTAssertEqual(pair(["EpsonColorMode"]), "EpsonColorMode=Off") + XCTAssertNil(pair(["PageSize"])) + } +} diff --git a/Tests/ICCeryCoreTests/DriftAlertTests.swift b/Tests/ICCeryCoreTests/DriftAlertTests.swift new file mode 100644 index 0000000..11fb145 --- /dev/null +++ b/Tests/ICCeryCoreTests/DriftAlertTests.swift @@ -0,0 +1,95 @@ +import Foundation +import XCTest +@testable import ICCeryCore + +final class DriftAlertTests: XCTestCase { + + func testNotEnough() { + let records = [ + record(avg: 4.0, at: 1000) + ] + XCTAssertNil(DriftAlert.compute(from: records)) + } + + func testOneHourApart() { + let records = [ + record(avg: 4.0, at: 1000), + record(avg: 5.0, at: 4600) + ] + XCTAssertNotNil(DriftAlert.compute(from: records)) + } + + func testSameDayUnderHour() { + let records = [ + record(avg: 4.0, at: 1000), + record(avg: 5.0, at: 2000) + ] + XCTAssertNil(DriftAlert.compute(from: records)) + } + + func testDistinctDays() { + let day1 = record(avg: 4.0, at: 0) + let day2 = record(avg: 5.0, at: 86400 + 1000) + XCTAssertNotNil(DriftAlert.compute(from: [day1, day2])) + } + + func testNonPoor() { + let records = [ + record(avg: 1.0, at: 0), + record(avg: 1.5, at: 86400) + ] + XCTAssertNil(DriftAlert.compute(from: records)) + } + + func testNonPoorBreaksRun() { + let records = [ + record(avg: 4.0, at: 0), // poor + record(avg: 4.5, at: 86400), // poor, far apart + record(avg: 1.0, at: 90000), // good — breaks the run + record(avg: 4.0, at: 92000) // poor, recent but close to previous poor + ] + XCTAssertNil(DriftAlert.compute(from: records)) + } + + func testOnlySuffixRun() { + let records = [ + record(avg: 4.0, at: 0), // poor + record(avg: 4.5, at: 18000), // poor, > 1h from first + record(avg: 1.0, at: 20000), // good — breaks the run + record(avg: 4.0, at: 25000), // poor + record(avg: 4.5, at: 26000) // poor, < 1h and same day + ] + XCTAssertNil(DriftAlert.compute(from: records)) + } + + func testSuffixRunAlerts() { + let records = [ + record(avg: 1.0, at: 0), // good + record(avg: 4.0, at: 1000), // poor + record(avg: 4.5, at: 4600) // poor, 1h after previous + ] + XCTAssertNotNil(DriftAlert.compute(from: records)) + } + + func testSingleFinalPoor() { + let records = [ + record(avg: 1.0, at: 0), + record(avg: 4.0, at: 86400) + ] + XCTAssertNil(DriftAlert.compute(from: records)) + } + + private func record(avg: Double, at offset: TimeInterval) -> VerificationRecord { + VerificationRecord( + id: "vr-\(Int(offset))", + profileName: "p", + printerName: "", + avgDE: avg, + maxDE: avg, + rmsDE: avg, + patchCount: 1, + status: VerificationStatus.from(avgDE: avg), + timestamp: Date(timeIntervalSince1970: offset) + ) + } +} diff --git a/Tests/ICCeryCoreTests/FileHelpersTests.swift b/Tests/ICCeryCoreTests/FileHelpersTests.swift new file mode 100644 index 0000000..32a99a8 --- /dev/null +++ b/Tests/ICCeryCoreTests/FileHelpersTests.swift @@ -0,0 +1,126 @@ +import XCTest +import Foundation +@testable import ICCeryCore + +private func tempDir(_ name: String = UUID().uuidString) throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-files-\(name)") + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url +} + +private func touch(_ url: URL, _ contents: String = "x") throws { + try contents.write(to: url, atomically: true, encoding: .utf8) +} + +final class PathSecurityTests: XCTestCase { + func testRejectsTraversalAndSeparators() { + for bad in ["a/b", "a\\b", "..", "a/../b", "", "..x"] { + XCTAssertFalse(PathSecurity.isValidBasename(bad)) + XCTAssertThrowsError(try PathSecurity.sanitizeBasename(bad)) { error in + XCTAssertTrue(error is PathSecurity.Error) + } + } + } + + func testAcceptsNormalNames() { + for good in ["target", "My Target 01", "écheneau-ümläut", "a.b"] { + XCTAssertTrue(PathSecurity.isValidBasename(good)) + } + } + + func testResolveSafeCwdPrefersExplicit() throws { + let dir = try tempDir() + XCTAssertEqual(PathSecurity.resolveSafeCwd(dir), dir) + } + + func testResolveSafeCwdNeverReturnsNil() { + let missing = URL(fileURLWithPath: "/nonexistent-\(UUID().uuidString)") + let resolved = PathSecurity.resolveSafeCwd(missing) + XCTAssertTrue(FileManager.default.fileExists(atPath: resolved.path)) + } +} + +final class AtomicFileWriterTests: XCTestCase { + func testWritesAndLeavesNoTmp() throws { + let dir = try tempDir() + let url = dir.appendingPathComponent("state.json") + try AtomicFileWriter.write(Data("{\"a\":1}".utf8), to: url) + XCTAssertEqual(try String(contentsOf: url, encoding: .utf8), "{\"a\":1}") + XCTAssertFalse(FileManager.default.fileExists(atPath: url.appendingPathExtension("tmp").path)) + } + + func testOverwritesExistingAtomically() throws { + let dir = try tempDir() + let url = dir.appendingPathComponent("f.txt") + try AtomicFileWriter.write("one", to: url) + try AtomicFileWriter.write("two-longer", to: url) + XCTAssertEqual(try String(contentsOf: url, encoding: .utf8), "two-longer") + } + + func testCreatesParentDirs() throws { + let dir = try tempDir() + let url = dir.appendingPathComponent("a/b/c/deep.json") + try AtomicFileWriter.write("{}", to: url) + XCTAssertTrue(FileManager.default.fileExists(atPath: url.path)) + } +} + +final class ArtefactProbeTests: XCTestCase { + func testVerifyProgression() throws { + let dir = try tempDir() + var v = ArtefactProbe.verify(basename: "t", cwd: dir) + XCTAssertEqual(v, StageArtefacts()) + + try touch(dir.appendingPathComponent("t.ti1")) + v = ArtefactProbe.verify(basename: "t", cwd: dir) + XCTAssertTrue(v.stage1Complete && !v.stage2Complete && !v.stage3Complete) + + try touch(dir.appendingPathComponent("t.ti2")) + try touch(dir.appendingPathComponent("t.ti3")) + v = ArtefactProbe.verify(basename: "t", cwd: dir) + XCTAssertTrue(v.stage2Complete && v.stage3Complete && !v.stage4Complete) + + try touch(dir.appendingPathComponent("t.icc")) + v = ArtefactProbe.verify(basename: "t", cwd: dir) + XCTAssertTrue(v.stage4Complete && v.profilePath?.pathExtension == "icc") + } + + func testIcmWinsOverIcc() throws { + let dir = try tempDir() + try touch(dir.appendingPathComponent("p.icc")) + try touch(dir.appendingPathComponent("p.icm")) + let profile = ArtefactProbe.resolveProfile(basename: "p", cwd: dir) + XCTAssertEqual(profile?.pathExtension, "icm") + } + + func testEnumeratesPassesPagesAndCAL() throws { + let dir = try tempDir() + for name in [ + "t.ti1", "t.ti2", "t.tif", "t.2.tif", "t_03.tif", + "t.ti3", "t_pass1.ti3", "t_pass2.ti3", + "t.icc", "t.gam", + "CAL_t.ti1", "CAL_t.cal", + // must NOT match: + "other.ti1", "t.txt", "CAL_other.ti1", + ] { try touch(dir.appendingPathComponent(name)) } + + let names = ArtefactProbe.existingArtefacts(basename: "t", cwd: dir) + .map(\.lastPathComponent) + for expected in [ + "t.ti1", "t.ti2", "t.tif", "t.2.tif", "t_03.tif", + "t.ti3", "t_pass1.ti3", "t_pass2.ti3", + "t.icc", "t.gam", "CAL_t.ti1", "CAL_t.cal", + ] { + XCTAssertTrue(names.contains(expected), "missing \(expected)") + } + XCTAssertFalse(names.contains("other.ti1")) + XCTAssertFalse(names.contains("t.txt")) + XCTAssertFalse(names.contains("CAL_other.ti1")) + } + + func testEmptyDirReturnsEmpty() throws { + let dir = try tempDir() + XCTAssertTrue(ArtefactProbe.existingArtefacts(basename: "x", cwd: dir).isEmpty) + } +} diff --git a/Tests/ICCeryCoreTests/GamutContainmentTests.swift b/Tests/ICCeryCoreTests/GamutContainmentTests.swift new file mode 100644 index 0000000..c69cf8b --- /dev/null +++ b/Tests/ICCeryCoreTests/GamutContainmentTests.swift @@ -0,0 +1,85 @@ +import Foundation +import XCTest +@testable import ICCeryCore + +/// ``GamutGeometry`` containment and volume goldens on the bundled +/// `sRGB.gam` reference mesh (issue #147). No GPU involved. +final class GamutContainmentTests: XCTestCase { + + /// Returns the bundled real `sRGB.gam` in `Resources/Argyll/reference_gamuts`. + private var bundledSRGBGamURL: URL { + let bundle = Bundle.main + let resource = bundle.resourceURL ?? bundle.bundleURL + return resource.appendingPathComponent("Argyll/reference_gamuts/sRGB.gam") + } + + private func loadSRGB() throws -> GamutMesh { + try GamutMeshParser.parse(url: bundledSRGBGamURL) + } + + func testNeutralMidGreyIsInsideSRGB() throws { + let mesh = try loadSRGB() + XCTAssertEqual( + GamutGeometry.containment(of: LabColor(l: 50, a: 0, b: 0), in: mesh), + .inside) + } + + func testSaturatedColourIsOutsideSRGB() throws { + let mesh = try loadSRGB() + XCTAssertEqual( + GamutGeometry.containment(of: LabColor(l: 50, a: 80, b: 80), in: mesh), + .outside) + } + + func testVolumeIsFiniteAndPositiveOnBundledSRGB() throws { + let mesh = try loadSRGB() + let volume = GamutGeometry.volume(of: mesh) + XCTAssertTrue(volume.isFinite) + XCTAssertGreaterThan(volume, 0) + } + + func testVertexOnlyMeshReportsUnknown() { + let mesh = GamutMesh( + vertices: [ + GamutVertex(lab: LabColor(l: 50, a: 0, b: 0), rgb: DisplayRGB(r: 0.5, g: 0.5, b: 0.5)), + ], + faces: []) + XCTAssertEqual( + GamutGeometry.containment(of: LabColor(l: 50, a: 0, b: 0), in: mesh), + .unknown) + XCTAssertEqual(GamutGeometry.volume(of: mesh), 0) + } + + func testKnownCubeFixture() throws { + // Unit cube centred at Lab (50, 0, 0): a*,b* ∈ ±10, L* ∈ 40...60. + // Two triangles per face, outward winding. + let lab = { (l: Double, a: Double, b: Double) in + GamutVertex(lab: LabColor(l: l, a: a, b: b), rgb: DisplayRGB(r: 0, g: 0, b: 0)) + } + // Corners in (a, L, b) space. + let c = [ + lab(40, -10, -10), lab(40, 10, -10), lab(40, 10, 10), lab(40, -10, 10), // bottom + lab(60, -10, -10), lab(60, 10, -10), lab(60, 10, 10), lab(60, -10, 10), // top + ] + let quad = { (a: UInt32, b: UInt32, c: UInt32, d: UInt32) in + [GamutTriangle(a: a, b: b, c: c), GamutTriangle(a: a, b: c, c: d)] + } + var faces: [GamutTriangle] = [] + faces += quad(0, 3, 2, 1) // bottom (y=40) + faces += quad(4, 5, 6, 7) // top (y=60) + faces += quad(0, 1, 5, 4) // z=-10 + faces += quad(3, 7, 6, 2) // z=+10 + faces += quad(1, 2, 6, 5) // x=+10 + faces += quad(0, 4, 7, 3) // x=-10 + let mesh = GamutMesh(vertices: c, faces: faces) + + XCTAssertEqual( + GamutGeometry.containment(of: LabColor(l: 50, a: 0, b: 0), in: mesh), + .inside) + XCTAssertEqual( + GamutGeometry.containment(of: LabColor(l: 50, a: 20, b: 0), in: mesh), + .outside) + // 20 × 20 × 20 Lab-cube. + XCTAssertEqual(GamutGeometry.volume(of: mesh), 8000, accuracy: 1) + } +} diff --git a/Tests/ICCeryCoreTests/GamutGeometryBuilderTests.swift b/Tests/ICCeryCoreTests/GamutGeometryBuilderTests.swift new file mode 100644 index 0000000..d3f1b31 --- /dev/null +++ b/Tests/ICCeryCoreTests/GamutGeometryBuilderTests.swift @@ -0,0 +1,31 @@ +import XCTest +import SceneKit +import ICCeryCore +@testable import ICCery + +/// ``GamutSceneGeometryBuilder`` edge-case tests. +@MainActor +final class GamutGeometryBuilderTests: XCTestCase { + + func testDropsOutOfBoundsFaces() { + let white = GamutVertex( + lab: LabColor(l: 100, a: 0, b: 0), + rgb: DisplayRGB(r: 1, g: 1, b: 1) + ) + let black = GamutVertex( + lab: LabColor(l: 0, a: 0, b: 0), + rgb: DisplayRGB(r: 0, g: 0, b: 0) + ) + let mesh = GamutMesh( + vertices: [white, black], + faces: [ + GamutTriangle(a: 0, b: 1, c: 0), + GamutTriangle(a: 0, b: 1, c: 99) + ] + ) + + let (_, element) = GamutSceneGeometryBuilder.geometry(for: mesh) + + XCTAssertEqual(element.primitiveCount, 1, "Only the in-bounds face should be in the index buffer") + } +} diff --git a/Tests/ICCeryCoreTests/GamutMeshParserTests.swift b/Tests/ICCeryCoreTests/GamutMeshParserTests.swift new file mode 100644 index 0000000..cb54693 --- /dev/null +++ b/Tests/ICCeryCoreTests/GamutMeshParserTests.swift @@ -0,0 +1,161 @@ +import Foundation +import XCTest +@testable import ICCeryCore + +/// ``GamutMeshParser`` acceptance + edge-case tests. +final class GamutMeshParserTests: XCTestCase { + + /// Returns the bundled real `sRGB.gam` in `Resources/Argyll/reference_gamuts`. + private var bundledSRGBGamURL: URL { + let bundle = Bundle.main + let resource = bundle.resourceURL ?? bundle.bundleURL + return resource.appendingPathComponent("Argyll/reference_gamuts/sRGB.gam") + } + + func testParsesBundledSRGB() throws { + let mesh = try GamutMeshParser.parse(url: bundledSRGBGamURL) + + XCTAssertEqual(mesh.vertices.count, 448, "sRGB.gam has 448 vertices") + XCTAssertEqual(mesh.faces.count, 892, "sRGB.gam has 892 faces") + } + + func testDiscardsVertexNo() throws { + let text = """ + GAMUT + NUMBER_OF_FIELDS 4 + BEGIN_DATA_FORMAT + VERTEX_NO LAB_L LAB_A LAB_B + END_DATA_FORMAT + NUMBER_OF_SETS 4 + BEGIN_DATA + 100 10.0 20.0 30.0 + 50 20.0 30.0 40.0 + 2 30.0 40.0 50.0 + 7 40.0 50.0 60.0 + END_DATA + NUMBER_OF_FIELDS 3 + BEGIN_DATA_FORMAT + VERTEX_0 VERTEX_1 VERTEX_2 + END_DATA_FORMAT + NUMBER_OF_SETS 2 + BEGIN_DATA + 0 1 2 + 1 2 3 + END_DATA + """ + + let mesh = try GamutMeshParser.parse(text: text) + + XCTAssertEqual(mesh.vertices.count, 4) + XCTAssertEqual(mesh.faces.count, 2) + XCTAssertEqual(mesh.vertices[0].lab, LabColor(l: 10, a: 20, b: 30)) + XCTAssertEqual(mesh.vertices[3].lab, LabColor(l: 40, a: 50, b: 60)) + } + + func testIgnoresComments() throws { + let text = """ + # Header comment + NUMBER_OF_FIELDS 4 + BEGIN_DATA_FORMAT + VERTEX_NO LAB_L LAB_A LAB_B + END_DATA_FORMAT + NUMBER_OF_SETS 2 + BEGIN_DATA + 0 10.0 20.0 30.0 + # inline comment + 1 20.0 30.0 40.0 + END_DATA + # another comment + NUMBER_OF_FIELDS 3 + BEGIN_DATA_FORMAT + VERTEX_0 VERTEX_1 VERTEX_2 + END_DATA_FORMAT + NUMBER_OF_SETS 1 + BEGIN_DATA + 0 1 0 + END_DATA + """ + + let mesh = try GamutMeshParser.parse(text: text) + XCTAssertEqual(mesh.vertices.count, 2) + XCTAssertEqual(mesh.faces.count, 1) + } + + func testRemapsCoordinates() throws { + let text = """ + NUMBER_OF_FIELDS 4 + BEGIN_DATA_FORMAT + VERTEX_NO LAB_L LAB_A LAB_B + END_DATA_FORMAT + NUMBER_OF_SETS 1 + BEGIN_DATA + 0 50.0 -20.0 80.0 + END_DATA + """ + + let mesh = try GamutMeshParser.parse(text: text) + XCTAssertEqual(mesh.vertices.first?.position, SIMD3(-20, 50, 80)) + } + + func testComputesVertexColor() throws { + let text = """ + NUMBER_OF_FIELDS 4 + BEGIN_DATA_FORMAT + VERTEX_NO LAB_L LAB_A LAB_B + END_DATA_FORMAT + NUMBER_OF_SETS 1 + BEGIN_DATA + 0 100.0 0.0 0.0 + END_DATA + """ + + let mesh = try GamutMeshParser.parse(text: text) + let white = try XCTUnwrap(mesh.vertices.first).rgb + XCTAssertTrue(white.r > 0.95) + XCTAssertTrue(white.g > 0.95) + XCTAssertTrue(white.b > 0.95) + } + + func testDropsOutOfBoundsFaces() throws { + let text = """ + NUMBER_OF_FIELDS 4 + BEGIN_DATA_FORMAT + VERTEX_NO LAB_L LAB_A LAB_B + END_DATA_FORMAT + NUMBER_OF_SETS 2 + BEGIN_DATA + 0 10.0 0.0 0.0 + 1 20.0 0.0 0.0 + END_DATA + NUMBER_OF_FIELDS 3 + BEGIN_DATA_FORMAT + VERTEX_0 VERTEX_1 VERTEX_2 + END_DATA_FORMAT + NUMBER_OF_SETS 2 + BEGIN_DATA + 0 1 0 + 0 1 99 + END_DATA + """ + + let mesh = try GamutMeshParser.parse(text: text) + XCTAssertEqual(mesh.faces.count, 1) + } + + func testThrowsOnEmptyFile() { + XCTAssertThrowsError(try GamutMeshParser.parse(text: "")) { error in + guard case GamutMeshParseError.noDataBlock = error else { + return XCTFail("Expected GamutMeshParseError.noDataBlock, got \(error)") + } + } + } + + func testThrowsWhenMissing() { + let url = URL(fileURLWithPath: "/nonexistent/path/to/mesh.gam") + XCTAssertThrowsError(try GamutMeshParser.parse(url: url)) { error in + guard case GamutMeshParseError.missingFile = error else { + return XCTFail("Expected GamutMeshParseError.missingFile, got \(error)") + } + } + } +} diff --git a/Tests/ICCeryCoreTests/GamutViewModelTests.swift b/Tests/ICCeryCoreTests/GamutViewModelTests.swift new file mode 100644 index 0000000..f93108b --- /dev/null +++ b/Tests/ICCeryCoreTests/GamutViewModelTests.swift @@ -0,0 +1,137 @@ +import Foundation +import Metal +import XCTest +@testable import ICCeryCore +@testable import ICCery + +/// Issue #147 — `GamutViewModel` compare-slot behaviour: failed or +/// missing compare meshes are info, never fatal (#24); sRGB always stays. +@MainActor +final class GamutViewModelTests: XCTestCase { + + /// Bundled root that has `reference_gamuts/sRGB.gam` but **no** tool + /// binaries, so `iccgamut` spawns deterministically fail. + private func bundledRootWithoutTools() throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-gamut-vm-\(UUID().uuidString)") + let gamutDir = root.appendingPathComponent("reference_gamuts") + try FileManager.default.createDirectory( + at: gamutDir, withIntermediateDirectories: true) + let bundle = Bundle.main.resourceURL ?? Bundle.main.bundleURL + try FileManager.default.copyItem( + at: bundle.appendingPathComponent("Argyll/reference_gamuts/sRGB.gam"), + to: gamutDir.appendingPathComponent("sRGB.gam")) + return root + } + + private func makeViewModel(root: URL? = nil) throws -> GamutViewModel { + let env = try TestAppEnvironment.make(bundledArgyllRoot: root) + return GamutViewModel(environment: env.environment) + } + + func testInitialLoadHasSRGBAndFacesStatus() async throws { + let vm = try makeViewModel() + await vm.awaitInitialLoad() + + XCTAssertNotNil(vm.layer(id: GamutViewModel.srgbLayerID)) + XCTAssertTrue(vm.status.contains("faces"), "status: \(vm.status)") + } + + /// #147 — `viewerUnavailable` is decided before any `SCNView` is + /// mounted: it must exactly mirror Metal presence on this host. + func testViewerUnavailableMirrorsMetalAvailability() async throws { + let vm = try makeViewModel() + XCTAssertEqual( + vm.viewerUnavailable, + MTLCreateSystemDefaultDevice() == nil) + } + + func testMissingCompareGamLeavesSRGBAndSetsNotice() async throws { + let vm = try makeViewModel() + await vm.awaitInitialLoad() + + await vm.loadCompareGam( + url: URL(fileURLWithPath: "/nonexistent/compare.gam")) + + XCTAssertNotNil(vm.layer(id: GamutViewModel.srgbLayerID)) + XCTAssertNil(vm.layer(id: GamutViewModel.compareLayerID)) + XCTAssertNotNil(vm.noticeText) + } + + func testFailedIccgamutLeavesSRGBAndSetsNotice() async throws { + // bundledRootWithoutTools has no macos-universal/iccgamut. + let vm = try makeViewModel(root: bundledRootWithoutTools()) + await vm.awaitInitialLoad() + XCTAssertNotNil(vm.layer(id: GamutViewModel.srgbLayerID)) + + let profile = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-gamut-icc-\(UUID().uuidString).icc") + try Data("MOCK_ICC".utf8).write(to: profile) + defer { try? FileManager.default.removeItem(at: profile) } + + await vm.loadCompareProfile(url: profile) + + XCTAssertNotNil(vm.layer(id: GamutViewModel.srgbLayerID)) + XCTAssertNil(vm.layer(id: GamutViewModel.compareLayerID)) + XCTAssertNotNil(vm.noticeText) + } + + func testThirdProfileReplacesCompareSlot() async throws { + let vm = try makeViewModel() + await vm.awaitInitialLoad() + + let bundle = Bundle.main.resourceURL ?? Bundle.main.bundleURL + let srgb = bundle.appendingPathComponent("Argyll/reference_gamuts/sRGB.gam") + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-gamut-cmp-\(UUID().uuidString)") + try FileManager.default.createDirectory( + at: dir, withIntermediateDirectories: true) + let first = dir.appendingPathComponent("first.gam") + let second = dir.appendingPathComponent("second.gam") + try FileManager.default.copyItem(at: srgb, to: first) + try FileManager.default.copyItem(at: srgb, to: second) + defer { try? FileManager.default.removeItem(at: dir) } + + await vm.loadCompareGam(url: first) + XCTAssertNil(vm.noticeText) + XCTAssertEqual(vm.layer(id: GamutViewModel.compareLayerID)?.displayName, "first") + + await vm.loadCompareGam(url: second) + XCTAssertEqual(vm.layer(id: GamutViewModel.compareLayerID)?.displayName, "second") + XCTAssertEqual( + vm.layers.filter { $0.role == .profileB }.count, 1, + "compare slot holds one profile") + XCTAssertNotNil(vm.noticeText) + XCTAssertNotNil(vm.layer(id: GamutViewModel.srgbLayerID)) + } + + func testRemoveCompareLeavesSRGB() async throws { + let vm = try makeViewModel() + await vm.awaitInitialLoad() + + let bundle = Bundle.main.resourceURL ?? Bundle.main.bundleURL + await vm.loadCompareGam( + url: bundle.appendingPathComponent("Argyll/reference_gamuts/sRGB.gam")) + XCTAssertNotNil(vm.layer(id: GamutViewModel.compareLayerID)) + + vm.removeCompare() + XCTAssertNil(vm.layer(id: GamutViewModel.compareLayerID)) + XCTAssertNotNil(vm.layer(id: GamutViewModel.srgbLayerID)) + } + + func testInspectLabRunsContainmentPerLayer() async throws { + let vm = try makeViewModel() + await vm.awaitInitialLoad() + + vm.labEntryL = "50" + vm.labEntryA = "0" + vm.labEntryB = "0" + XCTAssertTrue(vm.canInspectLab) + vm.inspectEnteredLab() + + let srgb = vm.inspectResults.first { $0.id == GamutViewModel.srgbLayerID } + XCTAssertEqual(srgb?.containment, .inside) + XCTAssertTrue(vm.inspectIsApproximate) + XCTAssertNotNil(vm.inspectSwatch) + } +} diff --git a/Tests/ICCeryCoreTests/ICCeryProjectTests.swift b/Tests/ICCeryCoreTests/ICCeryProjectTests.swift new file mode 100644 index 0000000..e4122f0 --- /dev/null +++ b/Tests/ICCeryCoreTests/ICCeryProjectTests.swift @@ -0,0 +1,203 @@ +import Foundation +import XCTest +@testable import ICCeryCore + +/// Issue #149 — `.icceryproj` schema: snake_case keys, strict +/// `schema_version == 1`, and save-time refusal for empty/illegal +/// basename and empty/unsafe cwd (#59/#60/R11). +final class ICCeryProjectTests: XCTestCase { + + private func tempDir() throws -> URL { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-proj-\(UUID().uuidString)") + try FileManager.default.createDirectory( + at: dir, withIntermediateDirectories: true) + return dir + } + + private func makeProject( + basename: String = "run-1", + cwd: String = "/tmp" + ) -> ICCeryProject { + ICCeryProject( + name: "Run One", + basename: basename, + cwd: cwd, + profileBasename: "run-1", + printerID: "Mock_Queue", + printerDisplayName: "Mock Queue", + mediaRecipeID: "recipe-1", + presetID: "preset-std-rgb", + calibrationURL: "/tmp/r.cal", + lastVerification: VerificationSnapshot( + date: Date(timeIntervalSince1970: 1_700_000_000), + avgDE00: 0.8, maxDE00: 2.1, + status: "excellent", profileFilename: "run-1.icc")) + } + + // MARK: - Round trip / keys + + func testSnakeCaseRoundTrip() throws { + let dir = try tempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let url = dir.appendingPathComponent("p.icceryproj") + + let project = makeProject() + try project.save(to: url) + let loaded = try ICCeryProject.load(from: url) + + XCTAssertEqual(loaded.schemaVersion, 1) + XCTAssertEqual(loaded.basename, "run-1") + XCTAssertEqual(loaded.cwd, "/tmp") + XCTAssertEqual(loaded.profileBasename, "run-1") + XCTAssertEqual(loaded.printerID, "Mock_Queue") + XCTAssertEqual(loaded.mediaRecipeID, "recipe-1") + XCTAssertEqual(loaded.presetID, "preset-std-rgb") + XCTAssertEqual(loaded.calibrationURL, "/tmp/r.cal") + XCTAssertEqual(loaded.lastVerification?.avgDE00, 0.8) + XCTAssertEqual(loaded.lastVerification?.maxDE00, 2.1) + XCTAssertEqual(loaded.lastVerification?.status, "excellent") + XCTAssertEqual(loaded.lastVerification?.profileFilename, "run-1.icc") + + let raw = try String(contentsOf: url, encoding: .utf8) + for key in [ + "\"schema_version\"", "\"profile_basename\"", + "\"printer_id\"", "\"printer_display_name\"", + "\"media_recipe_id\"", "\"preset_id\"", + "\"calibration_url\"", "\"last_verification\"", + "\"avg_de00\"", "\"max_de00\"", "\"profile_filename\"", + ] { + XCTAssertTrue(raw.contains(key), "missing key \(key)") + } + } + + func testOptionalFieldsDecodeWhenAbsent() throws { + let dir = try tempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let url = dir.appendingPathComponent("min.icceryproj") + let json = """ + { + "schema_version": 1, + "name": "Minimal", + "basename": "abc", + "cwd": "/tmp", + "updated": "2026-09-13T00:00:00Z" + } + """ + try json.write(to: url, atomically: true, encoding: .utf8) + + let loaded = try ICCeryProject.load(from: url) + XCTAssertEqual(loaded.basename, "abc") + XCTAssertNil(loaded.mediaRecipeID) + XCTAssertNil(loaded.presetID) + XCTAssertNil(loaded.lastVerification) + XCTAssertNil(loaded.calibrationURL) + } + + // MARK: - Schema gate + + func testSchemaVersion2Throws() throws { + let dir = try tempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let url = dir.appendingPathComponent("v2.icceryproj") + try """ + {"schema_version": 2, "name": "x", "basename": "abc", + "cwd": "/tmp", "future_field": [1, 2]} + """.write(to: url, atomically: true, encoding: .utf8) + + XCTAssertThrowsError(try ICCeryProject.load(from: url)) { error in + guard case ICCeryProject.ValidationError.unsupportedSchema(2) + = error else { + return XCTFail("expected unsupportedSchema, got \(error)") + } + XCTAssertEqual( + error.localizedDescription, + "This project file is not schema 1.") + } + } + + func testMissingSchemaVersionThrows() throws { + let dir = try tempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let url = dir.appendingPathComponent("noversion.icceryproj") + try "{\"basename\": \"abc\", \"cwd\": \"/tmp\"}" + .write(to: url, atomically: true, encoding: .utf8) + XCTAssertThrowsError(try ICCeryProject.load(from: url)) + } + + // MARK: - Validation + + func testEmptyBasenameRefusesSave() throws { + let dir = try tempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let url = dir.appendingPathComponent("p.icceryproj") + XCTAssertThrowsError( + try makeProject(basename: "").save(to: url) + ) { error in + XCTAssertEqual( + error as? ICCeryProject.ValidationError, .emptyBasename) + } + XCTAssertFalse(FileManager.default.fileExists(atPath: url.path)) + } + + func testIllegalBasenameRefusesSave() throws { + let dir = try tempDir() + defer { try? FileManager.default.removeItem(at: dir) } + for bad in ["a/b", "a\\b", "..", "x..y"] { + XCTAssertThrowsError( + try makeProject(basename: bad).save( + to: dir.appendingPathComponent("p.icceryproj")) + ) { error in + guard case ICCeryProject.ValidationError.invalidBasename + = error else { + return XCTFail("expected invalidBasename, got \(error)") + } + } + } + } + + func testEmptyAndRelativeCwdRefuseSave() throws { + let dir = try tempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let url = dir.appendingPathComponent("p.icceryproj") + XCTAssertThrowsError(try makeProject(cwd: "").save(to: url)) { error in + XCTAssertEqual( + error as? ICCeryProject.ValidationError, .emptyCwd) + } + XCTAssertThrowsError( + try makeProject(cwd: "relative/dir").save(to: url) + ) { error in + guard case ICCeryProject.ValidationError.unsafeCwd = error else { + return XCTFail("expected unsafeCwd, got \(error)") + } + } + XCTAssertFalse(FileManager.default.fileExists(atPath: url.path)) + } + + func testIllegalBasenameRefusesOpen() throws { + let dir = try tempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let url = dir.appendingPathComponent("bad.icceryproj") + try """ + {"schema_version": 1, "basename": "a/b", "cwd": "/tmp"} + """.write(to: url, atomically: true, encoding: .utf8) + XCTAssertThrowsError(try ICCeryProject.load(from: url)) { error in + guard case ICCeryProject.ValidationError.invalidBasename + = error else { + return XCTFail("expected invalidBasename, got \(error)") + } + } + } + + func testEmptyNameFallsBackToBasename() throws { + let project = try ICCeryProject( + name: "", basename: "stem", cwd: "/tmp").validated() + XCTAssertEqual(project.name, "stem") + } + + func testMissingFileThrowsOnOpen() { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("gone-\(UUID().uuidString).icceryproj") + XCTAssertThrowsError(try ICCeryProject.load(from: url)) + } +} diff --git a/Tests/ICCeryCoreTests/IccgamutArgsTests.swift b/Tests/ICCeryCoreTests/IccgamutArgsTests.swift new file mode 100644 index 0000000..86916a9 --- /dev/null +++ b/Tests/ICCeryCoreTests/IccgamutArgsTests.swift @@ -0,0 +1,14 @@ +import Foundation +import XCTest +@testable import ICCeryCore + +final class IccgamutArgsTests: XCTestCase { + + func testDensityNotDirectory() throws { + let config = IccgamutConfig( + profileURL: URL(fileURLWithPath: "/tmp/MyProfile.icc") + ) + let args = try IccgamutArgs.build(config: config) + XCTAssertEqual(args, ["-v", "-d", "10", "/tmp/MyProfile.icc"]) + } +} diff --git a/Tests/ICCeryCoreTests/JSONFileStoreTests.swift b/Tests/ICCeryCoreTests/JSONFileStoreTests.swift new file mode 100644 index 0000000..4d244d5 --- /dev/null +++ b/Tests/ICCeryCoreTests/JSONFileStoreTests.swift @@ -0,0 +1,77 @@ +import Foundation +import XCTest +@testable import ICCeryCore + +final class JSONFileStoreTests: XCTestCase { + private func tempURL() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("json-store-\(UUID().uuidString).json") + } + + func testMissingFileDefaults() throws { + let store = JSONFileStore( + fileURL: tempURL(), + corrupt: .throwCorrupt, + defaultValue: { .default } + ) + XCTAssertEqual(try store.load(), .default) + } + + func testCorruptDefaults() throws { + let url = tempURL() + try "{ not json".write(to: url, atomically: true, encoding: .utf8) + let store = JSONFileStore( + fileURL: url, + corrupt: .replaceWithDefault, + defaultValue: { .default } + ) + XCTAssertEqual(try store.load(), .default) + let kept = try String(contentsOf: url, encoding: .utf8) + XCTAssertEqual(kept, "{ not json") + } + + func testCorruptThrows() throws { + let url = tempURL() + try "not json".write(to: url, atomically: true, encoding: .utf8) + let store = JSONFileStore<[Int]>( + fileURL: url, + corrupt: .throwCorrupt, + defaultValue: { [] } + ) + XCTAssertThrowsError(try store.load()) { error in XCTAssertTrue(error is DecodingError) } + let kept = try String(contentsOf: url, encoding: .utf8) + XCTAssertEqual(kept, "not json") + } + + func testPrettySorted() throws { + let url = tempURL() + let store = JSONFileStore( + fileURL: url, + corrupt: .replaceWithDefault, + defaultValue: { .default } + ) + try store.save(.default) + let text = try String(contentsOf: url, encoding: .utf8) + XCTAssertTrue(text.contains("\n")) + XCTAssertTrue(text.contains("\"delta_e_good_max\"")) + // Lexical key sorting: ascending order of top-level keys. + let keys = [ + "ask_before_overwrite_profile", + "calibration_stale_days", + "custom_presets", + "default_install_location", + "delta_e_good_max", + "delta_e_warning_max", + "enable_i1pro2_leds", + "open_color_panel_after_install", + ] + var lastIndex = text.startIndex + for key in keys { + guard let range = text.range(of: "\"\(key)\"", range: lastIndex.. = [] + ) throws -> [String] { + try LpArgs.build( + queue: queue, tiffPath: tiff, + options: options, optionKeys: optionKeys) + } + + func testHeader() throws { + let argv = try build() + XCTAssertEqual(Array(argv[0...1]), ["-d", queue]) + XCTAssertEqual(Array(argv[2...3]), ["-t", "ICCery Target - target_001.tif"]) + XCTAssertEqual(Array(argv[4...5]), ["-o", "AP_ColorMatchingMode=AP_ApplicationColorMatching"]) + XCTAssertEqual(Array(argv[6...7]), ["-o", "AP.ColorMatchingMode=AP_ApplicationColorMatching"]) + XCTAssertEqual(argv.last, tiff) + XCTAssertFalse(argv.contains { $0 == "raw" || $0 == "-o raw" }) + } + + func testNeverRaw() throws { + let argv = try build(options: PrintOptions( + cupsOptions: "raw=true MediaType=Photo")) + for (i, arg) in argv.enumerated() where arg == "-o" { + XCTAssertNotEqual(argv[i + 1], "raw") + XCTAssertNotEqual(argv[i + 1], "raw=true") + } + XCTAssertFalse(argv.contains { $0.hasPrefix("raw=") }) + XCTAssertTrue(argv.contains("MediaType=Photo")) + } + + func testCapturedReplay() throws { + let argv = try build(options: PrintOptions( + cupsOptions: "InputSlot=Rear MediaType=Photo")) + let rear = argv.firstIndex(of: "InputSlot=Rear")! + let apFirst = argv.firstIndex(of: + "AP_ColorMatchingMode=AP_ApplicationColorMatching")! + XCTAssertTrue(rear > apFirst) + } + + func testCapturedWinsMedia() throws { + let argv = try build( + options: PrintOptions( + mediaType: "Plain", + cupsOptions: "MediaType=Glossy"), + optionKeys: ["MediaType"]) + XCTAssertTrue(argv.contains("MediaType=Glossy")) + XCTAssertFalse(argv.contains("MediaType=Plain")) + } + + func testMediaDerived() throws { + let argv = try build( + options: PrintOptions(mediaType: "SemiGloss"), + optionKeys: ["CNIJMediaType", "MediaType"]) + // CNIJMediaType wins over MediaType in detection order. + XCTAssertTrue(argv.contains("CNIJMediaType=SemiGloss")) + XCTAssertFalse(argv.contains("MediaType=SemiGloss")) + } + + func testBypassRules() throws { + let withBypass = try build( + optionKeys: ["EPIJ_CMat"]) + XCTAssertTrue(withBypass.contains("EPIJ_CMat=3")) + + let captured = try build( + options: PrintOptions(cupsOptions: "EPIJ_CMat=1"), + optionKeys: ["EPIJ_CMat"]) + // Captured value kept, detection not re-applied. + XCTAssertEqual(captured.filter { $0.hasPrefix("EPIJ_CMat") }, ["EPIJ_CMat=1"]) + } + + func testOrientation() throws { + XCTAssertTrue(try build(options: PrintOptions(orientation: "portrait")) + .contains("orientation-requested=3")) + XCTAssertTrue(try build(options: PrintOptions(orientation: "landscape")) + .contains("orientation-requested=4")) + let capturedOrients = try build(options: PrintOptions( + orientation: "landscape", + cupsOptions: "orientation-requested=5")) + XCTAssertFalse(capturedOrients.contains("orientation-requested=4")) + XCTAssertTrue(capturedOrients.contains("orientation-requested=5")) + } + + func testPageSize() throws { + XCTAssertTrue(try build(options: PrintOptions(paperSize: "A4")) + .contains("PageSize=A4")) + let capturedSize = try build(options: PrintOptions( + paperSize: "A4", cupsOptions: "PageSize=Letter")) + XCTAssertFalse(capturedSize.contains("PageSize=A4")) + XCTAssertTrue(capturedSize.contains("PageSize=Letter")) + } + + func testSanitise() throws { + XCTAssertThrowsError(try build(options: PrintOptions( + cupsOptions: "InputSlot=Rear;rm -rf /"))) { error in + XCTAssertTrue(error is LpArgsError) + } + XCTAssertThrowsError(try build(options: PrintOptions( + cupsOptions: "InputSlot=Rear\nMediaType=Photo"))) { error in + XCTAssertTrue(error is LpArgsError) + } + XCTAssertThrowsError(try build(options: PrintOptions( + cupsOptions: "InputSlot=$(whoami)"))) { error in + XCTAssertTrue(error is LpArgsError) + } + } +} diff --git a/Tests/ICCeryCoreTests/MeasurementTests.swift b/Tests/ICCeryCoreTests/MeasurementTests.swift new file mode 100644 index 0000000..aac8cdb --- /dev/null +++ b/Tests/ICCeryCoreTests/MeasurementTests.swift @@ -0,0 +1,306 @@ +import Foundation +import XCTest +@testable import ICCeryCore + +final class InstrumentParserTests: XCTestCase { + + func testJson() throws { + let json = """ + { + "event": "instruments", + "devices": [ + {"port": 1, "name": "X-Rite i1Pro", "type": "usb"}, + {"port": 2, "name": "i1Pro 2", "type": "usb"}, + {"port": 3, "name": "i1iO Table", "type": "usb"} + ] + } + """ + let devices = try InstrumentParser.parse(json) + XCTAssertEqual(devices.count, 3) + XCTAssertEqual(devices[0].port, 1) + XCTAssertEqual(devices[0].name, "X-Rite i1Pro") + XCTAssertEqual(devices[2].port, 3) + } + + func testRegexFallback() throws { + let text = """ + 1: 'X-Rite i1Pro' on usb + 2: 'ColorMunki Smile' + """ + "\n" + let devices = try InstrumentParser.parse(text) + XCTAssertEqual(devices.count, 2) + XCTAssertEqual(devices[0].port, 1) + XCTAssertEqual(devices[1].name, "ColorMunki Smile") + } + + func testEmpty() throws { + XCTAssertTrue(try InstrumentParser.parse("").isEmpty) + } +} + +final class ChartreadArgsTests: XCTestCase { + + func testBaseline() throws { + let config = ChartreadConfig(basename: "target", selectedPort: 1) + let args = try ChartreadArgs.build(config: config) + XCTAssertEqual(args, ["-v", "-u", "target"]) + } + + func testPortArgument() throws { + let config = ChartreadConfig(basename: "target", selectedPort: 3) + let args = try ChartreadArgs.build(config: config) + XCTAssertEqual(args, ["-v", "-u", "-c", "3", "target"]) + } + + func testLeds() throws { + let config = ChartreadConfig( + basename: "target", + selectedPort: 2, + enableLEDs: true + ) + let args = try ChartreadArgs.build(config: config) + XCTAssertTrue(args.contains("-Y")) + XCTAssertTrue(args.contains("l")) + } + + func testAutoPort() throws { + let config = ChartreadConfig(basename: "target") + let args = try ChartreadArgs.build(config: config) + XCTAssertFalse(args.contains("-c")) + } +} + +final class ChartreadClassifierTests: XCTestCase { + + func testCalibration() { + let r = ChartreadClassifier.classify( + line: "Place instrument on calibration tile and hit [Space] to calibrate.", + previousState: .idle + ) + XCTAssertEqual(r.state, .calibrating) + } + + func testAwaitingStrip() { + let r = ChartreadClassifier.classify( + line: "Hit [Space] to read strip A", + previousState: .calibrating + ) + XCTAssertEqual(r.state, .awaitingStrip) + } + + func testDone() { + let r = ChartreadClassifier.classify( + line: "'d' if/when done", + previousState: .awaitingStrip + ) + XCTAssertEqual(r.state, .allStripsRead) + } + + func testPlaceSheet() { + let r = ChartreadClassifier.classify( + line: "Please place sheet 1 of 2 on the table", + previousState: .idle + ) + XCTAssertEqual(r.state, .tablePlaceSheet) + XCTAssertEqual(r.sheetNumber, 1) + XCTAssertEqual(r.sheetTotal, 2) + } + + func testLocatePatch() { + let r = ChartreadClassifier.classify( + line: "locate patch A1 with the sight,", + previousState: .tablePlaceSheet + ) + XCTAssertEqual(r.state, .tableAlign) + XCTAssertEqual(r.alignmentPatch, "A1") + } + + func testRemoveNotice() { + let r = ChartreadClassifier.classify( + line: "Please remove last sheet from table", + previousState: .tablePlaceSheet + ) + XCTAssertEqual(r.state, .tablePlaceSheet) + XCTAssertEqual(r.isRemoveSheetNotice, true) + } +} + +final class ChartreadRowTests: XCTestCase { + + func testDecode() throws { + let json = """ + {"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2, + "patch_count": 1, "patches": [ + {"id": "1", "loc": "A1", "is_pad": false, "device": [0, 50, 100], + "expected": {"Lab": [50, 0, 0]}, + "measured": {"Lab": [51, 1, -1]}} + ]} + """ + let row = try JSONDecoder().decode(ChartreadRow.self, from: Data(json.utf8)) + XCTAssertEqual(row.rowId, "A") + XCTAssertEqual(row.patchCount, 1) + XCTAssertEqual(row.patches[0].measured.lab?.l, 51) + } + + func testDecodeXYZAndLab() throws { + let json = """ + {"event": "row_complete", "row_id": "B", "row_index": 1, "total_rows": 2, + "patch_count": 1, "patches": [ + {"id": "7", "loc": "B7", "is_pad": false, "device": [10, 20, 30, 40], + "measured": {"XYZ": [30.5, 32.1, 25.9], "Lab": [63.4, 2.5, -8.2]}} + ]} + """ + let row = try JSONDecoder().decode(ChartreadRow.self, from: Data(json.utf8)) + let measured = row.patches[0].measured + XCTAssertEqual(measured.xyz, CIEXYZ(x: 30.5, y: 32.1, z: 25.9)) + XCTAssertEqual(measured.lab, CIELab(l: 63.4, a: 2.5, b: -8.2)) + } + + func testXyzWireEncoding() throws { + for color in [XYZColor(x: 1.5, y: 2.5, z: 3.5), CIEXYZ(x: 1.5, y: 2.5, z: 3.5)] { + let value = try JSONSerialization.jsonObject( + with: JSONEncoder().encode(color)) + XCTAssertEqual(value as? [Double], [1.5, 2.5, 3.5]) + } + } + + func testLabWireEncoding() throws { + for color in [LabColor(l: 50, a: -1, b: 2), CIELab(l: 50, a: -1, b: 2)] { + let value = try JSONSerialization.jsonObject( + with: JSONEncoder().encode(color)) + XCTAssertEqual(value as? [Double], [50, -1, 2]) + } + } + + func testPatchColorKeys() throws { + let color = PatchColor( + xyz: CIEXYZ(x: 10, y: 20, z: 30), + lab: CIELab(l: 55, a: 1, b: -2)) + let object = try JSONSerialization.jsonObject( + with: JSONEncoder().encode(color)) as? [String: Any] + XCTAssertEqual(object?["XYZ"] as? [Double], [10, 20, 30]) + XCTAssertEqual(object?["Lab"] as? [Double], [55, 1, -2]) + XCTAssertNil(object?["spectral"]) + } +} + +final class ColourMathTests: XCTestCase { + + func testWhiteLab() { + let white = XYZColor(x: 96.4212, y: 100.0, z: 82.5188) + let lab = LabColorMath.xyzToLab(white) + XCTAssertTrue(abs(lab.l - 100) < 0.5) + XCTAssertTrue(abs(lab.a) < 0.5) + XCTAssertTrue(abs(lab.b) < 0.5) + } + + func testLabToSRGB() { + let red = LabColor(l: 55, a: 80, b: 70) + let rgb = LabColorMath.labToSRGB(red) + XCTAssertTrue(rgb.r > 0.8) + XCTAssertTrue(rgb.g < 0.2) + XCTAssertTrue(rgb.b < 0.2) + } + + func testPadWhite() { + let white = LabColor(l: 95, a: 0, b: 0) + let rgb = LabColorMath.labToSRGB(white) + XCTAssertTrue(rgb.r > 0.9) + XCTAssertTrue(rgb.g > 0.9) + XCTAssertTrue(rgb.b > 0.9) + } + + func testCiede2000() { + let a = LabColor(l: 50, a: -1.3802, b: -84.2814) + let b = LabColor(l: 50, a: 0.0000, b: -82.7485) + XCTAssertTrue(abs(ColorDifference.deltaE00(a, b) - 1.00) < 0.001) + } + + func testClassify() { + XCTAssertEqual(ColorDifference.classify(deltaE: 0.5, goodMax: 2.0, warningMax: 5.0), .good) + XCTAssertEqual(ColorDifference.classify(deltaE: 3.0, goodMax: 2.0, warningMax: 5.0), .warning) + XCTAssertEqual(ColorDifference.classify(deltaE: 6.0, goodMax: 2.0, warningMax: 5.0), .bad) + } +} + +final class MeasurementArtefactTests: XCTestCase { + + private func makeCwd() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + func testDiscovery() throws { + let cwd = try makeCwd() + defer { try? FileManager.default.removeItem(at: cwd) } + + try "A".write(to: cwd.appendingPathComponent("target_pass3.ti3"), atomically: true, encoding: .utf8) + try "B".write(to: cwd.appendingPathComponent("target_pass1.ti3"), atomically: true, encoding: .utf8) + try "C".write(to: cwd.appendingPathComponent("target_pass10.ti3"), atomically: true, encoding: .utf8) + + let passes = MeasurementArtefacts.passSnapshots(basename: "target", cwd: cwd) + XCTAssertEqual(passes.map(\.lastPathComponent), ["target_pass1.ti3", "target_pass3.ti3", "target_pass10.ti3"]) + } + + func testSnapshotPromote() throws { + let cwd = try makeCwd() + defer { try? FileManager.default.removeItem(at: cwd) } + + let canonical = cwd.appendingPathComponent("target.ti3") + try "canonical".write(to: canonical, atomically: true, encoding: .utf8) + + let pass = try MeasurementArtefacts.snapshotPass(basename: "target", cwd: cwd) + XCTAssertEqual(pass.lastPathComponent, "target_pass1.ti3") + XCTAssertFalse(FileManager.default.fileExists(atPath: canonical.path)) + + let promoted = try MeasurementArtefacts.promotePass(pass: pass, basename: "target", cwd: cwd) + XCTAssertEqual(promoted.lastPathComponent, "target.ti3") + XCTAssertTrue(FileManager.default.fileExists(atPath: promoted.path)) + } + + func testCollision() throws { + let cwd = try makeCwd() + defer { try? FileManager.default.removeItem(at: cwd) } + + let canonical = cwd.appendingPathComponent("target.ti3") + try "v1".write(to: canonical, atomically: true, encoding: .utf8) + _ = try MeasurementArtefacts.snapshotPass(basename: "target", cwd: cwd) + + try "v2".write(to: canonical, atomically: true, encoding: .utf8) + let pass2 = try MeasurementArtefacts.snapshotPass(basename: "target", cwd: cwd) + XCTAssertEqual(pass2.lastPathComponent, "target_pass2.ti3") + } +} + +final class AverageArgsTests: XCTestCase { + + func testPassCount() { + let cwd = URL(fileURLWithPath: "/tmp") + let config = AverageConfig( + workingDirectory: cwd, + basename: "target", + passFiles: [URL(fileURLWithPath: "target_pass1.ti3")] + ) + XCTAssertThrowsError(try AverageArgs.build(config: config)) { error in + XCTAssertTrue(error is AverageArgError) + } + } + + func testOrdering() throws { + let cwd = URL(fileURLWithPath: "/tmp") + let config = AverageConfig( + workingDirectory: cwd, + basename: "target", + passFiles: [ + URL(fileURLWithPath: "/tmp/target_pass1.ti3"), + URL(fileURLWithPath: "/tmp/target_pass2.ti3"), + ] + ) + let args = try AverageArgs.build(config: config) + XCTAssertEqual(args.first, "-v") + XCTAssertEqual(args.last, "target.ti3") + XCTAssertEqual(args, ["-v", "target_pass1.ti3", "target_pass2.ti3", "target.ti3"]) + } +} diff --git a/Tests/ICCeryCoreTests/MediaLibraryStoreTests.swift b/Tests/ICCeryCoreTests/MediaLibraryStoreTests.swift new file mode 100644 index 0000000..c72d108 --- /dev/null +++ b/Tests/ICCeryCoreTests/MediaLibraryStoreTests.swift @@ -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) + } +} diff --git a/Tests/ICCeryCoreTests/MediaLibraryViewModelTests.swift b/Tests/ICCeryCoreTests/MediaLibraryViewModelTests.swift new file mode 100644 index 0000000..d62e6ce --- /dev/null +++ b/Tests/ICCeryCoreTests/MediaLibraryViewModelTests.swift @@ -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)) + } +} diff --git a/Tests/ICCeryCoreTests/MediaRecipeTests.swift b/Tests/ICCeryCoreTests/MediaRecipeTests.swift new file mode 100644 index 0000000..1c02039 --- /dev/null +++ b/Tests/ICCeryCoreTests/MediaRecipeTests.swift @@ -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()) + } +} diff --git a/Tests/ICCeryCoreTests/PresetTests.swift b/Tests/ICCeryCoreTests/PresetTests.swift new file mode 100644 index 0000000..3fa0083 --- /dev/null +++ b/Tests/ICCeryCoreTests/PresetTests.swift @@ -0,0 +1,473 @@ +import Foundation +import XCTest +@testable import ICCeryCore + +final class ProfilingPresetTests: XCTestCase { + + func testRoundTrip() throws { + var p = PresetCatalog.highQualityCMYK + p.colprofInputViewingCond = "D50_2" + let data = try JSONEncoder().encode(p) + let decoded = try JSONDecoder().decode(ProfilingPreset.self, from: data) + XCTAssertEqual(decoded, p) + // Spot-check the wire format. + let obj = try JSONSerialization.jsonObject(with: data) as! [String: Any] + XCTAssertEqual(obj["colour_space"] as? String, "cmyk") + XCTAssertEqual(obj["patch_count"] as? Int, 1500) + XCTAssertEqual(obj["total_ink_limit"] as? Int, 320) + XCTAssertEqual(obj["bit_depth"] as? Int, 16) + XCTAssertEqual(obj["colprof_input_viewing_cond"] as? String, "D50_2") + } + + func testSchemaTolerance() throws { + let json = """ + {"id":"x","name":"N","colour_space":"rgb","patch_count":10, + "white_patches":1,"black_patches":1,"instrument":"i1", + "page_size":"A4","bit_depth":8,"dpi":300,"future_key":42} + """.data(using: .utf8)! + let ok = try JSONDecoder().decode(ProfilingPreset.self, from: json) + XCTAssertEqual(ok.id, "x") + + let missing = """ + {"id":"x","name":"N","colour_space":"rgb"} + """.data(using: .utf8)! + XCTAssertThrowsError(try JSONDecoder().decode(ProfilingPreset.self, from: missing)) { error in XCTAssertTrue(error is DecodingError) } + } + + func testValidation() { + XCTAssertThrowsError(try ProfilingPreset(id: "a", name: "n", colourSpace: "lab").validated()) { error in XCTAssertTrue(error is ProfilingPreset.ValidationError) } + XCTAssertThrowsError(try ProfilingPreset(id: "a", name: "n", dpi: 10).validated()) { error in XCTAssertTrue(error is ProfilingPreset.ValidationError) } + XCTAssertThrowsError(try ProfilingPreset(id: "a", name: "n", bitDepth: 12).validated()) { error in XCTAssertTrue(error is ProfilingPreset.ValidationError) } + XCTAssertThrowsError(try ProfilingPreset(id: "a", name: "n", patchCount: 0).validated()) { error in XCTAssertTrue(error is ProfilingPreset.ValidationError) } + } +} + +final class PresetCatalogTests: XCTestCase { + + func testBuiltIns() { + XCTAssertEqual(PresetCatalog.builtIns.count, 4) + let byID = Dictionary(uniqueKeysWithValues: PresetCatalog.builtIns.map { ($0.id, $0) }) + + let std = byID["preset-std-rgb"]! + XCTAssertTrue(std.colourSpace == "rgb" && std.patchCount == 800 + && std.pageSize == "A4" && std.bitDepth == 8 + && std.dpi == 300 && std.colprofQuality == "m" + && std.whitePatches == 4 && std.blackPatches == 4) + + let hq = byID["preset-hq-cmyk"]! + XCTAssertTrue(hq.colourSpace == "cmyk" && hq.patchCount == 1500 + && hq.pageSize == "A3" && hq.bitDepth == 16 + && hq.dpi == 300 && hq.colprofQuality == "h" + && hq.totalInkLimit == 320 && hq.blackPatches == 8) + + let draft = byID["preset-draft-rgb"]! + XCTAssertTrue(draft.colourSpace == "rgb" && draft.patchCount == 400 + && draft.pageSize == "A4" && draft.bitDepth == 8 + && draft.dpi == 150 && draft.colprofQuality == "l") + + let ultra = byID["preset-ultra-rgb"]! + XCTAssertTrue(ultra.colourSpace == "rgb" && ultra.patchCount == 2500 + && ultra.pageSize == "A3" && ultra.bitDepth == 16 + && ultra.dpi == 300 && ultra.colprofQuality == "u" + && ultra.ofpsHighQuality == true + && ultra.whitePatches == 6 && ultra.blackPatches == 6) + + for p in PresetCatalog.builtIns { + XCTAssertEqual(p.instrument, "i1") + XCTAssertEqual(p.colprofFwa, "D50") + XCTAssertEqual(p.randomSeed, 1) + XCTAssertEqual(p.noRandomize, false) + XCTAssertEqual(p.colprofAlgorithm, "l") + } + } + + func testOverlay() { + let custom = ProfilingPreset( + id: "preset-std-rgb", name: "Shadowed", patchCount: 42) + let all = PresetCatalog.all(custom: [custom]) + XCTAssertEqual(all.count, 4) + XCTAssertEqual(all.first { $0.id == "preset-std-rgb" }?.patchCount, 42) + XCTAssertTrue(PresetCatalog.isBuiltIn("preset-std-rgb")) + XCTAssertFalse(PresetCatalog.isBuiltIn("custom-1")) + } +} + +final class PresetStoreTests: XCTestCase { + + private func tempSettingsURL() throws -> URL { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir.appendingPathComponent("settings.json") + } + + func testCrud() throws { + let url = try tempSettingsURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let store = PresetStore(settingsStore: SettingsStore(fileURL: url)) + + var p = ProfilingPreset(id: "custom-x", name: "Mine", patchCount: 999, dpi: 150) + try store.saveCustom(p) + XCTAssertEqual(store.customs().count, 1) + XCTAssertEqual(store.all().count, 5) + + p.name = "Renamed" + try store.saveCustom(p) + XCTAssertEqual(store.customs().count, 1) + XCTAssertEqual(store.customs()[0].name, "Renamed") + + let data = try store.export(p) + let imported = try store.import(data) + XCTAssertEqual(imported.name, "Renamed") + XCTAssertEqual(imported.dpi, 150) + + XCTAssertTrue(try store.deleteCustom(id: "custom-x")) + XCTAssertTrue(store.customs().isEmpty) + XCTAssertFalse(try store.deleteCustom(id: "preset-std-rgb")) + } + + func testImportBuiltinCollision() throws { + let url = try tempSettingsURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let store = PresetStore(settingsStore: SettingsStore(fileURL: url)) + let data = try store.export(PresetCatalog.standardRGB) + let imported = try store.import(data) + XCTAssertTrue(imported.id.hasPrefix("custom-")) + XCTAssertFalse(PresetCatalog.isBuiltIn(imported.id)) + } + + func testBuiltInImmutable() throws { + let url = try tempSettingsURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let store = PresetStore(settingsStore: SettingsStore(fileURL: url)) + var shadowed = PresetCatalog.standardRGB + shadowed.name = "Hacked" + XCTAssertThrowsError(try store.saveCustom(shadowed)) { error in XCTAssertTrue(error is PresetStore.PresetStoreError) } + } +} + +final class PresetMigrationTests: XCTestCase { + + private func tempSettingsURL() throws -> URL { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir.appendingPathComponent("settings.json") + } + + func testLegacyMigration() throws { + let url = try tempSettingsURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let legacy = """ + {"custom_presets":[ + {"name":"Old One","values":{"colour_space":"cmyk","patch_count":"900", + "dpi":"150","bit_depth":"16","instrument":"p3","page_size":"A3"}}, + {"name":"","values":{}}, + 42 + ]} + """.data(using: .utf8)! + try legacy.write(to: url) + + let settings = SettingsStore(fileURL: url).load() + XCTAssertEqual(settings.customPresets.count, 1) + let p = settings.customPresets[0] + XCTAssertEqual(p.name, "Old One") + XCTAssertTrue(p.id.hasPrefix("custom-0-")) + XCTAssertEqual(p.colourSpace, "cmyk") + XCTAssertEqual(p.patchCount, 900) + XCTAssertEqual(p.dpi, 150) + XCTAssertEqual(p.bitDepth, 16) + XCTAssertEqual(p.instrument, "p3") + XCTAssertEqual(p.pageSize, "A3") + } + + func testTypedRoundTrip() throws { + let url = try tempSettingsURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let store = SettingsStore(fileURL: url) + var s = AppSettings() + s.customPresets = [ProfilingPreset(id: "c1", name: "C1", patchCount: 700)] + try store.save(s) + let loaded = store.load() + XCTAssertEqual(loaded.customPresets.first?.patchCount, 700) + } + + func testDraftDPI() throws { + let url = try tempSettingsURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let store = PresetStore(settingsStore: SettingsStore(fileURL: url)) + let data = try store.export(PresetCatalog.draftRGB) + let obj = try JSONSerialization.jsonObject(with: data) as! [String: Any] + XCTAssertEqual(obj["dpi"] as? Int, 150) + let back = try store.import(data) + XCTAssertEqual(back.dpi, 150) + } +} + +final class PresetMappingTests: XCTestCase { + func testDraftDpi() { + let cfg = PrinttargConfig( + preset: PresetCatalog.draftRGB, + basename: "t", + workingDirectory: nil, + calibrationFile: nil + ) + XCTAssertEqual(cfg.dpi, 150) + XCTAssertEqual(cfg.layoutOrder, .deterministic) + } + + func testOptionalNil() { + let preset = ProfilingPreset(id: "x", name: "n", patchCount: 800) + let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil) + XCTAssertNil(cfg.greySteps) + XCTAssertNil(cfg.singleChannelSteps) + XCTAssertNil(cfg.neutralSteps) + XCTAssertNil(cfg.totalInkLimit) + XCTAssertNil(cfg.darkEmphasis) + XCTAssertNil(cfg.devicePower) + } + + func testRoundTripConfigs() { + var preset = PresetCatalog.highQualityCMYK + preset.pageSize = "210x297" + preset.colprofFwa = "D50" + preset.greySteps = nil + let targen = TargenConfig(preset: preset, basename: "job", workingDirectory: nil) + let printtarg = PrinttargConfig( + preset: preset, basename: "job", workingDirectory: nil, calibrationFile: nil + ) + let colprof = ColprofConfig(preset: preset, basename: "job", workingDirectory: nil) + XCTAssertEqual(printtarg.pageSize, .custom) + XCTAssertEqual(printtarg.customPageWidth, 210) + XCTAssertEqual(colprof.fwa, "D50") + let back = ProfilingPreset( + id: preset.id, + name: preset.name, + description: preset.description, + targen: targen, + printtarg: printtarg, + colprof: colprof, + calibrationFile: preset.calibrationFile, + applyCalibration: preset.applyCalibration + ) + XCTAssertEqual(back.dpi, preset.dpi) + XCTAssertEqual(back.colourSpace, "cmyk") + XCTAssertEqual(back.pageSize, "210x297") + XCTAssertEqual(back.colprofFwa, "D50") + XCTAssertNil(back.greySteps) + } + + func testFullRoundTrip() { + let preset = ProfilingPreset( + id: "custom-full", + name: "Full", + description: "All fields", + colourSpace: "cmyk", + patchCount: 1500, + whitePatches: 6, + blackPatches: 8, + greySteps: 9, + singleChannelSteps: 7, + neutralSteps: 4, + neutralConcentration: 0.7, + preconditioningProfile: "/tmp/pre.icm", + ofpsHighQuality: true, + ofpsAdaptation: 0.2, + fullSpreadAlgorithm: "R", + totalInkLimit: 280, + darkEmphasis: 1.3, + devicePower: 1.2, + instrument: "p3", + pageSize: "250x300", + bitDepth: 16, + dpi: 360, + randomSeed: 42, + noRandomize: false, + calibrationFile: "/tmp/a.cal", + applyCalibration: true, + colprofAlgorithm: "x", + colprofQuality: "u", + colprofIntent: "p", + colprofFwa: "D65", + colprofIlluminant: "D65", + colprofObserver: "1931_2", + colprofInputViewingCond: "D50_2", + colprofOutputViewingCond: "D65_2" + ) + + let targen = TargenConfig(preset: preset, basename: "j", workingDirectory: nil) + XCTAssertEqual(targen.colourSpace, .cmyk) + XCTAssertEqual(targen.patchCount, 1500) + XCTAssertEqual(targen.whitePatches, 6) + XCTAssertEqual(targen.blackPatches, 8) + XCTAssertEqual(targen.greySteps, 9) + XCTAssertEqual(targen.singleChannelSteps, 7) + XCTAssertEqual(targen.neutralSteps, 4) + XCTAssertEqual(targen.neutralConcentration, 0.7) + XCTAssertEqual(targen.preconditioningProfile, "/tmp/pre.icm") + XCTAssertEqual(targen.ofpsHighQuality, true) + XCTAssertEqual(targen.ofpsAdaptation, 0.2) + XCTAssertEqual(targen.fullSpreadAlgorithm, .uniformRandom) + XCTAssertEqual(targen.totalInkLimit, 280) + XCTAssertEqual(targen.darkEmphasis, 1.3) + XCTAssertEqual(targen.devicePower, 1.2) + + let printtarg = PrinttargConfig( + preset: preset, + basename: "j", + workingDirectory: nil, + calibrationFile: preset.calibrationFile + ) + XCTAssertEqual(printtarg.instrument, .p3) + XCTAssertEqual(printtarg.pageSize, .custom) + XCTAssertEqual(printtarg.customPageWidth, 250) + XCTAssertEqual(printtarg.customPageHeight, 300) + XCTAssertEqual(printtarg.bitDepth, .sixteen) + XCTAssertEqual(printtarg.dpi, 360) + XCTAssertEqual(printtarg.layoutOrder, .customSeed) + XCTAssertEqual(printtarg.customSeed, 42) + XCTAssertEqual(printtarg.calibrationFile, "/tmp/a.cal") + + let colprof = ColprofConfig(preset: preset, basename: "j", workingDirectory: nil) + XCTAssertEqual(colprof.algorithm, "x") + XCTAssertEqual(colprof.quality, "u") + XCTAssertEqual(colprof.intent, "p") + XCTAssertEqual(colprof.fwa, "D65") + XCTAssertEqual(colprof.illuminant, "D65") + XCTAssertEqual(colprof.observer, "1931_2") + XCTAssertEqual(colprof.inputViewingCond, "D50_2") + XCTAssertEqual(colprof.outputViewingCond, "D65_2") + + let back = ProfilingPreset( + id: preset.id, + name: preset.name, + description: preset.description, + targen: targen, + printtarg: printtarg, + colprof: colprof, + calibrationFile: preset.calibrationFile, + applyCalibration: preset.applyCalibration + ) + XCTAssertEqual(back, preset) + } + + func testFullSpreadAlgorithms() { + let cases: [(String, FullSpreadAlgorithm)] = [ + ("ofps", .ofps), + ("t", .target), + ("r", .random), + ("R", .uniformRandom), + ("q", .quasiRandom), + ("Q", .uniformQuasiRandom), + ("i", .invertedQuasiRandom), + ("I", .invertedUniformQuasiRandom) + ] + for (value, expected) in cases { + var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100) + preset.fullSpreadAlgorithm = value + let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil) + if expected == .ofps { + // ofps is the default — no flag emitted, stored value is nil. + XCTAssertNil(cfg.fullSpreadAlgorithm) + } else { + XCTAssertEqual(cfg.fullSpreadAlgorithm, expected) + } + let back = ProfilingPreset( + id: "x", name: "n", description: "", + targen: cfg, + printtarg: PrinttargConfig( + preset: preset, basename: "t", + workingDirectory: nil, calibrationFile: nil + ), + colprof: ColprofConfig(preset: preset, basename: "t", workingDirectory: nil), + calibrationFile: nil, + applyCalibration: nil + ) + XCTAssertEqual(back.fullSpreadAlgorithm, value) + } + } + + func testOfpsHighQualityFalse() { + var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100) + preset.ofpsHighQuality = false + let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil) + XCTAssertEqual(cfg.ofpsHighQuality, false) + + preset.ofpsHighQuality = nil + let nilCfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil) + XCTAssertNil(nilCfg.ofpsHighQuality) + } + + func testLayoutMapping() { + let cases: [(Bool?, Int?, LayoutOrder, Int)] = [ + (true, nil, .raster, 1), + (true, 7, .raster, 7), + (false, nil, .deterministic, 1), + (false, 1, .deterministic, 1), + (nil, 1, .deterministic, 1), + (false, 5, .customSeed, 5) + ] + for (noRandomize, seed, layout, expectedSeed) in cases { + var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100) + preset.noRandomize = noRandomize + preset.randomSeed = seed + let cfg = PrinttargConfig( + preset: preset, basename: "t", + workingDirectory: nil, calibrationFile: nil + ) + XCTAssertEqual(cfg.layoutOrder, layout) + XCTAssertEqual(cfg.customSeed, expectedSeed) + } + } + + func testCustomPageFallback() { + let cases: [(String, PageSize, Double, Double)] = [ + ("250x300", .custom, 250.0, 300.0), + ("50x50", .custom, 50.0, 50.0), + ("foo", .a4, 210.0, 297.0), + ("30x40", .a4, 210.0, 297.0), + ("210x", .a4, 210.0, 297.0) + ] + for (raw, page, w, h) in cases { + var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100) + preset.pageSize = raw + let cfg = PrinttargConfig( + preset: preset, basename: "t", + workingDirectory: nil, calibrationFile: nil + ) + XCTAssertEqual(cfg.pageSize, page) + XCTAssertEqual(cfg.customPageWidth, w) + XCTAssertEqual(cfg.customPageHeight, h) + } + } + + func testFwaToSelection() { + let cases: [(String?, ColprofFwaSelection)] = [ + (nil, .none), + ("none", .none), + ("NONE", .none), + ("", .empty), + ("D50", .D50), + ("d50", .D50), + ("D65", .D65), + ("d65", .D65), + ("/tmp/fwa.sp", .custom) + ] + for (raw, expected) in cases { + XCTAssertEqual(ColprofFwaSelection(presetValue: raw), expected) + } + } + + func testFwaToPresetValue() { + let cases: [(ColprofFwaSelection, String?)] = [ + (.none, nil), + (.empty, ""), + (.D50, "D50"), + (.D65, "D65"), + (.custom, "/tmp/fwa.sp") + ] + for (selection, expected) in cases { + XCTAssertEqual(selection.presetValue(customPath: "/tmp/fwa.sp"), expected) + } + } +} diff --git a/Tests/ICCeryCoreTests/PresetViewModelMappingTests.swift b/Tests/ICCeryCoreTests/PresetViewModelMappingTests.swift new file mode 100644 index 0000000..08806f1 --- /dev/null +++ b/Tests/ICCeryCoreTests/PresetViewModelMappingTests.swift @@ -0,0 +1,121 @@ +import Foundation +import XCTest +@testable import ICCeryCore +@testable import ICCery + +/// Issue #82 — preset application through the live view models, under an +/// isolated `TestAppEnvironment` (temp stores, fresh ProcessManager). +@MainActor +final class PresetViewModelMappingTests: XCTestCase { + + private func makeWorkflow() throws -> (TestAppEnvironment, TargetWorkflowViewModel) { + let env = try TestAppEnvironment.make() + return (env, TargetWorkflowViewModel(environment: env.environment)) + } + + func testNilFwaClearsCustomPath() throws { + let (env, vm) = try makeWorkflow() + defer { env.cleanup() } + + var customPreset = ProfilingPreset( + id: "c-fwa", name: "FWA", patchCount: 800, + colprofFwa: "/tmp/fwa.sp" + ) + vm.applyPreset(customPreset) + XCTAssertEqual(vm.profile.fwaSelection, .custom) + XCTAssertEqual(vm.profile.fwaCustomPath, "/tmp/fwa.sp") + + customPreset.colprofFwa = nil + vm.applyPreset(customPreset) + XCTAssertEqual(vm.profile.fwaSelection, .none) + XCTAssertEqual(vm.profile.fwaCustomPath, "") + XCTAssertNil(vm.profile.fwaValue) + } + + func testCustomFwaRoundTrip() throws { + let (env, vm) = try makeWorkflow() + defer { env.cleanup() } + + let preset = ProfilingPreset( + id: "c-fwa2", name: "FWA2", patchCount: 800, + colprofFwa: "/tmp/other.sp" + ) + vm.applyPreset(preset) + XCTAssertEqual(vm.profile.fwaSelection, .custom) + XCTAssertEqual(vm.profile.fwaCustomPath, "/tmp/other.sp") + XCTAssertEqual(vm.profile.fwaValue, "/tmp/other.sp") + } + + func testPresetCalibrationReachesStage2() throws { + let (env, vm) = try makeWorkflow() + defer { env.cleanup() } + + // Stale live state must not leak into the preset-applied layout. + vm.profile.applyCalibration = true + vm.profile.calibrationFile = "/tmp/stale.cal" + + let preset = ProfilingPreset( + id: "c-cal", name: "Cal", patchCount: 800, + calibrationFile: "/tmp/preset.cal", + applyCalibration: true + ) + vm.applyPreset(preset) + + XCTAssertTrue(vm.profile.applyCalibration) + XCTAssertEqual(vm.profile.calibrationFile, "/tmp/preset.cal") + XCTAssertEqual(vm.buildPrinttargConfig().calibrationFile, "/tmp/preset.cal") + } + + func testDisabledCalibrationClearsStage2() throws { + let (env, vm) = try makeWorkflow() + defer { env.cleanup() } + + vm.profile.applyCalibration = true + vm.profile.calibrationFile = "/tmp/stale.cal" + + let preset = ProfilingPreset( + id: "c-nocal", name: "NoCal", patchCount: 800, + calibrationFile: "/tmp/preset.cal", + applyCalibration: nil + ) + vm.applyPreset(preset) + + XCTAssertFalse(vm.profile.applyCalibration) + XCTAssertNil(vm.buildPrinttargConfig().calibrationFile) + } + + func testFormFieldsApply() throws { + let (env, vm) = try makeWorkflow() + defer { env.cleanup() } + + var preset = ProfilingPreset( + id: "c-form", name: "Form", + colourSpace: "cmyk", patchCount: 1500, + whitePatches: 6, + blackPatches: 8, + greySteps: 9, + fullSpreadAlgorithm: "r", + pageSize: "250x300", + dpi: 150 + ) + vm.applyPreset(preset) + + XCTAssertEqual(vm.colourSpace, .cmyk) + XCTAssertEqual(vm.effectivePatchCount, 1500) + XCTAssertEqual(vm.whitePatches, 6) + XCTAssertEqual(vm.blackPatches, 8) + XCTAssertTrue(vm.greyStepsEnabled && vm.greySteps == 9) + XCTAssertEqual(vm.algorithm, .random) + XCTAssertEqual(vm.tiffDpi, 150) + XCTAssertEqual(vm.pageSize, .custom) + XCTAssertTrue(vm.customPageW == 250 && vm.customPageH == 300) + XCTAssertEqual(vm.selectedPresetID, "c-form") + + // Disabled advanced controls stay nil in the snapshot, not + // numeric sentinels. + preset.greySteps = nil + vm.applyPreset(preset) + XCTAssertFalse(vm.greyStepsEnabled) + XCTAssertNil(vm.buildTargenConfig().greySteps) + } +} diff --git a/Tests/ICCeryCoreTests/PrintPanelTests.swift b/Tests/ICCeryCoreTests/PrintPanelTests.swift new file mode 100644 index 0000000..5a92936 --- /dev/null +++ b/Tests/ICCeryCoreTests/PrintPanelTests.swift @@ -0,0 +1,67 @@ +import Foundation +import XCTest +@testable import ICCeryCore +@testable import ICCery + +/// Issue 13 — panel outcome mapping (cancel → nil, ok → result). +/// The real `NSPrintPanel` is never run in tests; these exercise the +/// `UITestHooks` seam the UI tests rely on. +final class PrintPanelStubTests: XCTestCase { + + private func withEnv( + _ vars: [String: String?], + _ body: () throws -> Void + ) rethrows { + var saved: [String: String?] = [:] + for key in vars.keys { + saved[key] = ProcessInfo.processInfo.environment[key] + } + for (key, value) in vars { + if let value { setenv(key, value, 1) } else { unsetenv(key) } + } + defer { + for (key, value) in saved { + if let value { setenv(key, value, 1) } else { unsetenv(key) } + } + } + try body() + } + + func testCancelIsNil() throws { + try withEnv([ + "ICCERY_UI_TESTING": "1", + "ICCERY_TEST_PRINT_PANEL": "cancel", + ]) { + XCTAssertTrue(UITestHooks.printPanelStubbed) + XCTAssertNil(UITestHooks.printPanelResult(forQueue: "q")) + } + } + + func testOkResult() throws { + try withEnv([ + "ICCERY_UI_TESTING": "1", + "ICCERY_TEST_PRINT_PANEL": "ok", + "ICCERY_TEST_PANEL_OPTIONS": "MediaType=Photo InputSlot=Rear", + "ICCERY_TEST_PANEL_PRINTER": "Other_Queue", + ]) { + let result = UITestHooks.printPanelResult(forQueue: "q") + XCTAssertEqual(result?.selectedPrinter, "Other_Queue") + XCTAssertEqual(result?.options.cupsOptions, "MediaType=Photo InputSlot=Rear") + XCTAssertEqual(result?.options.mediaType, "Photo") + XCTAssertEqual(result?.options.ppdUncorrectedPassthrough, true) + } + } + + func testOkDefaultsPrinter() throws { + try withEnv([ + "ICCERY_UI_TESTING": "1", + "ICCERY_TEST_PRINT_PANEL": "ok", + "ICCERY_TEST_PANEL_OPTIONS": nil, + "ICCERY_TEST_PANEL_PRINTER": nil, + ]) { + let result = UITestHooks.printPanelResult(forQueue: "My_Queue") + XCTAssertEqual(result?.selectedPrinter, "My_Queue") + XCTAssertNil(result?.options.cupsOptions) + } + } +} diff --git a/Tests/ICCeryCoreTests/PrintcalArgsTests.swift b/Tests/ICCeryCoreTests/PrintcalArgsTests.swift new file mode 100644 index 0000000..cd56634 --- /dev/null +++ b/Tests/ICCeryCoreTests/PrintcalArgsTests.swift @@ -0,0 +1,74 @@ +import Foundation +import XCTest +@testable import ICCeryCore + +final class PrintcalArgsTests: XCTestCase { + + private let tmp = URL(fileURLWithPath: "/tmp/out.cal") + + func testDefaults() throws { + let config = PrintcalConfig( + ti3Basename: "CAL_demo", + outputURL: tmp + ) + let args = try PrintcalArgs.build(config: config) + XCTAssertEqual(args, ["-v", "-e", "-o", "/tmp/out.cal", "CAL_demo"]) + } + + func testAllOptions() throws { + let config = PrintcalConfig( + ti3Basename: "demo", + outputURL: tmp, + noInkLimit: true, + verify: true, + previousCalPath: "/tmp/old.cal", + totalInkLimit: 280, + channelLimits: [ + PrintcalChannelLimit(channel: "C", percent: 95), + PrintcalChannelLimit(channel: "M", percent: 90) + ] + ) + let args = try PrintcalArgs.build(config: config) + XCTAssertEqual(args, [ + "-v", "-e", + "-I", "-z", + "-a", "/tmp/old.cal", + "-m", "280.0", + "-xC", "95.0", + "-xM", "90.0", + "-o", "/tmp/out.cal", + "CAL_demo" + ]) + } + + func testWhitespacePreviousCal() throws { + let config = PrintcalConfig( + ti3Basename: "demo", + outputURL: tmp, + previousCalPath: " \n\t " + ) + let args = try PrintcalArgs.build(config: config) + XCTAssertFalse(args.contains("-a")) + XCTAssertEqual(args, ["-v", "-e", "-o", "/tmp/out.cal", "CAL_demo"]) + } + + func testPreviousCalTrimmed() throws { + let config = PrintcalConfig( + ti3Basename: "demo", + outputURL: tmp, + previousCalPath: " /tmp/old.cal " + ) + let args = try PrintcalArgs.build(config: config) + XCTAssertEqual(args[args.firstIndex(of: "-a")! + 1], "/tmp/old.cal") + } + + func testRejectsBadChannelLimit() { + let config = PrintcalConfig( + ti3Basename: "demo", + outputURL: tmp, + channelLimits: [PrintcalChannelLimit(channel: "K", percent: 150)] + ) + XCTAssertThrowsError(try PrintcalArgs.build(config: config)) + } + +} diff --git a/Tests/ICCeryCoreTests/PrinttargTests.swift b/Tests/ICCeryCoreTests/PrinttargTests.swift new file mode 100644 index 0000000..a64df4c --- /dev/null +++ b/Tests/ICCeryCoreTests/PrinttargTests.swift @@ -0,0 +1,416 @@ +import XCTest +import Foundation +@testable import ICCeryCore + +final class PrinttargArgsTests: XCTestCase { + + private func config( + instrument: PrintInstrument = .i1, + pageSize: PageSize = .a4, + customW: Double = 210, customH: Double = 297, + bitDepth: TiffBitDepth = .eight, + dpi: Int = 300, + layout: LayoutOrder = .deterministic, + seed: Int = 1, + label: String? = nil, + calFile: String? = nil, + calEmbed: Bool = false, + basename: String = "target" + ) -> PrinttargConfig { + PrinttargConfig( + instrument: instrument, pageSize: pageSize, + customPageWidth: customW, customPageHeight: customH, + bitDepth: bitDepth, dpi: dpi, + layoutOrder: layout, customSeed: seed, label: label, + calibrationFile: calFile, calibrationEmbedOnly: calEmbed, + basename: basename + ) + } + + func testBaseline() throws { + let args = try PrinttargArgs.build(config: config()) + XCTAssertEqual(args, ["-v", "-u", "-i", "i1", "-p", "A4", + "-R", "1", "-t", "300", "target"]) + } + + func testDeterministicDefault() throws { + let args = try PrinttargArgs.build(config: config()) + XCTAssertTrue(args.contains("-R")) + XCTAssertFalse(args.contains("-r")) + XCTAssertEqual(args[args.firstIndex(of: "-R")! + 1], "1") + } + + func testCustomSeed() throws { + let args = try PrinttargArgs.build(config: config(layout: .customSeed, seed: 42)) + XCTAssertEqual(args[args.firstIndex(of: "-R")! + 1], "42") + XCTAssertThrowsError(try PrinttargArgs.build(config: config(layout: .customSeed, seed: 0))) { error in + XCTAssertTrue(error is PrinttargArgError) + } + } + + func testRaster() throws { + let args = try PrinttargArgs.build(config: config(layout: .raster, seed: 9)) + XCTAssertTrue(args.contains("-r")) + XCTAssertFalse(args.contains("-R")) + } + + func testLabel() throws { + let args = try PrinttargArgs.build( + config: config(label: "ICCery - t - P - I - D - A - 01/02/2026 03:04")) + let i = args.firstIndex(of: "-d")! + XCTAssertTrue(args[i + 1].hasPrefix("ICCery - t")) + } + + func testBitDepthAndDPI() throws { + XCTAssertTrue(try PrinttargArgs.build(config: config(bitDepth: .sixteen, dpi: 600)) + .contains("-T")) + XCTAssertTrue(try PrinttargArgs.build(config: config(bitDepth: .eight, dpi: 72)) + .contains("-t")) + XCTAssertThrowsError(try PrinttargArgs.build(config: config(dpi: 71))) { error in + XCTAssertTrue(error is PrinttargArgError) + } + XCTAssertThrowsError(try PrinttargArgs.build(config: config(dpi: 601))) { error in + XCTAssertTrue(error is PrinttargArgError) + } + } + + func testInstruments() throws { + let expected: [(PrintInstrument, String)] = [ + (.i1, "i1"), (.p3, "p3"), (.cm, "CM"), (.ss, "SS"), + (.dtp20, "20"), (.dtp22, "22"), (.dtp41, "41"), (.dtp51, "51"), + ] + for (inst, code) in expected { + let args = try PrinttargArgs.build(config: config(instrument: inst)) + XCTAssertEqual(args[args.firstIndex(of: "-i")! + 1], code) + } + } + + func testPageSizes() throws { + for size in PageSize.allCases where size != .custom { + let args = try PrinttargArgs.build(config: config(pageSize: size)) + XCTAssertEqual(args[args.firstIndex(of: "-p")! + 1], size.rawValue) + } + let custom = try PrinttargArgs.build(config: config( + pageSize: .custom, customW: 150, customH: 220)) + XCTAssertEqual(custom[custom.firstIndex(of: "-p")! + 1], "150x220") + } + + func testCustomPageTooSmall() { + XCTAssertThrowsError(try PrinttargArgs.build(config: config(pageSize: .custom, customW: 49.9))) { error in + XCTAssertTrue(error is PrinttargArgError) + } + XCTAssertThrowsError(try PrinttargArgs.build(config: config(pageSize: .custom, customH: 10))) { error in + XCTAssertTrue(error is PrinttargArgError) + } + } + + func testCalibrationFlags() throws { + let k = try PrinttargArgs.build(config: config(calFile: "/tmp/a.cal")) + XCTAssertEqual(k[k.firstIndex(of: "-K")! + 1], "/tmp/a.cal") + let i = try PrinttargArgs.build(config: config(calFile: "/tmp/a.cal", calEmbed: true)) + XCTAssertEqual(i[i.firstIndex(of: "-I")! + 1], "/tmp/a.cal") + XCTAssertFalse(i.contains("-K")) + } + + func testCalProtection() throws { + let args = try PrinttargArgs.build( + config: config(calFile: "/tmp/a.cal", basename: "CAL_test")) + XCTAssertFalse(args.contains("-K")) + XCTAssertFalse(args.contains("-I")) + } + + func testWhitespaceOptions() throws { + let args = try PrinttargArgs.build( + config: config(label: " \n ", calFile: " \t ")) + XCTAssertFalse(args.contains("-d")) + XCTAssertFalse(args.contains("-K")) + XCTAssertFalse(args.contains("-I")) + } + + func testTrimmedOptions() throws { + let args = try PrinttargArgs.build( + config: config(label: " My Label ", calFile: " /tmp/a.cal ")) + XCTAssertEqual(args[args.firstIndex(of: "-d")! + 1], "My Label") + XCTAssertEqual(args[args.firstIndex(of: "-K")! + 1], "/tmp/a.cal") + } + + func testUnsafeBasename() { + XCTAssertThrowsError(try PrinttargArgs.build(config: config(basename: "../x"))) { error in + XCTAssertTrue(error is PathSecurity.Error) + } + } +} + +final class PrinttargLabelTests: XCTestCase { + + private var fixedDate: Date { + var comps = DateComponents() + comps.year = 2026; comps.month = 2; comps.day = 3 + comps.hour = 14; comps.minute = 5 + return Calendar(identifier: .gregorian).date(from: comps)! + } + + func testAutomatic() { + let label = PrinttargLabel.automatic( + basename: "tgt", + metadata: TargetLabelMetadata( + printer: "Epson", inkSet: "CMYK", + driverPaper: "Photo", actualPaper: "Matte"), + date: fixedDate, timeZone: .current) + XCTAssertTrue(label.hasPrefix("ICCery - tgt - Epson - CMYK - Photo - Matte - ")) + XCTAssertTrue(label.hasSuffix("03/02/2026") || label.contains("/02/2026")) + } + + func testUnspecified() { + let label = PrinttargLabel.automatic( + basename: "tgt", metadata: TargetLabelMetadata(), + date: fixedDate, timeZone: .current) + XCTAssertTrue(label.contains(" - Unspecified - Unspecified - Unspecified - Unspecified - ")) + } + + func testManualWins() { + let resolved = PrinttargLabel.resolved( + customLabel: " My Label ", basename: "tgt", + metadata: TargetLabelMetadata(), date: fixedDate) + XCTAssertEqual(resolved, "My Label") + } +} + +final class PrinttargManifestTests: XCTestCase { + + private let prettySingle = """ + Some log line + Doing work... + { + "event": "manifest", + "pages": [ + { + "filename": "target.tif", + "patches": 800, + "width_mm": 210.0, + "height_mm": 297.0 + } + ] + } + trailing text + """ + + private let prettyMulti = """ + { + "event": "manifest", + "pages": [ + {"filename": "p1.tif", "patches": 400, "width_mm": 210, "height_mm": 148}, + {"filename": "p2.tif", "patches": 400, "width_mm": 210, "height_mm": 148} + ] + } + """ + + func testSinglePage() throws { + let m = try PrinttargManifestExtractor.manifest(from: prettySingle) + XCTAssertEqual(m.event, "manifest") + XCTAssertEqual(m.pages.count, 1) + XCTAssertEqual(m.pages[0].filename, "target.tif") + XCTAssertEqual(m.pages[0].patches, 800) + } + + func testMultiPage() throws { + let m = try PrinttargManifestExtractor.manifest(from: prettyMulti) + XCTAssertEqual(m.pages.map(\.filename), ["p1.tif", "p2.tif"]) + } + + func testNoJSON() { + XCTAssertThrowsError(try PrinttargManifestExtractor.manifest(from: "plain text\nno json")) { error in + XCTAssertTrue(error is ManifestError) + } + } + + func testWrongEvent() { + let stdout = "{\n \"event\": \"row\",\n \"row\": 1\n}\n" + XCTAssertThrowsError(try PrinttargManifestExtractor.manifest(from: stdout)) { error in + XCTAssertTrue(error is ManifestError) + } + } + + func testRowColorsNotManifest() { + let stdout = "ROW_COLORS_JSON: {\"a\":1}\n{\"event\":\"manifest\",\"pages\":[]}" + // Extraction only starts at a '{' that begins a trimmed line, + // so the ROW_COLORS_JSON line is skipped entirely. + let m = try? PrinttargManifestExtractor.manifest(from: stdout) + XCTAssertNotNil(m) + XCTAssertEqual(m?.event, "manifest") + } + + func testBracesInFilename() throws { + let stdout = "log\n{\n\"event\": \"manifest\",\n\"pages\": [{\"filename\": \"a}b.tif\", \"patches\": 1, \"width_mm\": 50, \"height_mm\": 50}]\n}\n" + let m = try PrinttargManifestExtractor.manifest(from: stdout) + XCTAssertEqual(m.pages[0].filename, "a}b.tif") + } + + func testUnsafeFilenames() { + for bad in ["../x.tif", "/abs/x.tif", "dir/x.tif", "x.txt", ""] { + let stdout = "{\n\"event\":\"manifest\",\"pages\":[{\"filename\":\"\(bad)\",\"patches\":1,\"width_mm\":50,\"height_mm\":50}]\n}" + XCTAssertThrowsError(try PrinttargManifestExtractor.manifest(from: stdout)) { error in + XCTAssertTrue(error is ManifestError) + } + } + } +} + +final class ArgyllRunnerPrinttargTests: XCTestCase { + + private func makeFixture(_ body: String, name: String = "printtarg") throws -> URL { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let url = dir.appendingPathComponent(name) + try body.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], ofItemAtPath: url.path) + return dir + } + + /// A minimal valid TIFF (8-bit, tiny) for gallery preview tests. + private func writeTinyTIFF(at url: URL) throws { + // 1x1 8-bit grayscale TIFF, little-endian. + var bytes: [UInt8] = [ + 0x49, 0x49, 0x2A, 0x00, // II + magic + 0x08, 0x00, 0x00, 0x00, // IFD offset + ] + let ifdCount: UInt16 = 10 + bytes += withUnsafeBytes(of: ifdCount.littleEndian) { Array($0) } + func tag(_ t: UInt16, _ type: UInt16, _ count: UInt32, _ value: UInt32) { + bytes += withUnsafeBytes(of: t.littleEndian) { Array($0) } + bytes += withUnsafeBytes(of: type.littleEndian) { Array($0) } + bytes += withUnsafeBytes(of: count.littleEndian) { Array($0) } + bytes += withUnsafeBytes(of: value.littleEndian) { Array($0) } + } + tag(256, 3, 1, 1) // ImageWidth = 1 + tag(257, 3, 1, 1) // ImageLength = 1 + tag(258, 3, 1, 8) // BitsPerSample = 8 + tag(259, 3, 1, 1) // Compression = none + tag(262, 3, 1, 1) // Photometric = BlackIsZero + tag(273, 4, 1, 0) // StripOffsets — patched below + tag(277, 3, 1, 1) // SamplesPerPixel = 1 + tag(278, 3, 1, 1) // RowsPerStrip = 1 + tag(279, 4, 1, 1) // StripByteCounts = 1 + tag(284, 3, 1, 1) // PlanarConfig + bytes += [0, 0, 0, 0] // next IFD = none + let pixelOffset = bytes.count + bytes += [0x80] // the pixel + // Patch StripOffsets (located right after the tag header at + // offset 8 + 2 + 5*12 + 8 = position of value field). + let valuePos = 8 + 2 + 5 * 12 + 8 + let off = UInt32(pixelOffset).littleEndian + withUnsafeBytes(of: off) { b in + bytes[valuePos] = b[0]; bytes[valuePos+1] = b[1] + bytes[valuePos+2] = b[2]; bytes[valuePos+3] = b[3] + } + try Data(bytes).write(to: url) + } + + func testSuccess() async throws { + let dir = try makeFixture(""" + #!/bin/sh + last="" + for arg in "$@"; do last="$arg"; done + printf 'log line\\n' + printf '{\\n "event": "manifest",\\n "pages": [\\n {"filename": "%s.tif", "patches": 4, "width_mm": 210, "height_mm": 297}\\n ]\\n}\\n' "$last" + touch "$last.ti2" + exit 0 + """) + defer { try? FileManager.default.removeItem(at: dir) } + // Basename "pt" → manifest references pt.tif; write a real TIFF. + try writeTinyTIFF(at: dir.appendingPathComponent("pt.tif")) + + let resolver = BinaryResolver(bundledRoot: dir, overrideDir: dir) + let runner = ArgyllRunner( + processManager: ProcessManager(), binaryResolver: resolver) + let config = PrinttargConfig(basename: "pt", workingDirectory: dir) + let result = try await runner.runPrinttarg(config: config) + XCTAssertEqual(result.ti2URL.lastPathComponent, "pt.ti2") + XCTAssertEqual(result.manifest.pages.count, 1) + XCTAssertEqual(result.pages.count, 1) + let png = result.pages[0].previewPNG + XCTAssertNotNil(png) + if let png { + XCTAssertEqual(png.prefix(8), Data([0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A])) + } + } + + func testFailure() async throws { + let dir = try makeFixture(""" + #!/bin/sh + echo "oops" >&2 + exit 3 + """) + defer { try? FileManager.default.removeItem(at: dir) } + let runner = ArgyllRunner( + processManager: ProcessManager(), + binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir)) + await assertAsyncThrows(expectedType: ArgyllRunnerError.self) { + try await runner.runPrinttarg( + config: PrinttargConfig(basename: "x", workingDirectory: dir)) + } errorHandler: { error in + XCTAssertEqual(error, .toolFailed( + tool: "printtarg", code: 3, logs: ["oops"])) + } + } + + func testNoManifest() async throws { + let dir = try makeFixture(""" + #!/bin/sh + last="" + for arg in "$@"; do last="$arg"; done + touch "$last.ti2" + echo "no json here" + exit 0 + """) + defer { try? FileManager.default.removeItem(at: dir) } + let runner = ArgyllRunner( + processManager: ProcessManager(), + binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir)) + await assertAsyncThrows(expectedType: ArgyllRunnerError.self) { + try await runner.runPrinttarg( + config: PrinttargConfig(basename: "x", workingDirectory: dir)) + } + } + + func testNoTi2() async throws { + let dir = try makeFixture(""" + #!/bin/sh + printf '{\\n"event":"manifest",\\n"pages":[]\\n}\\n' + exit 0 + """) + defer { try? FileManager.default.removeItem(at: dir) } + let runner = ArgyllRunner( + processManager: ProcessManager(), + binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir)) + await assertAsyncThrows(expectedType: ArgyllRunnerError.self) { + try await runner.runPrinttarg( + config: PrinttargConfig(basename: "x", workingDirectory: dir)) + } + } + + func testDeterminism() async throws { + let dir = try makeFixture(""" + #!/bin/sh + last="" + for arg in "$@"; do last="$arg"; done + printf 'TI2\\nDETERMINISTIC\\n' > "$last.ti2" + printf '{\\n"event":"manifest",\\n"pages":[]\\n}\\n' + exit 0 + """) + defer { try? FileManager.default.removeItem(at: dir) } + let runner = ArgyllRunner( + processManager: ProcessManager(), + binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir)) + // Two runs, two basenames — same argv except basename. + _ = try await runner.runPrinttarg( + config: PrinttargConfig(basename: "a", workingDirectory: dir)) + _ = try await runner.runPrinttarg( + config: PrinttargConfig(basename: "b", workingDirectory: dir)) + let d1 = try Data(contentsOf: dir.appendingPathComponent("a.ti2")) + let d2 = try Data(contentsOf: dir.appendingPathComponent("b.ti2")) + XCTAssertEqual(d1, d2) + } +} diff --git a/Tests/ICCeryCoreTests/ProcessManagerTests.swift b/Tests/ICCeryCoreTests/ProcessManagerTests.swift new file mode 100644 index 0000000..261b5a9 --- /dev/null +++ b/Tests/ICCeryCoreTests/ProcessManagerTests.swift @@ -0,0 +1,489 @@ +import XCTest +import Foundation +@testable import ICCeryCore + +/// Helpers shared across ProcessManager tests. Fixture binaries are shell +/// scripts written to a temp dir — no resource bundling required. +/// XCTest executes test methods serially by default. +final class ProcessManagerTests: XCTestCase { + + // MARK: - Fixture plumbing + + private static let fixtureDir: URL = { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-pm-tests-\(UUID().uuidString)") + try! FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + }() + + /// Writes a shell script fixture and returns its executable URL. + private func script(_ name: String, _ body: String) throws -> URL { + let url = Self.fixtureDir.appendingPathComponent(name) + try body.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], ofItemAtPath: url.path + ) + return url + } + + /// Collects events for `id` until `.exit`, `timeout` seconds max. + private func collect( + _ manager: ProcessManager, + id: String, + timeout: TimeInterval = 10 + ) async -> [ProcessEvent] { + await withCheckedContinuation { cont in + let box = Box() + Task { + for await event in manager.events() { + guard event.id == id else { continue } + box.append(event) + if case .exit = event { break } + } + if box.finish() { cont.resume(returning: box.events) } + } + Task { + try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + if box.finish() { cont.resume(returning: box.events) } + } + } + } + + private final class Box: @unchecked Sendable { + private let lock = NSLock() + private var _events: [ProcessEvent] = [] + private var finished = false + var events: [ProcessEvent] { lock.lock(); defer { lock.unlock() }; return _events } + func append(_ e: ProcessEvent) { lock.lock(); _events.append(e); lock.unlock() } + func finish() -> Bool { lock.lock(); defer { lock.unlock() }; if finished { return false }; finished = true; return true } + } + + /// Subscribes synchronously (registration happens inside `events()`) + /// then records every event for `id` until the task is cancelled. + /// Unlike `collect`, observation continues past `.exit` so tests can + /// prove exactly-once exit emission. + private func observe( + _ manager: ProcessManager, + id: String, + into box: Box + ) -> Task { + let stream = manager.events() + return Task { + for await event in stream { + guard event.id == id else { continue } + box.append(event) + } + } + } + + private func exitCount(in box: Box) -> Int { + box.events.filter { if case .exit = $0 { return true }; return false }.count + } + + private func waitForExit(in box: Box, timeout: TimeInterval = 10) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if exitCount(in: box) > 0 { return true } + try? await Task.sleep(nanoseconds: 10_000_000) + } + return false + } + + private func waitForFile(_ url: URL, timeout: TimeInterval = 5) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if FileManager.default.fileExists(atPath: url.path) { return true } + try? await Task.sleep(nanoseconds: 10_000_000) + } + return false + } + + private func waitForRunning( + _ manager: ProcessManager, + id: String, + timeout: TimeInterval = 5 + ) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if await manager.isRunning(id) { return true } + try? await Task.sleep(nanoseconds: 10_000_000) + } + return false + } + + // MARK: - Tests + + func testStreamsStdoutAndEmitsExit() async throws { + let pm = ProcessManager() + let bin = try script("lines.sh", "#!/bin/sh\necho hello\necho world\n") + async let events = collect(pm, id: "t1") + try await pm.runStreaming(id: "t1", binary: bin, arguments: []) + let evs = await events + let lines = evs.compactMap { e -> String? in + if case .stdout(_, let l) = e { return l }; return nil + } + XCTAssertEqual(lines, ["hello", "world"]) + XCTAssertTrue(evs.contains(.exit(id: "t1", code: 0))) + } + + func testRoutesStderrSeparately() async throws { + let pm = ProcessManager() + let bin = try script("err.sh", "#!/bin/sh\necho out\necho oops 1>&2\n") + async let evs = collect(pm, id: "t2") + try await pm.runStreaming(id: "t2", binary: bin, arguments: []) + let events = await evs + XCTAssertTrue(events.contains(.stdout(id: "t2", line: "out"))) + XCTAssertTrue(events.contains(.stderr(id: "t2", line: "oops"))) + } + + func testStripsRowColorsJSONPrefix() async throws { + let pm = ProcessManager() + let bin = try script( + "rows.sh", + "#!/bin/sh\necho 'ROW_COLORS_JSON: {\"row\":1}'\necho plain\n" + ) + async let evs = collect(pm, id: "t3") + try await pm.runStreaming(id: "t3", binary: bin, arguments: []) + let events = await evs + let rows = events.compactMap { e -> String? in + if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) } + return nil + } + XCTAssertEqual(rows, ["{\"row\":1}"]) + XCTAssertTrue(events.contains(.stdout(id: "t3", line: "plain"))) + // Prefixed lines must not leak into stdout. + XCTAssertFalse(events.contains(.stdout(id: "t3", line: "ROW_COLORS_JSON: {\"row\":1}"))) + } + + func testUnterminatedTailFlushesOnExit() async throws { + let pm = ProcessManager() + let bin = try script("tail.sh", "#!/bin/sh\nprintf 'no-newline'\n") + async let evs = collect(pm, id: "t4") + try await pm.runStreaming(id: "t4", binary: bin, arguments: []) + let t4SawTail = await evs.contains(.stdout(id: "t4", line: "no-newline")) + XCTAssertTrue(t4SawTail) + } + + func testStdinRoundTrip() async throws { + let pm = ProcessManager() + // Read two lines then exit naturally — a killed sh would lose its + // buffered stdio output, which is exactly the chartread pattern. + let bin = try script( + "echo.sh", + "#!/bin/sh\nIFS= read -r a; echo \"got:$a\"\nIFS= read -r b; echo \"got:$b\"\n" + ) + async let evs = collect(pm, id: "t5") + try await pm.runStreaming(id: "t5", binary: bin, arguments: []) + try await pm.sendStdin(id: "t5", text: " \n") + try await pm.sendStdin(id: "t5", text: "d\n") + let events = await evs + XCTAssertTrue(events.contains(.stdout(id: "t5", line: "got: "))) + XCTAssertTrue(events.contains(.stdout(id: "t5", line: "got:d"))) + } + + func testDuplicateIDRejected() async throws { + let pm = ProcessManager() + let bin = try script("slow.sh", "#!/bin/sh\nsleep 30\n") + try await pm.runStreaming(id: "t6", binary: bin, arguments: []) + await assertAsyncThrows(expectedType: ProcessError.self) { + try await pm.runStreaming(id: "t6", binary: bin, arguments: []) + } errorHandler: { error in + XCTAssertEqual(error, .duplicateID("t6")) + } + await pm.kill(id: "t6") + } + + func testKillEmitsExitAndClosesStdin() async throws { + let pm = ProcessManager() + let bin = try script("slow2.sh", "#!/bin/sh\ncat\n") + async let evs = collect(pm, id: "t7") + try await pm.runStreaming(id: "t7", binary: bin, arguments: []) + await pm.kill(id: "t7") + let events = await evs + // exit emitted exactly once + let exits = events.filter { if case .exit = $0 { return true }; return false } + XCTAssertEqual(exits.count, 1) + await assertAsyncThrows(expectedType: ProcessError.self) { + try await pm.sendStdin(id: "t7", text: "d\n") + } errorHandler: { error in + XCTAssertEqual(error, .unknownID("t7")) + } + } + + func testKillAllCountsSignaled() async throws { + let pm = ProcessManager() + let bin = try script("slow3.sh", "#!/bin/sh\nsleep 30\n") + try await pm.runStreaming(id: "a", binary: bin, arguments: []) + try await pm.runStreaming(id: "b", binary: bin, arguments: []) + let count = await pm.killAll() + XCTAssertEqual(count, 2) + } + + func testCapturedRunReturnsBothStreams() async throws { + let pm = ProcessManager() + let bin = try script("cap.sh", "#!/bin/sh\necho out-data\necho err-data 1>&2\nexit 3\n") + let result = try await pm.runCaptured(id: "cap", binary: bin, arguments: []) + XCTAssertTrue(result.stdout.contains("out-data")) + XCTAssertTrue(result.stderr.contains("err-data")) + XCTAssertEqual(result.exitCode, 3) + } + + func testCapturedRunFastExit() async throws { + let pm = ProcessManager() + let bin = try script("fast.sh", "#!/bin/sh\nexit 7\n") + let result = try await pm.runCaptured(id: "fast", binary: bin, arguments: []) + XCTAssertEqual(result.exitCode, 7) + XCTAssertEqual(result.stdout, "") + XCTAssertEqual(result.stderr, "") + } + + func testCapturedRunStderrOnly() async throws { + let pm = ProcessManager() + let bin = try script("stderr-only.sh", "#!/bin/sh\necho 'mock lp failure' 1>&2\nexit 1\n") + let result = try await pm.runCaptured(id: "stderr-only", binary: bin, arguments: []) + XCTAssertEqual(result.exitCode, 1) + XCTAssertEqual(result.stdout, "") + XCTAssertTrue(result.stderr.contains("mock lp failure")) + } + + func testCapturedRunDoesNotDeadlockOnLargeOutput() async throws { + let pm = ProcessManager() + // 5000 lines each stream exceeds the 64 KiB pipe buffer. + let bin = try script( + "big.sh", + "#!/bin/sh\ni=0; while [ $i -lt 5000 ]; do echo \"out-$i\"; echo \"err-$i\" 1>&2; i=$((i+1)); done\n" + ) + let result = try await pm.runCaptured(id: "big", binary: bin, arguments: []) + XCTAssertTrue(result.stdout.contains("out-4999")) + XCTAssertTrue(result.stderr.contains("err-4999")) + } + + func testArgyllEnvVarIsSet() async throws { + let pm = ProcessManager() + let bin = try script("env.sh", "#!/bin/sh\necho \"ANI=$ARGYLL_NOT_INTERACTIVE\"\n") + async let evs = collect(pm, id: "t10") + try await pm.runStreaming(id: "t10", binary: bin, arguments: []) + let t10SawEnv = await evs.contains(.stdout(id: "t10", line: "ANI=1")) + XCTAssertTrue(t10SawEnv) + } + + func testUnknownIDStdinThrows() async throws { + let pm = ProcessManager() + await assertAsyncThrows(expectedType: ProcessError.self) { + try await pm.sendStdin(id: "nope", text: "d\n") + } errorHandler: { error in + XCTAssertEqual(error, .unknownID("nope")) + } + } + + func testExplicitPartialFlushEmitsRowColorsJSON() async throws { + let pm = ProcessManager() + let marker = Self.fixtureDir + .appendingPathComponent("partial-row-ready-\(UUID().uuidString)") + let bin = try script( + "partial-row.sh", + "#!/bin/sh\nprintf 'ROW_COLORS_JSON: {\"row\":9}'\ntouch \"$1\"\nsleep 30\n" + ) + let box = Box() + let observer = observe(pm, id: "t11", into: box) + try await pm.runStreaming(id: "t11", binary: bin, arguments: [marker.path]) + let markerReady = await waitForFile(marker) + XCTAssertTrue(markerReady) + // Retry the flush so the pipe-ingest task can win the actor race + // on a loaded host; the first successful flush emits the row. + var flushed = false + for _ in 0..<50 { + await pm.flushPartialLine(id: "t11") + if box.events.contains(where: { if case .jsonRow = $0 { return true }; return false }) { + flushed = true + break + } + try await Task.sleep(nanoseconds: 20_000_000) + } + XCTAssertTrue(flushed) + await pm.kill(id: "t11") + let sawExit = await waitForExit(in: box) + XCTAssertTrue(sawExit) + observer.cancel() + let events = box.events + let rows = events.compactMap { e -> String? in + if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) } + return nil + } + XCTAssertEqual(rows, ["{\"row\":9}"]) + // Prefixed tails must not leak into stdout, even via finalize. + XCTAssertFalse(events.contains(.stdout(id: "t11", line: "ROW_COLORS_JSON: {\"row\":9}"))) + XCTAssertEqual(exitCount(in: box), 1) + } + + func testUnterminatedRowTailFinalizesAsJSONRow() async throws { + let pm = ProcessManager() + let bin = try script( + "row-tail.sh", + "#!/bin/sh\nprintf 'ROW_COLORS_JSON: {\"row\":42}'\n" + ) + let box = Box() + let observer = observe(pm, id: "t12", into: box) + try await pm.runStreaming(id: "t12", binary: bin, arguments: []) + let sawExit = await waitForExit(in: box) + XCTAssertTrue(sawExit) + observer.cancel() + let events = box.events + let rows = events.compactMap { e -> String? in + if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) } + return nil + } + XCTAssertEqual(rows, ["{\"row\":42}"]) + XCTAssertFalse(events.contains(.stdout(id: "t12", line: "ROW_COLORS_JSON: {\"row\":42}"))) + let rowIndex = events.firstIndex { + if case .jsonRow = $0 { return true }; return false + } + let exitIndexes = events.indices.filter { + if case .exit = events[$0] { return true }; return false + } + XCTAssertEqual(exitIndexes.count, 1) + if let rowIndex, let exitIndex = exitIndexes.first { + XCTAssertTrue(rowIndex < exitIndex) + } else { + XCTFail("expected a jsonRow before the exit event") + } + } + + func testFastStreamingExitEmitsExactlyOneExit() async throws { + let pm = ProcessManager() + let bin = try script("fast-stream.sh", "#!/bin/sh\nexit 0\n") + let box = Box() + let observer = observe(pm, id: "t13", into: box) + try await pm.runStreaming(id: "t13", binary: bin, arguments: []) + let sawExit = await waitForExit(in: box) + XCTAssertTrue(sawExit) + // The grace window must outlast the 2 s finalize watchdog so a + // duplicate emission from it would be observed. + try await Task.sleep(nanoseconds: 2_500_000_000) + observer.cancel() + XCTAssertEqual(box.events, [.exit(id: "t13", code: 0)]) + } + + func testFastCapturedExitEmitsExactlyOneExit() async throws { + let pm = ProcessManager() + let bin = try script("fast-cap.sh", "#!/bin/sh\nexit 7\n") + let box = Box() + let observer = observe(pm, id: "t14", into: box) + let result = try await pm.runCaptured(id: "t14", binary: bin, arguments: []) + XCTAssertEqual(result.exitCode, 7) + // Both the termination handler and the waitUntilExit watchdog + // resume the same box; give the slower path time to fire. + try await Task.sleep(nanoseconds: 500_000_000) + observer.cancel() + XCTAssertEqual(box.events, [.exit(id: "t14", code: 7)]) + } + + func testCapturedRunSetsArgyllNotInteractive() async throws { + let pm = ProcessManager() + let bin = try script( + "cap-env.sh", + "#!/bin/sh\necho \"ANI=$ARGYLL_NOT_INTERACTIVE\"\n" + ) + let result = try await pm.runCaptured(id: "t15", binary: bin, arguments: []) + XCTAssertEqual(result.stdout, "ANI=1\n") + } + + func testKillAllTerminatesStreamingAndCapturedChildren() async throws { + let pm = ProcessManager() + let marker = Self.fixtureDir + .appendingPathComponent("mixed-cap-ready-\(UUID().uuidString)") + let slowBin = try script("mixed-slow.sh", "#!/bin/sh\nsleep 30\n") + let capBin = try script("mixed-cap.sh", "#!/bin/sh\ntouch \"$1\"\nsleep 30\n") + let streamBox = Box() + let capBox = Box() + let streamObserver = observe(pm, id: "t16", into: streamBox) + let capObserver = observe(pm, id: "t17", into: capBox) + try await pm.runStreaming(id: "t16", binary: slowBin, arguments: []) + let capTask = Task { + try await pm.runCaptured(id: "t17", binary: capBin, arguments: [marker.path]) + } + let markerReady = await waitForFile(marker) + XCTAssertTrue(markerReady) + let t16Running = await waitForRunning(pm, id: "t16") + XCTAssertTrue(t16Running) + let t17Running = await waitForRunning(pm, id: "t17") + XCTAssertTrue(t17Running) + let killed = await pm.killAll() + XCTAssertEqual(killed, 2) + _ = try await capTask.value + let streamExit = await waitForExit(in: streamBox) + XCTAssertTrue(streamExit) + let capExit = await waitForExit(in: capBox) + XCTAssertTrue(capExit) + // Grace window outlasts the streaming finalize watchdog. + try await Task.sleep(nanoseconds: 2_500_000_000) + streamObserver.cancel() + capObserver.cancel() + let t16RunningAfter = await pm.isRunning("t16") + XCTAssertFalse(t16RunningAfter) + let t17RunningAfter = await pm.isRunning("t17") + XCTAssertFalse(t17RunningAfter) + XCTAssertEqual(exitCount(in: streamBox), 1) + XCTAssertEqual(exitCount(in: capBox), 1) + } +} + +final class ProcessLineDecoderTests: XCTestCase { + func testSplitsAcrossChunkBoundaries() { + var d = ProcessLineDecoder() + XCTAssertEqual(d.feed(Data("he".utf8)), []) + XCTAssertEqual(d.feed(Data("llo\nwor".utf8)), ["hello"]) + XCTAssertEqual(d.feed(Data("ld\n".utf8)), ["world"]) + XCTAssertNil(d.finish()) + } + + func testCrlfIsStripped() { + var d = ProcessLineDecoder() + XCTAssertEqual(d.feed(Data("a\r\nb\r\n".utf8)), ["a", "b"]) + } + + func testFinishReturnsRemainder() { + var d = ProcessLineDecoder() + _ = d.feed(Data("x".utf8)) + XCTAssertEqual(d.finish(), "x") + XCTAssertNil(d.finish()) + } +} + +final class JSONAccumulatorTests: XCTestCase { + func testMultilinePrettyJSON() { + var acc = JSONAccumulator() + XCTAssertNil(acc.feed(line: "{")) + XCTAssertNil(acc.feed(line: " \"k\": 1")) + let done = acc.feed(line: "}") + XCTAssertNotNil(done) + let obj = try? JSONSerialization.jsonObject(with: done!) as? [String: Int] + XCTAssertEqual(obj?["k"], 1) + } + + func testNonJSONLinesIgnored() { + var acc = JSONAccumulator() + XCTAssertNil(acc.feed(line: "Reading instrument...")) + XCTAssertNil(acc.feed(line: "still text")) + XCTAssertNil(acc.completeData) + } + + func testDecodeTyped() { + struct Doc: Decodable { let n: Int } + var acc = JSONAccumulator() + // Split so the doc completes on the second feed. + XCTAssertNil(acc.feed(line: "{\"n\":")) + let data = acc.feed(line: "7}") + XCTAssertNotNil(data) + let doc = data.flatMap { try? JSONDecoder().decode(Doc.self, from: $0) } + XCTAssertEqual(doc?.n, 7) + XCTAssertTrue(acc.isEmpty) + } +} + +final class LogSanitizerTests: XCTestCase { + func testHomeIsRewritten() { + let path = "\(NSHomeDirectory())/Documents/foo.ti1" + XCTAssertEqual(LogSanitizer.sanitize(path), "~/Documents/foo.ti1") + } +} diff --git a/Tests/ICCeryCoreTests/ProcessRunSupportTests.swift b/Tests/ICCeryCoreTests/ProcessRunSupportTests.swift new file mode 100644 index 0000000..357f317 --- /dev/null +++ b/Tests/ICCeryCoreTests/ProcessRunSupportTests.swift @@ -0,0 +1,66 @@ +import Foundation +import XCTest +@testable import ICCery + +/// Direct contracts for the shared logged-run helper (issue #80). +/// +/// `runLogged` owns the running-flag transition (`false → true → false`) +/// and the log-reset decision; these tests pin both sides of the +/// contract plus the coalesced `@MainActor` log hop. +@MainActor +final class ProcessRunSupportTests: XCTestCase { + + private struct SentinelError: Error {} + + func testSuccessTransitions() async throws { + var running: [Bool] = [] + var resets = 0 + var received: [String] = [] + + let result = try await ProcessRunSupport.runLogged( + setRunning: { running.append($0) }, + resetLog: { resets += 1 }, + onLog: { batch in + // MainActor.assertIsolated() needs Swift 5.9; the runner is + // on Xcode 14.2 (Swift 5.7) (#115). + XCTAssertTrue(Thread.isMainThread) + received.append(contentsOf: batch) + } + ) { onLog in + onLog(["alpha", "beta"]) + return 42 + } + + XCTAssertEqual(result, 42) + XCTAssertEqual(running, [true, false]) + XCTAssertEqual(resets, 1) + + // The sink hops back through a main-actor Task; yield until the + // coalesced batch lands. + for _ in 0..<200 where received.isEmpty { + try await Task.sleep(nanoseconds: 10_000_000) + } + XCTAssertEqual(received, ["alpha", "beta"]) + } + + func testFailureTransitions() async throws { + var running: [Bool] = [] + var resets = 0 + + do { + _ = try await ProcessRunSupport.runLogged( + setRunning: { running.append($0) }, + resetLog: { resets += 1 }, + onLog: { _ in } + ) { _ -> Int in + throw SentinelError() + } + XCTFail("Expected runLogged to rethrow") + } catch is SentinelError { + // Expected path. + } + + XCTAssertEqual(running, [true, false]) + XCTAssertEqual(resets, 1) + } +} diff --git a/Tests/ICCeryCoreTests/ProfcheckArgsTests.swift b/Tests/ICCeryCoreTests/ProfcheckArgsTests.swift new file mode 100644 index 0000000..09b461f --- /dev/null +++ b/Tests/ICCeryCoreTests/ProfcheckArgsTests.swift @@ -0,0 +1,15 @@ +import Foundation +import XCTest +@testable import ICCeryCore + +final class ProfcheckArgsTests: XCTestCase { + + func testArgv() throws { + let config = ProfcheckConfig( + ti3URL: URL(fileURLWithPath: "/tmp/target.ti3"), + iccURL: URL(fileURLWithPath: "/tmp/target.icc") + ) + let args = try ProfcheckArgs.build(config: config) + XCTAssertEqual(args, ["-v", "-k", "-s", "-u", "/tmp/target.ti3", "/tmp/target.icc"]) + } +} diff --git a/Tests/ICCeryCoreTests/ProfcheckParserTests.swift b/Tests/ICCeryCoreTests/ProfcheckParserTests.swift new file mode 100644 index 0000000..50b165d --- /dev/null +++ b/Tests/ICCeryCoreTests/ProfcheckParserTests.swift @@ -0,0 +1,65 @@ +import Foundation +import XCTest +@testable import ICCeryCore + +final class ProfcheckParserTests: XCTestCase { + + func testJsonReport() { + let output = """ + No of test patches = 52 + {"event": "report", "peak_de2000": 2.41, "avg_de2000": 0.85, "rms": 1.02} + Profile check complete, errors(CIEDE2000): max. = 9.99, avg. = 9.99, RMS = 9.99 + """ + let report = ProfcheckParser.parse(output) + XCTAssertEqual(report.isValid, true) + XCTAssertEqual(report.patchCount, 52) + XCTAssertEqual(report.avgDE, 0.85) + XCTAssertEqual(report.maxDE, 2.41) + XCTAssertEqual(report.rmsDE, 1.02) + XCTAssertEqual(report.status, .excellent) + } + + func testLegacyText() { + let output = """ + No of test patches = 120 + Profile check complete, errors(CIEDE2000): max. = 3.50, avg. = 1.80, RMS = 0.95 + """ + let report = ProfcheckParser.parse(output) + XCTAssertEqual(report.isValid, true) + XCTAssertEqual(report.patchCount, 120) + XCTAssertEqual(report.avgDE, 1.80) + XCTAssertEqual(report.maxDE, 3.50) + XCTAssertEqual(report.rmsDE, 0.95) + XCTAssertEqual(report.status, .good) + } + + func testRegexFallback() { + let output = """ + No of test patches = 10 + avg = 4.25 + max = 6.10 + rms = 2.30 + """ + let report = ProfcheckParser.parse(output) + XCTAssertEqual(report.isValid, true) + XCTAssertEqual(report.avgDE, 4.25) + XCTAssertEqual(report.maxDE, 6.10) + XCTAssertEqual(report.rmsDE, 2.30) + XCTAssertEqual(report.status, .poor) + } + + func testUnparseable() { + let output = "some random text without metrics" + let report = ProfcheckParser.parse(output) + XCTAssertEqual(report.isValid, false) + XCTAssertNotNil(report.warning) + XCTAssertNil(report.avgDE) + } + + func testStatusBands() { + XCTAssertEqual(VerificationStatus.from(avgDE: 0.5), .excellent) + XCTAssertEqual(VerificationStatus.from(avgDE: 1.5), .good) + XCTAssertEqual(VerificationStatus.from(avgDE: 2.5), .acceptable) + XCTAssertEqual(VerificationStatus.from(avgDE: 4.0), .poor) + } +} diff --git a/Tests/ICCeryCoreTests/ProfileInstallerTests.swift b/Tests/ICCeryCoreTests/ProfileInstallerTests.swift new file mode 100644 index 0000000..67cb353 --- /dev/null +++ b/Tests/ICCeryCoreTests/ProfileInstallerTests.swift @@ -0,0 +1,164 @@ +import Foundation +import XCTest +@testable import ICCeryCore + +/// A `FileManager` subclass that reports a temporary directory as the +/// user home, so `ProfileInstaller` can be tested without writing to the +/// real `~/Library/ColorSync/Profiles`. +private final class TestFileManager: FileManager { + let tempHome: URL + + init(home: URL) { + self.tempHome = home + super.init() + } + + override var homeDirectoryForCurrentUser: URL { + tempHome + } +} + +final class ProfileInstallerTests: XCTestCase { + + private func makeTempDir() throws -> URL { + let fm = FileManager.default + let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try fm.createDirectory(at: tmp, withIntermediateDirectories: true) + return tmp + } + + private func makeSource( + at dir: URL, + name: String, + bytes: [UInt8] = Array(repeating: 0, count: 256) + ) throws -> URL { + let url = dir.appendingPathComponent(name) + let data = Data(bytes) + try data.write(to: url) + return url + } + + func testUserInstall() throws { + let fm = FileManager.default + let tmp = try makeTempDir() + let testFM = TestFileManager(home: tmp) + let source = try makeSource(at: tmp, name: "test.icc") + + let result = try ProfileInstaller.install( + config: InstallProfileConfig(sourceURL: source), + fileManager: testFM + ) + + XCTAssertTrue(result.registered) + XCTAssertFalse(result.overwritten) + XCTAssertFalse(result.renamed) + XCTAssertTrue(result.destPath.hasSuffix("test.icc")) + XCTAssertTrue(fm.fileExists(atPath: result.destPath)) + } + + func testOverwriteSucceeds() throws { + let fm = FileManager.default + let tmp = try makeTempDir() + let testFM = TestFileManager(home: tmp) + let source = try makeSource(at: tmp, name: "m5_profile.icc", bytes: (0..<256).map { UInt8($0) }) + + // First install. + let first = try ProfileInstaller.install( + config: InstallProfileConfig(sourceURL: source), + fileManager: testFM + ) + XCTAssertFalse(first.overwritten) + + // Change the source contents. + let newBytes: [UInt8] = (0..<256).map { UInt8(($0 + 100) % 256) } + try Data(newBytes).write(to: source) + + let options = InstallProfileOptions( + forceOverwrite: true, + preferSystem: false, + collisionPolicy: .overwrite, + openColorPanel: false + ) + let second = try ProfileInstaller.install( + config: InstallProfileConfig(sourceURL: source, options: options), + fileManager: testFM + ) + + XCTAssertTrue(second.overwritten) + XCTAssertFalse(second.renamed) + XCTAssertTrue(fm.fileExists(atPath: second.destPath)) + let installed = try Data(contentsOf: URL(fileURLWithPath: second.destPath)) + XCTAssertEqual(Array(installed), newBytes) + } + + func testPreservesIcmExtension() throws { + let tmp = try makeTempDir() + let testFM = TestFileManager(home: tmp) + let source = try makeSource(at: tmp, name: "m5_profile.icm") + + let result = try ProfileInstaller.install( + config: InstallProfileConfig(sourceURL: source), + fileManager: testFM + ) + + XCTAssertEqual(URL(fileURLWithPath: result.destPath).pathExtension, "icm") + XCTAssertTrue(result.destPath.hasSuffix("m5_profile.icm")) + } + + func testRejectsParentTraversal() throws { + let fm = FileManager.default + let tmp = try makeTempDir() + + // Create a real file in the parent of `tmp` with a path that contains + // a literal ".." component. + let parent = tmp.deletingLastPathComponent() + let naughtyName = "naughty-\(UUID().uuidString).icc" + let realFile = parent.appendingPathComponent(naughtyName) + _ = try makeSource(at: parent, name: naughtyName) + defer { try? fm.removeItem(at: realFile) } + + let sourceURL = tmp + .appendingPathComponent("..") + .appendingPathComponent(naughtyName) + XCTAssertTrue(fm.fileExists(atPath: sourceURL.path)) + + do { + _ = try ProfileInstaller.install(config: InstallProfileConfig(sourceURL: sourceURL)) + XCTFail("Expected unsafeStem error") + } catch let error as ProfileInstallError { + if case .unsafeStem = error { } else { XCTFail("Expected unsafeStem, got \(error)") } + } catch { + XCTFail("Unexpected error type: \(error)") + } + } + + func testAllowsDoubleDotStem() throws { + let fm = FileManager.default + let tmp = try makeTempDir() + let testFM = TestFileManager(home: tmp) + let source = try makeSource(at: tmp, name: "foo..bar.icc") + + let result = try ProfileInstaller.install( + config: InstallProfileConfig(sourceURL: source), + fileManager: testFM + ) + + XCTAssertTrue(result.destPath.hasSuffix("foo..bar.icc")) + XCTAssertTrue(fm.fileExists(atPath: result.destPath)) + } + + func testRejectsSmallSource() throws { + let tmp = try makeTempDir() + let source = tmp.appendingPathComponent("tiny.icc") + try Data(repeating: 0, count: 64).write(to: source) + + do { + _ = try ProfileInstaller.install(config: InstallProfileConfig(sourceURL: source)) + XCTFail("Expected sourceTooSmall error") + } catch let error as ProfileInstallError { + if case .sourceTooSmall = error { } else { XCTFail("Expected sourceTooSmall, got \(error)") } + } catch { + XCTFail("Unexpected error type: \(error)") + } + } +} diff --git a/Tests/ICCeryCoreTests/ProjectSessionTests.swift b/Tests/ICCeryCoreTests/ProjectSessionTests.swift new file mode 100644 index 0000000..6169b0f --- /dev/null +++ b/Tests/ICCeryCoreTests/ProjectSessionTests.swift @@ -0,0 +1,406 @@ +import Foundation +import XCTest +@testable import ICCeryCore +@testable import ICCery + +/// Issue #149 — `ProjectSession` open/save/new/close against an +/// isolated `TestAppEnvironment`. Disk artefacts always win over the +/// project JSON (R18); a `CAL_` basename is never persisted (R11). +@MainActor +final class ProjectSessionTests: XCTestCase { + + private var env: TestAppEnvironment! + private var workflow: TargetWorkflowViewModel! + private var project: ProjectSession! + private var cwd: URL! + + override func setUp() async throws { + env = try TestAppEnvironment.make() + workflow = TargetWorkflowViewModel(environment: env.environment) + project = workflow.project + cwd = env.root.appendingPathComponent("proj-cwd") + try FileManager.default.createDirectory( + at: cwd, withIntermediateDirectories: true) + // Recents are pushed asynchronously; drain before asserting. + await flush() + } + + override func tearDown() async throws { + env?.cleanup() + env = nil + workflow = nil + project = nil + cwd = nil + } + + /// Lets pending `Task`s in the view models run. + private func flush() async { + try? await Task.sleep(nanoseconds: 100_000_000) + } + + private func artefact(_ ext: String, stem: String = "job") throws { + try "x".write( + to: cwd.appendingPathComponent("\(stem).\(ext)"), + atomically: true, encoding: .utf8) + } + + private func writeProjectFile( + _ name: String = "job.icceryproj", + basename: String = "job", + cwdOverride: String? = nil, + lastVerification: Bool = false, + mediaRecipeID: String? = nil, + presetID: String? = nil + ) throws -> URL { + let snapshot: VerificationSnapshot? = lastVerification + ? VerificationSnapshot( + date: Date(), avgDE00: 0.7, maxDE00: 1.9, + status: "excellent", profileFilename: "\(basename).icc") + : nil + let p = ICCeryProject( + name: "Fixture Project", + basename: basename, + cwd: cwdOverride ?? cwd.path, + mediaRecipeID: mediaRecipeID, + presetID: presetID, + lastVerification: snapshot) + let url = env.root.appendingPathComponent(name) + try p.save(to: url) + return url + } + + // MARK: - Open + + func testOpenAppliesBasenameCwdAndBinds() async throws { + try artefact("ti1") + try artefact("ti2") + try artefact("ti3") + let url = try writeProjectFile() + + await project.openAsync(url) + + XCTAssertEqual(workflow.wizard.basename, "job") + XCTAssertEqual(workflow.wizard.workingDirectory?.path, cwd.path) + XCTAssertEqual(workflow.targetBasename, "job") + XCTAssertEqual(workflow.targetDirectory?.path, cwd.path) + XCTAssertEqual(project.projectURL, url) + XCTAssertEqual(project.project?.name, "Fixture Project") + XCTAssertEqual(project.windowTitle, "ICCery — Fixture Project") + XCTAssertFalse(project.isDirty) + XCTAssertTrue(workflow.wizard.isUnlocked(.buildProfile)) + XCTAssertFalse(workflow.wizard.isUnlocked(.verifyInstall)) + } + + func testOpenDiskWinsOverJsonStage() async throws { + // JSON claims a finished profile; disk only has .ti2 (R18). + try artefact("ti2") + let url = try writeProjectFile(lastVerification: true) + + await project.openAsync(url) + + XCTAssertFalse(workflow.wizard.isUnlocked(.buildProfile)) + XCTAssertFalse(workflow.wizard.isUnlocked(.verifyInstall)) + XCTAssertTrue(project.diskBehindNotes) + let notice = workflow.wizard.notice + XCTAssertEqual(notice?.kind, .info) + XCTAssertTrue( + notice?.text.contains("artefacts on disk stop at .ti2") == true, + "got: \(notice?.text ?? "nil")") + } + + func testOpenWithTi3ButNoIccLocksStage5Only() async throws { + try artefact("ti3") + let url = try writeProjectFile(lastVerification: true) + + await project.openAsync(url) + + XCTAssertTrue(workflow.wizard.isUnlocked(.buildProfile)) + XCTAssertFalse(workflow.wizard.isUnlocked(.verifyInstall)) + XCTAssertTrue(project.diskBehindNotes) + } + + func testOpenSchema2LeavesLiveStateUntouched() async throws { + workflow.wizard.setTarget(basename: "live", workingDirectory: cwd) + let url = env.root.appendingPathComponent("v2.icceryproj") + try """ + {"schema_version": 2, "basename": "other", "cwd": "\(cwd.path)"} + """.write(to: url, atomically: true, encoding: .utf8) + + await project.openAsync(url) + + XCTAssertNil(project.projectURL) + XCTAssertEqual(workflow.wizard.basename, "live") + XCTAssertEqual(workflow.wizard.notice?.kind, .error) + XCTAssertTrue( + workflow.wizard.notice?.text.contains("not schema 1") == true) + } + + func testOpenMissingCwdPresentsRelocateSheet() async throws { + let gone = env.root.appendingPathComponent("no-such-dir").path + let url = try writeProjectFile(cwdOverride: gone) + workflow.wizard.setTarget(basename: "live", workingDirectory: cwd) + + await project.openAsync(url) + + XCTAssertTrue(project.showingRelocateSheet) + XCTAssertNotNil(project.pendingRelocate) + XCTAssertNil(project.projectURL) + XCTAssertEqual(workflow.wizard.basename, "live") + } + + func testRelocateCancelAbortsOpen() async throws { + let gone = env.root.appendingPathComponent("no-such-dir").path + let url = try writeProjectFile(cwdOverride: gone) + + await project.openAsync(url) + project.cancelRelocate() + + XCTAssertFalse(project.showingRelocateSheet) + XCTAssertNil(project.projectURL) + } + + func testOpenUnknownMediaRecipeStillOpens() async throws { + let url = try writeProjectFile(mediaRecipeID: "recipe-absent") + + await project.openAsync(url) + await flush() + + XCTAssertEqual(project.projectURL, url) + XCTAssertEqual(workflow.wizard.basename, "job") + // The unknown recipe was ignored, not fatal. + XCTAssertEqual(workflow.media.selectedRecipeID, "none") + } + + func testOpenAppliesPresetWhenNoRecipe() async throws { + let url = try writeProjectFile(presetID: "preset-std-rgb") + + await project.openAsync(url) + await flush() + + XCTAssertEqual(workflow.selectedPresetID, "preset-std-rgb") + } + + // MARK: - Save + + func testSaveRefusesEmptyBasename() async throws { + let url = try writeProjectFile() + await project.openAsync(url) + await flush() + workflow.wizard.basename = "" + + XCTAssertFalse(project.canSave) + let saved = await project.saveProjectAsync() + XCTAssertFalse(saved) + // The file keeps its original basename. + let reloaded = try ICCeryProject.load(from: url) + XCTAssertEqual(reloaded.basename, "job") + } + + func testSaveRefusesEmptyCwd() async throws { + let url = try writeProjectFile() + await project.openAsync(url) + await flush() + workflow.wizard.workingDirectory = nil + + XCTAssertFalse(project.canSave) + let saved = await project.saveProjectAsync() + XCTAssertFalse(saved) + } + + func testCalBasenameRefusedWithoutPersistedOriginal() async throws { + let url = try writeProjectFile() + await project.openAsync(url) + await flush() + // Bare CAL_ stem — no persisted original to trust (R11). + workflow.wizard.basename = "CAL_job" + workflow.wizard.calibrationOriginalBasename = "" + + XCTAssertTrue(project.canSave) // enabled so the banner shows + let saved = await project.saveProjectAsync() + + XCTAssertFalse(saved) + XCTAssertTrue( + workflow.wizard.notice?.text.contains( + "Finish or exit calibration") == true) + let raw = try String(contentsOf: url, encoding: .utf8) + XCTAssertFalse(raw.contains("CAL_")) + XCTAssertEqual(try ICCeryProject.load(from: url).basename, "job") + } + + func testCalBasenameSavesPersistedOriginal() async throws { + let url = try writeProjectFile() + await project.openAsync(url) + await flush() + workflow.wizard.basename = "CAL_job" + workflow.wizard.calibrationOriginalBasename = "job" + + let saved = await project.saveProjectAsync() + + XCTAssertTrue(saved) + let reloaded = try ICCeryProject.load(from: url) + XCTAssertEqual(reloaded.basename, "job") + XCTAssertFalse(rawContainsCal(url)) + } + + private func rawContainsCal(_ url: URL) -> Bool { + guard let raw = try? String(contentsOf: url, encoding: .utf8) else { + return false + } + return raw.contains("CAL_") + } + + func testSaveCapturesLiveFields() async throws { + let url = try writeProjectFile() + await project.openAsync(url) + await flush() + workflow.print.printers = [ + Printer(name: "Mock_Queue", displayName: "Mock Queue Display") + ] + workflow.print.selectedPrinter = "Mock_Queue" + workflow.selectedPresetID = "preset-std-rgb" + workflow.profile.calibrationFile = "/tmp/live.cal" + + XCTAssertTrue(project.isDirty) + await flush() // title recompute is deferred one main turn + XCTAssertTrue(project.windowTitle.hasSuffix("•")) + + let saved = await project.saveProjectAsync() + XCTAssertTrue(saved) + let reloaded = try ICCeryProject.load(from: url) + XCTAssertEqual(reloaded.printerID, "Mock_Queue") + XCTAssertEqual( + reloaded.printerDisplayName, "Mock Queue Display") + XCTAssertEqual(reloaded.presetID, "preset-std-rgb") + XCTAssertEqual(reloaded.calibrationURL, "/tmp/live.cal") + XCTAssertFalse(project.isDirty) + } + + // MARK: - New / Close + + func testNewClearsBasenameKeepsArtefacts() async throws { + try artefact("ti3") + let url = try writeProjectFile() + await project.openAsync(url) + await flush() + XCTAssertTrue(workflow.wizard.isUnlocked(.buildProfile)) + + project.requestNew() + XCTAssertTrue(project.showingNewAlert) + project.confirmNew() + + XCTAssertEqual(workflow.wizard.basename, "") + XCTAssertEqual(workflow.targetBasename, "") + XCTAssertNil(project.projectURL) + XCTAssertEqual(project.windowTitle, "ICCery") + XCTAssertEqual(workflow.media.selectedRecipeID, "none") + XCTAssertEqual(workflow.wizard.sessionMode, .profile) + // The artefact is untouched; the stepper just can't see it. + XCTAssertTrue( + FileManager.default.fileExists( + atPath: cwd.appendingPathComponent("job.ti3").path)) + XCTAssertFalse(workflow.wizard.isUnlocked(.buildProfile)) + } + + func testNewWhileChildLiveBannersInstead() async throws { + workflow.measurement.isChartreadRunning = true + project.requestNew() + XCTAssertFalse(project.showingNewAlert) + XCTAssertNotNil(workflow.wizard.notice) + } + + func testDirtyNewShowsDirtyAlert() async throws { + let url = try writeProjectFile() + await project.openAsync(url) + await flush() + workflow.wizard.basename = "changed" + + project.requestNew() + XCTAssertFalse(project.showingNewAlert) + XCTAssertTrue(project.showingDirtyAlert) + + // Don't Save → New proceeds. + project.resolveDirty(save: false) + XCTAssertEqual(workflow.wizard.basename, "") + XCTAssertNil(project.projectURL) + } + + func testCloseKeepsLiveSession() async throws { + try artefact("ti1") + let url = try writeProjectFile() + await project.openAsync(url) + await flush() + + project.requestClose() + + XCTAssertNil(project.projectURL) + XCTAssertEqual(workflow.wizard.basename, "job") + XCTAssertEqual(workflow.wizard.workingDirectory?.path, cwd.path) + XCTAssertEqual(project.windowTitle, "ICCery") + } + + func testDirtyCloseSaveThenCloses() async throws { + let url = try writeProjectFile() + await project.openAsync(url) + await flush() + workflow.wizard.basename = "renamed" + + project.requestClose() + XCTAssertTrue(project.showingDirtyAlert) + + project.resolveDirty(save: true) + try await Task.sleep(nanoseconds: 300_000_000) + + XCTAssertNil(project.projectURL) + XCTAssertEqual(try ICCeryProject.load(from: url).basename, + "renamed") + } + + // MARK: - Report / recents + + func testSaveReportWritesMarkdown() async throws { + try artefact("ti1") + try artefact("ti3") + let url = try writeProjectFile(lastVerification: true) + await project.openAsync(url) + await flush() + + project.saveReport() + try await Task.sleep(nanoseconds: 300_000_000) + + let report = cwd.appendingPathComponent("job-report.md") + XCTAssertTrue(FileManager.default.fileExists(atPath: report.path)) + let text = try String(contentsOf: report, encoding: .utf8) + XCTAssertTrue(text.contains("job.ti1 | exists")) + XCTAssertTrue(text.contains("job.ti2 | missing")) + XCTAssertTrue(text.contains("job.ti3 | exists")) + XCTAssertTrue(text.contains("avg ΔE₀₀ 0.70")) + XCTAssertTrue( + workflow.wizard.notice?.text.contains("job-report.md") == true) + } + + func testOpenPushesRecent() async throws { + let url = try writeProjectFile() + await project.openAsync(url) + try await Task.sleep(nanoseconds: 300_000_000) + + let recents = project.recents + XCTAssertEqual(recents.first?.path, url.path) + XCTAssertEqual(recents.first?.name, "Fixture Project") + } + + func testOpenRecentMissingFileDropped() async throws { + let store = env.environment.recentProjectsStore + let ghost = env.root.appendingPathComponent("ghost.icceryproj") + try await store.add(url: ghost, name: "Ghost") + + project.openRecent( + RecentProjectEntry(name: "Ghost", path: ghost.path)) + try await Task.sleep(nanoseconds: 300_000_000) + + XCTAssertTrue( + workflow.wizard.notice?.text.contains("gone") == true) + let loaded = try await store.load() + XCTAssertTrue(loaded.isEmpty) + XCTAssertNil(project.projectURL) + } +} diff --git a/Tests/ICCeryCoreTests/RecentProjectsStoreTests.swift b/Tests/ICCeryCoreTests/RecentProjectsStoreTests.swift new file mode 100644 index 0000000..e73cf85 --- /dev/null +++ b/Tests/ICCeryCoreTests/RecentProjectsStoreTests.swift @@ -0,0 +1,134 @@ +import Foundation +import XCTest +@testable import ICCeryCore + +/// Issue #149 — `recent_projects.json`: cap 20, newest first, dedupe +/// by path, missing files pruned on submenu build, Clear Menu wipes the +/// recents file only, corrupt file kept (R12). +final class RecentProjectsStoreTests: XCTestCase { + + private var root: URL! + + override func setUp() async throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-recents-\(UUID().uuidString)") + try FileManager.default.createDirectory( + at: root, withIntermediateDirectories: true) + } + + override func tearDown() async throws { + if let root { try? FileManager.default.removeItem(at: root) } + root = nil + } + + private var storeURL: URL { + root.appendingPathComponent("recent_projects.json") + } + + private func projectFile(_ name: String) throws -> URL { + let url = root.appendingPathComponent("\(name).icceryproj") + try "{}".write(to: url, atomically: true, encoding: .utf8) + return url + } + + func testCapTwentyNewestFirst() async throws { + let store = RecentProjectsStore(url: storeURL) + for i in 0..<25 { + let url = try projectFile("p\(i)") + try await store.add(url: url, name: "P\(i)") + } + let all = try await store.load() + XCTAssertEqual(all.count, 20) + XCTAssertEqual(all.first?.name, "P24") + XCTAssertEqual(all.last?.name, "P5") + } + + func testAddDeduplicatesByPath() async throws { + let store = RecentProjectsStore(url: storeURL) + let url = try projectFile("dup") + try await store.add(url: url, name: "First") + try await store.add(url: try projectFile("other"), name: "Other") + try await store.add(url: url, name: "Second") + + let all = try await store.load() + XCTAssertEqual(all.count, 2) + XCTAssertEqual(all.first?.name, "Second") + XCTAssertEqual( + all.filter { $0.path == url.path }.count, 1) + } + + func testPruneMissingDropsGoneFiles() async throws { + let store = RecentProjectsStore(url: storeURL) + let live = try projectFile("live") + try await store.add(url: live, name: "Live") + try await store.add( + url: root.appendingPathComponent("gone.icceryproj"), + name: "Gone") + + let pruned = try await store.pruneMissing() + XCTAssertEqual(pruned.count, 1) + XCTAssertEqual(pruned.first?.name, "Live") + + // The rewrite persisted the drop. + let reloaded = try await RecentProjectsStore(url: storeURL).load() + XCTAssertEqual(reloaded.count, 1) + } + + func testRemoveByPath() async throws { + let store = RecentProjectsStore(url: storeURL) + let url = try projectFile("a") + try await store.add(url: url, name: "A") + let removed = try await store.remove(path: url.path) + XCTAssertTrue(removed) + let loaded = try await store.load() + XCTAssertTrue(loaded.isEmpty) + let second = try await store.remove(path: url.path) + XCTAssertFalse(second) + } + + func testClearWipesRecentsFileOnly() async throws { + let store = RecentProjectsStore(url: storeURL) + let projectURL = try projectFile("keep") + try await store.add(url: projectURL, name: "Keep") + + try await store.clear() + + let loaded = try await store.load() + XCTAssertTrue(loaded.isEmpty) + // The `.icceryproj` itself survives Clear Menu. + XCTAssertTrue( + FileManager.default.fileExists(atPath: projectURL.path)) + let raw = try String(contentsOf: storeURL, encoding: .utf8) + XCTAssertTrue(raw.contains("[")) + } + + func testCorruptFileThrowsAndKeepsBytes() async throws { + try "not json".write( + to: storeURL, atomically: true, encoding: .utf8) + let store = RecentProjectsStore(url: storeURL) + + await assertAsyncThrows(expectedType: DecodingError.self) { + try await store.load() + } + XCTAssertEqual( + try String(contentsOf: storeURL, encoding: .utf8), "not json") + } + + func testEntryStoresBookmarkAndPath() async throws { + let store = RecentProjectsStore(url: storeURL) + let url = try projectFile("b") + try await store.add(url: url, name: "B") + let entry = try await store.load().first + XCTAssertEqual(entry?.path, url.path) + XCTAssertNotNil(entry?.bookmark) + XCTAssertFalse(entry?.bookmarkHash.isEmpty ?? true) + } + + func testBookmarkHashStableAcrossStores() async throws { + let a = RecentProjectEntry(name: "x", path: "/tmp/p.icceryproj") + let b = RecentProjectEntry(name: "y", path: "/tmp/p.icceryproj") + XCTAssertEqual(a.bookmarkHash, b.bookmarkHash) + let c = RecentProjectEntry(name: "z", path: "/tmp/q.icceryproj") + XCTAssertNotEqual(a.bookmarkHash, c.bookmarkHash) + } +} diff --git a/Tests/ICCeryCoreTests/SettingsTests.swift b/Tests/ICCeryCoreTests/SettingsTests.swift new file mode 100644 index 0000000..c8ae705 --- /dev/null +++ b/Tests/ICCeryCoreTests/SettingsTests.swift @@ -0,0 +1,183 @@ +import Foundation +import XCTest +@testable import ICCeryCore + +private func tempStoreURL() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-settings-\(UUID().uuidString)") + .appendingPathComponent("settings.json") +} + +final class AppSettingsTests: XCTestCase { + func testDefaults() { + let s = AppSettings.default + XCTAssertNil(s.argyllBinaryDir) + XCTAssertNil(s.defaultInstrument) + XCTAssertNil(s.logLevel) + XCTAssertEqual(s.deltaEGoodMax, 2.0) + XCTAssertEqual(s.deltaEWarningMax, 5.0) + XCTAssertTrue(s.customPresets.isEmpty) + XCTAssertFalse(s.enableI1Pro2Leds) + XCTAssertEqual(s.calibrationStaleDays, 30) + XCTAssertEqual(s.defaultInstallLocation, .user) + XCTAssertTrue(s.askBeforeOverwriteProfile) + XCTAssertFalse(s.openColorPanelAfterInstall) + XCTAssertTrue(s.isValid) + } + + func testNegativeThresholds() { + var s = AppSettings.default + s.deltaEGoodMax = -1 + XCTAssertEqual(s.validate(), [AppSettings.errorNegativeDeltaE]) + s.deltaEGoodMax = 2.0 + s.deltaEWarningMax = -0.5 + // -0.5 < 0 → negative error; good(2.0) >= warn(-0.5) → order error too + XCTAssertTrue(s.validate() == [ + AppSettings.errorNegativeDeltaE, + AppSettings.errorThresholdOrder, + ]) + } + + func testGoodMustBeStrictlyLessThanWarning() { + var s = AppSettings.default + s.deltaEGoodMax = 5.0 + XCTAssertEqual(s.validate(), [AppSettings.errorThresholdOrder]) + s.deltaEGoodMax = 6.0 + XCTAssertEqual(s.validate(), [AppSettings.errorThresholdOrder]) + s.deltaEGoodMax = 4.9 + XCTAssertTrue(s.isValid) + } + + func testSnakeCaseKeys() throws { + let s = AppSettings.default + let data = try JSONEncoder().encode(s) + let json = String(data: data, encoding: .utf8)! + XCTAssertTrue(json.contains("\"delta_e_good_max\"")) + XCTAssertTrue(json.contains("\"default_install_location\"")) + XCTAssertTrue(json.contains("\"enable_i1pro2_leds\"")) + } +} + +final class SettingsStoreTests: XCTestCase { + func testRoundTrip() throws { + let url = tempStoreURL() + let store = SettingsStore(fileURL: url) + var s = AppSettings.default + s.deltaEGoodMax = 1.5 + s.defaultInstrument = "p3" + try store.save(s) + XCTAssertEqual(store.load(), s) + } + + func testCorruptJsonFallsBackToDefaults() throws { + let url = tempStoreURL() + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true + ) + try "{ not json".write(to: url, atomically: true, encoding: .utf8) + XCTAssertEqual(SettingsStore(fileURL: url).load(), .default) + } + + func testMissingFileReturnsDefaults() { + XCTAssertEqual(SettingsStore(fileURL: tempStoreURL()).load(), .default) + } + + func testInvalidSettingsNotPersisted() throws { + let url = tempStoreURL() + let store = SettingsStore(fileURL: url) + var s = AppSettings.default + s.deltaEGoodMax = 9.0 // >= warning 5.0 + XCTAssertThrowsError(try store.save(s)) { error in XCTAssertTrue(error is SettingsStore.SettingsError) } + XCTAssertFalse(FileManager.default.fileExists(atPath: url.path)) + } + + func testInvalidSaveOverValidFilePreservesBytesAndPostsNothing() throws { + let url = tempStoreURL() + let store = SettingsStore(fileURL: url) + var valid = AppSettings.default + valid.deltaEGoodMax = 1.5 + try store.save(valid) + let originalBytes = try Data(contentsOf: url) + + var fired = false + let token = NotificationCenter.default.addObserver( + forName: SettingsStore.settingsDidChange, object: nil, queue: nil + ) { _ in fired = true } + defer { NotificationCenter.default.removeObserver(token) } + + var invalid = AppSettings.default + invalid.deltaEGoodMax = 9.0 + XCTAssertThrowsError(try store.save(invalid)) { error in XCTAssertTrue(error is SettingsStore.SettingsError) } + XCTAssertEqual(try Data(contentsOf: url), originalBytes) + XCTAssertFalse(fired) + XCTAssertEqual(store.load(), valid) + } + + func testSavePostsNotification() async throws { + let url = tempStoreURL() + let store = SettingsStore(fileURL: url) + var fired = false + let token = NotificationCenter.default.addObserver( + forName: SettingsStore.settingsDidChange, object: nil, queue: nil + ) { _ in fired = true } + defer { NotificationCenter.default.removeObserver(token) } + try store.save(.default) + XCTAssertTrue(fired) + } +} + +final class LogSinkTests: XCTestCase { + private func tempLog() -> (URL, LogSink) { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-log-\(UUID().uuidString)") + .appendingPathComponent("iccery.log") + return (url, LogSink(fileURL: url)) + } + + func testWritesFormattedLines() { + let (url, sink) = tempLog() + sink.setLevel(.debug) + sink.write(level: .info, category: "test", message: "hello") + let content = (try? String(contentsOf: url, encoding: .utf8)) ?? "" + XCTAssertTrue(content.contains("[INFO] test: hello")) + } + + func testLevelFilteringIsLive() { + let (url, sink) = tempLog() + sink.setLevel(.error) + sink.write(level: .info, category: "t", message: "hidden") + sink.setLevel(.info) // runtime change, no restart (#158) + sink.write(level: .info, category: "t", message: "shown") + let content = (try? String(contentsOf: url, encoding: .utf8)) ?? "" + XCTAssertFalse(content.contains("hidden")) + XCTAssertTrue(content.contains("shown")) + } + + func testRotatesAt5MiBKeeping5Segments() throws { + let (url, sink) = tempLog() + sink.setLevel(.trace) + // Pre-fill the active log just under the cap, then cross it. + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true + ) + let big = String(repeating: "x", count: Int(LogSink.maxSegmentBytes)) + try big.write(to: url, atomically: true, encoding: .utf8) + + sink.write(level: .info, category: "t", message: "trigger rotation") + XCTAssertTrue(FileManager.default.fileExists( + atPath: url.appendingPathExtension("1").path + )) + // Active log is small again. + let size = try FileManager.default.attributesOfItem( + atPath: url.path + )[.size] as? UInt64 + XCTAssertTrue((size ?? 0) < 1024) + } + + func testTailExcerptCaps() throws { + let (url, sink) = tempLog() + sink.setLevel(.debug) + sink.write(level: .info, category: "t", message: "line") + XCTAssertTrue(sink.tailExcerpt(maxBytes: 8).count <= 8) + } +} diff --git a/Tests/ICCeryCoreTests/SpotReadArgsTests.swift b/Tests/ICCeryCoreTests/SpotReadArgsTests.swift new file mode 100644 index 0000000..49cb326 --- /dev/null +++ b/Tests/ICCeryCoreTests/SpotReadArgsTests.swift @@ -0,0 +1,46 @@ +import XCTest +@testable import ICCeryCore + +/// `SpotReadArgs` goldens (issue #148): +/// `spotread -v -e [-c port] [-Y l]` — never `-u`, never a basename, +/// `-c` only for ports > 1, `-Y l` only when the LED setting is on. +final class SpotReadArgsTests: XCTestCase { + + func testAutoOmitsPort() { + let args = SpotReadArgs.build(config: SpotReadConfig()) + XCTAssertEqual(args, ["-v", "-e"]) + } + + func testPort1OmitsC() { + let args = SpotReadArgs.build(config: SpotReadConfig(selectedPort: 1)) + XCTAssertEqual(args, ["-v", "-e"]) + } + + func testPort2IncludesC() { + let args = SpotReadArgs.build(config: SpotReadConfig(selectedPort: 2)) + XCTAssertEqual(args, ["-v", "-e", "-c", "2"]) + } + + func testLedFlag() { + let args = SpotReadArgs.build(config: SpotReadConfig(enableLEDs: true)) + XCTAssertEqual(args, ["-v", "-e", "-Y", "l"]) + } + + func testPortAndLeds() { + let args = SpotReadArgs.build( + config: SpotReadConfig(selectedPort: 2, enableLEDs: true)) + XCTAssertEqual(args, ["-v", "-e", "-c", "2", "-Y", "l"]) + } + + func testNeverU() { + for config in [ + SpotReadConfig(), + SpotReadConfig(selectedPort: 2), + SpotReadConfig(enableLEDs: true), + SpotReadConfig(selectedPort: 3, enableLEDs: true), + ] { + XCTAssertFalse(SpotReadArgs.build(config: config).contains("-u")) + XCTAssertFalse(SpotReadArgs.build(config: config).contains("-d")) + } + } +} diff --git a/Tests/ICCeryCoreTests/SpotReadClassifierTests.swift b/Tests/ICCeryCoreTests/SpotReadClassifierTests.swift new file mode 100644 index 0000000..04062ac --- /dev/null +++ b/Tests/ICCeryCoreTests/SpotReadClassifierTests.swift @@ -0,0 +1,91 @@ +import XCTest +@testable import ICCeryCore + +/// `SpotReadClassifier` / `SpotReadParser` against real `spotread` +/// phrasing (issue #148). The calibration-tile line classifies through +/// `ChartreadClassifier`; the spot prompt and its continuation lines +/// need the spot-specific matchers. +final class SpotReadClassifierTests: XCTestCase { + + // Real `spotread` stdout (calibration then spot prompt). + private let calibrateLines = [ + "Spot read needs a calibration before continuing", + "Place instrument on spot reading white calibration tile,", + " and then hit any key to continue,", + "or hit Esc or Q to abort:", + ] + private let spotPromptLines = [ + "Place instrument on a spot to be measured,", + " and hit a key to take a reading,", + "or hit Esc or Q to abort:", + ] + + func testCalibrationPrompt() { + var state = ChartreadState.idle + for line in calibrateLines { + state = SpotReadClassifier.classify(line: line, previousState: state).state + } + XCTAssertEqual(state, .calibrating) + } + + func testSpotPromptIsAwaitingTrigger() { + var state = ChartreadState.calibrating + for line in spotPromptLines { + state = SpotReadClassifier.classify(line: line, previousState: state).state + } + XCTAssertEqual(state, .awaitingStrip) + } + + func testAbortLineDoesNotBecomeWarning() { + // "or hit Esc or Q to abort:" contains no '?' but does contain + // "abort" — it must stay on the current prompt, never flip to + // a warning. + let r = SpotReadClassifier.classify( + line: "or hit Esc or Q to abort:", previousState: .awaitingStrip) + XCTAssertEqual(r.state, .awaitingStrip) + } + + func testParseResultLine() throws { + let parsed = SpotReadParser.parse( + line: "Result is XYZ: 18.51 20.05 15.71, D50 Lab: 51.9 -8.3 12.2") + let lab = try XCTUnwrap(parsed?.lab) + XCTAssertEqual(lab.l, 51.9, accuracy: 0.001) + XCTAssertEqual(lab.a, -8.3, accuracy: 0.001) + XCTAssertEqual(lab.b, 12.2, accuracy: 0.001) + let xyz = try XCTUnwrap(parsed?.xyz) + XCTAssertEqual(xyz.x, 18.51, accuracy: 0.001) + XCTAssertEqual(xyz.y, 20.05, accuracy: 0.001) + XCTAssertEqual(xyz.z, 15.71, accuracy: 0.001) + } + + func testParseLabOnlyLine() throws { + let parsed = SpotReadParser.parse(line: "Result is Lab: 40.0 1.2 -3.4") + let lab = try XCTUnwrap(parsed?.lab) + XCTAssertEqual(lab.l, 40.0, accuracy: 0.001) + XCTAssertNil(parsed?.xyz) + } + + func testNonSampleLineParsesNil() { + XCTAssertNil(SpotReadParser.parse(line: "Place instrument on a spot to be measured,")) + XCTAssertNil(SpotReadParser.parse(line: "Calibration successful.")) + XCTAssertNil(SpotReadParser.parse(line: "")) + } + + func testDeltaEBetweenFixtures() { + let a = SpotReadParser.parse( + line: "Result is XYZ: 18.51 20.05 15.71, D50 Lab: 51.9 -8.3 12.2")!.lab + let b = SpotReadParser.parse( + line: "Result is XYZ: 19.00 20.50 16.00, D50 Lab: 52.3 -8.0 12.6")!.lab + XCTAssertEqual(ColorDifference.deltaE00(a, a), 0, accuracy: 0.0001) + XCTAssertGreaterThan(ColorDifference.deltaE00(a, b), 0) + XCTAssertEqual( + ColorDifference.classify(deltaE: 1.0, goodMax: 2.0, warningMax: 5.0), + .good) + XCTAssertEqual( + ColorDifference.classify(deltaE: 3.0, goodMax: 2.0, warningMax: 5.0), + .warning) + XCTAssertEqual( + ColorDifference.classify(deltaE: 6.0, goodMax: 2.0, warningMax: 5.0), + .bad) + } +} diff --git a/Tests/ICCeryCoreTests/SpotReadViewModelTests.swift b/Tests/ICCeryCoreTests/SpotReadViewModelTests.swift new file mode 100644 index 0000000..c6a4ffc --- /dev/null +++ b/Tests/ICCeryCoreTests/SpotReadViewModelTests.swift @@ -0,0 +1,250 @@ +import Foundation +import XCTest +@testable import ICCeryCore +@testable import ICCery + +/// Issue #148 — `SpotReadViewModel` under an isolated +/// `TestAppEnvironment` with per-test mock `spotread`/`instlist` +/// sidecars in a temp bin dir. +@MainActor +final class SpotReadViewModelTests: XCTestCase { + + private var env: TestAppEnvironment! + private var workflow: TargetWorkflowViewModel! + private var spot: SpotReadViewModel! + private var binDir: URL! + + override func setUp() async throws { + binDir = FileManager.default.temporaryDirectory + .appendingPathComponent("spot-bin-\(UUID().uuidString)") + try FileManager.default.createDirectory( + at: binDir, withIntermediateDirectories: true) + // `bundledArgyllRoot` also points at the temp bin dir so the + // real sidecars copied into the host app by the build phase do + // not mask a missing `spotread` in the override dir. + env = try TestAppEnvironment.make( + argyllBinDir: binDir, bundledArgyllRoot: binDir) + workflow = TargetWorkflowViewModel(environment: env.environment) + spot = workflow.spotRead + workflow.wizard.setTarget(basename: "spot", workingDirectory: env.root) + } + + override func tearDown() async throws { + spot?.stopIfNeeded() + try? await Task.sleep(nanoseconds: 700_000_000) + env?.cleanup() + try? FileManager.default.removeItem(at: binDir) + env = nil + workflow = nil + spot = nil + binDir = nil + } + + // MARK: - Helpers + + private func writeMock(_ name: String, _ body: String) throws { + let url = binDir.appendingPathComponent(name) + try body.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], ofItemAtPath: url.path) + } + + private func installInstlist(_ devicesJson: String) throws { + try writeMock("instlist", """ + #!/bin/sh + printf '%s' '\(devicesJson)' + exit 0 + """) + } + + private func installSpotread(lab: String = "51.9 -8.3 12.2") throws { + try writeMock("spotread", """ + #!/bin/sh + echo "Spot read needs a calibration before continuing" + echo "Place instrument on spot reading white calibration tile," + echo " and then hit any key to continue," + echo "or hit Esc or Q to abort:" + IFS= read -r line || exit 0 + echo "Calibration successful." + while true; do + echo "Place instrument on a spot to be measured," + echo " and hit a key to take a reading," + echo "or hit Esc or Q to abort:" + IFS= read -r line || exit 0 + case "$line" in + q*|Q*) exit 0 ;; + esac + echo "Result is XYZ: 18.51 20.05 15.71, D50 Lab: \(lab)" + done + """) + } + + private func waitFor( + _ predicate: @escaping () async -> Bool, + timeout: TimeInterval = 10 + ) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if await predicate() { return true } + try? await Task.sleep(nanoseconds: 50_000_000) + } + return await predicate() + } + + private func waitForSync( + _ predicate: @escaping () -> Bool, + timeout: TimeInterval = 10 + ) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if predicate() { return true } + try? await Task.sleep(nanoseconds: 50_000_000) + } + return predicate() + } + + // MARK: - Missing sidecar + + func testMissingSidecarNoSpawn() async throws { + // bin dir has no spotread → resolver override misses and the + // bundled path does not exist either. + XCTAssertFalse(spot.sidecarAvailable) + spot.sheetOpened() + XCTAssertEqual(workflow.wizard.notice?.kind, .error) + + spot.start() + XCTAssertEqual(spot.lastError, "spotread sidecar missing — run fetch-argyll") + XCTAssertFalse(spot.isRunning) + let running = await env.environment.runner.processManager.isRunning(ProcessID.spotread) + XCTAssertFalse(running) + } + + // MARK: - defaultInstrument seeding + + func testDefaultInstrumentSeedsPicker() async throws { + try installSpotread() + try installInstlist(""" + {"event":"instruments","devices":[ + {"port":1,"name":"X-Rite i1Pro","type":"i1"}, + {"port":2,"name":"ColorMunki Photo","type":"CM"}]} + """) + var settings = env.environment.settingsStore.load() + settings.defaultInstrument = "CM" + try env.environment.settingsStore.save(settings) + + spot.sheetOpened() + let ok1 = await waitFor { !self.spot.isDetecting && !self.spot.instruments.isEmpty } + XCTAssertTrue(ok1) + guard case .device(let device) = spot.selectedInstrument else { + XCTFail("Expected device selection, got .auto") + return + } + XCTAssertEqual(device.port, 2) + XCTAssertFalse(spot.defaultMissing) + } + + func testDefaultInstrumentNotPresent() async throws { + try installSpotread() + try installInstlist(""" + {"event":"instruments","devices":[ + {"port":1,"name":"X-Rite i1Pro","type":"i1"}]} + """) + var settings = env.environment.settingsStore.load() + settings.defaultInstrument = "51" // Spyder X — absent + try env.environment.settingsStore.save(settings) + + spot.sheetOpened() + let ok2 = await waitFor { !self.spot.isDetecting } + XCTAssertTrue(ok2) + XCTAssertEqual(spot.selectedInstrument, .auto) + XCTAssertTrue(spot.defaultMissing) + } + + func testSetDefaultToggleWritesSettingsOnly() async throws { + try installSpotread() + try installInstlist(""" + {"event":"instruments","devices":[ + {"port":2,"name":"ColorMunki Photo","type":"CM"}]} + """) + spot.sheetOpened() + let ok3 = await waitFor { !self.spot.instruments.isEmpty } + XCTAssertTrue(ok3) + spot.selectedInstrument = .device(spot.instruments[0]) + spot.applyDefaultToggle(true) + XCTAssertEqual(env.environment.settingsStore.load().defaultInstrument, "CM") + // printtarg instrument is untouched (R15). + XCTAssertEqual(workflow.instrument, .i1) + spot.applyDefaultToggle(false) + XCTAssertNil(env.environment.settingsStore.load().defaultInstrument) + } + + // MARK: - Exclusive lease + + func testDuplicateSpotreadIdRejected() async throws { + try writeMock("spotread", "#!/bin/sh\nsleep 30\n") + let pm = env.environment.runner.processManager + let bin = env.environment.runner.binaryResolver.resolve("spotread") + try await pm.runStreaming(id: ProcessID.spotread, binary: bin, arguments: []) + let ok4 = await pm.isRunning(ProcessID.spotread) + XCTAssertTrue(ok4) + do { + try await pm.runStreaming(id: ProcessID.spotread, binary: bin, arguments: []) + XCTFail("Expected duplicateID") + } catch let error as ProcessError { + guard case .duplicateID(let id) = error else { + XCTFail("Expected duplicateID, got \(error)") + return + } + XCTAssertEqual(id, "spotread") + } + await pm.kill(id: ProcessID.spotread) + } + + // MARK: - Session + + func testMockSpotreadProducesSample() async throws { + try installSpotread() + spot.sheetOpened() + spot.start() + let ok5 = await waitFor { self.spot.state == .calibrating } + XCTAssertTrue(ok5, "expected calibrating prompt") + + spot.calibrate() + let ok6 = await waitFor { self.spot.state == .awaitingStrip } + XCTAssertTrue(ok6, "expected read prompt") + + spot.trigger() + let ok7 = await waitFor { !self.spot.samples.isEmpty } + XCTAssertTrue(ok7, "expected a sample") + let sample = try XCTUnwrap(spot.samples.first) + XCTAssertEqual(sample.lab.l, 51.9, accuracy: 0.001) + XCTAssertNotNil(sample.xyz) + XCTAssertNil(spot.displayedDeltaE) // first sample hides ΔE + + spot.trigger() + let ok8 = await waitFor { self.spot.samples.count >= 2 } + XCTAssertTrue(ok8, "expected a second sample") + XCTAssertNotNil(spot.displayedDeltaE) + XCTAssertEqual(spot.displayedDeltaE ?? -1, 0, accuracy: 0.0001) // identical Lab + + spot.stopIfNeeded() + XCTAssertFalse(spot.isRunning) + let deadline = Date().addingTimeInterval(5) + var alive = await env.environment.runner.processManager.isRunning(ProcessID.spotread) + while alive && Date() < deadline { + try await Task.sleep(nanoseconds: 100_000_000) + alive = await env.environment.runner.processManager.isRunning(ProcessID.spotread) + } + XCTAssertFalse(alive, "spotread child must not outlive Stop") + } + + func testStartBlockedWhileChartreadRunning() async throws { + try installSpotread() + // Simulate a live Stage 3 chartread child. + workflow.measurement.isChartreadRunning = true + spot.sheetOpened() + spot.start() + XCTAssertEqual(spot.lastError, "Stop the Stage 3 chart read first.") + XCTAssertFalse(spot.isRunning) + } +} diff --git a/Tests/ICCeryCoreTests/TargenTests.swift b/Tests/ICCeryCoreTests/TargenTests.swift new file mode 100644 index 0000000..ce2f2d8 --- /dev/null +++ b/Tests/ICCeryCoreTests/TargenTests.swift @@ -0,0 +1,345 @@ +import XCTest +import Foundation +@testable import ICCeryCore + +final class TargenArgsTests: XCTestCase { + + func testRgbBaseline() throws { + let config = TargenConfig( + colourSpace: .rgb, + patchCount: 800, + whitePatches: 4, + blackPatches: 4, + basename: "test_rgb" + ) + let args = try TargenArgs.build(config: config) + XCTAssertEqual(args, ["-v", "-d", "2", "-f", "800", "-e", "4", "-B", "4", "test_rgb"]) + XCTAssertFalse(args.contains("-u")) + } + + func testCmykBaseline() throws { + let config = TargenConfig( + colourSpace: .cmyk, + patchCount: 1500, + whitePatches: 4, + blackPatches: 0, + basename: "test_cmyk" + ) + let args = try TargenArgs.build(config: config) + XCTAssertEqual(args, ["-v", "-d", "4", "-f", "1500", "-e", "4", "-B", "0", "test_cmyk"]) + } + + func testCustomPatchCount() throws { + let config = TargenConfig( + colourSpace: .rgb, + patchCount: 2500, + whitePatches: 4, + blackPatches: 4, + basename: "custom_patches" + ) + let args = try TargenArgs.build(config: config) + XCTAssertTrue(args.contains("-f")) + XCTAssertEqual(args[args.firstIndex(of: "-f")! + 1], "2500") + } + + func testAllAdvancedFlags() throws { + let config = TargenConfig( + colourSpace: .cmyk, + patchCount: 1200, + whitePatches: 6, + blackPatches: 2, + greySteps: 12, + singleChannelSteps: 8, + neutralSteps: 6, + neutralConcentration: 0.75, + preconditioningProfile: "/path/to/profile.icc", + ofpsHighQuality: true, + ofpsAdaptation: 0.10, + fullSpreadAlgorithm: .target, + totalInkLimit: 320, + darkEmphasis: 1.50, + devicePower: 2.0, + basename: "advanced_cmyk" + ) + let args = try TargenArgs.build(config: config) + let expected = [ + "-v", "-d", "4", + "-f", "1200", + "-e", "6", + "-B", "2", + "-g", "12", + "-s", "8", + "-n", "6", + "-N", "0.75", + "-c", "/path/to/profile.icc", + "-G", + "-A", "0.10", + "-t", + "-l", "320", + "-V", "1.50", + "-p", "2.00", + "advanced_cmyk" + ] + XCTAssertEqual(args, expected) + } + + func testRgbIgnoresInkLimit() throws { + let config = TargenConfig( + colourSpace: .rgb, + patchCount: 800, + whitePatches: 4, + blackPatches: 4, + totalInkLimit: 300, + basename: "rgb_no_ink" + ) + let args = try TargenArgs.build(config: config) + XCTAssertFalse(args.contains("-l")) + } + + func testNeutralConcentrationOmittedWhenDefault() throws { + let config = TargenConfig( + colourSpace: .rgb, + patchCount: 800, + whitePatches: 4, + blackPatches: 4, + neutralConcentration: 0.5005, + basename: "n_default" + ) + let args = try TargenArgs.build(config: config) + XCTAssertFalse(args.contains("-N")) + } + + func testAdaptationEmittedAtPointOne() throws { + let config = TargenConfig( + colourSpace: .rgb, + patchCount: 800, + whitePatches: 4, + blackPatches: 4, + ofpsAdaptation: 0.10, + basename: "a_flag" + ) + let args = try TargenArgs.build(config: config) + XCTAssertTrue(args.contains("-A")) + XCTAssertEqual(args[args.firstIndex(of: "-A")! + 1], "0.10") + } + + func testOfpsEmitsNoFlag() throws { + let config = TargenConfig( + colourSpace: .rgb, + patchCount: 800, + whitePatches: 4, + blackPatches: 4, + fullSpreadAlgorithm: .ofps, + basename: "ofps_test" + ) + let args = try TargenArgs.build(config: config) + XCTAssertFalse(args.contains("ofps")) + XCTAssertFalse(args.contains("-t")) + } + + func testDarkEmphasisAndPowerOmittedWhenOne() throws { + let config = TargenConfig( + colourSpace: .rgb, + patchCount: 800, + whitePatches: 4, + blackPatches: 4, + darkEmphasis: 1.0, + devicePower: 1.0, + basename: "defaults_omitted" + ) + let args = try TargenArgs.build(config: config) + XCTAssertFalse(args.contains("-V")) + XCTAssertFalse(args.contains("-p")) + } + + func testWhitespacePreconditioner() throws { + let config = TargenConfig( + colourSpace: .rgb, + patchCount: 800, + whitePatches: 4, + blackPatches: 4, + preconditioningProfile: " \n\t ", + basename: "ws_pre" + ) + let args = try TargenArgs.build(config: config) + XCTAssertFalse(args.contains("-c")) + } + + func testPreconditionerTrimmed() throws { + let config = TargenConfig( + colourSpace: .rgb, + patchCount: 800, + whitePatches: 4, + blackPatches: 4, + preconditioningProfile: " /path/to/profile.icc ", + basename: "trim_pre" + ) + let args = try TargenArgs.build(config: config) + XCTAssertEqual(args[args.firstIndex(of: "-c")! + 1], "/path/to/profile.icc") + } + + func testInvalidBasenameThrows() { + let config = TargenConfig( + colourSpace: .rgb, + patchCount: 800, + whitePatches: 4, + blackPatches: 4, + basename: "../bad_name" + ) + XCTAssertThrowsError(try TargenArgs.build(config: config)) { error in + XCTAssertTrue(error is PathSecurity.Error) + } + } + + func testInvalidPatchCountThrows() { + let config = TargenConfig( + colourSpace: .rgb, + patchCount: 0, + whitePatches: 4, + blackPatches: 4, + basename: "bad_count" + ) + XCTAssertThrowsError(try TargenArgs.build(config: config)) { error in + XCTAssertTrue(error is TargenArgError) + } + } + + func testInvalidInkLimitThrows() { + let config = TargenConfig( + colourSpace: .cmyk, + patchCount: 800, + whitePatches: 4, + blackPatches: 0, + totalInkLimit: 450, + basename: "bad_ink" + ) + XCTAssertThrowsError(try TargenArgs.build(config: config)) { error in + XCTAssertTrue(error is TargenArgError) + } + } +} + +final class ArgyllRunnerTargenTests: XCTestCase { + + func testSuccessfulTargenExecution() async throws { + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + // Create a mock targen script + let mockScript = """ + #!/bin/sh + # Find the last argument which is the basename + for arg do shift; set -- "$@" "$arg"; done + last="$arg" + echo "Generating patches..." + touch "$last.ti1" + echo "Done!" + exit 0 + """ + let mockURL = tempDir.appendingPathComponent("targen") + try mockScript.write(to: mockURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: mockURL.path) + + let resolver = BinaryResolver(bundledRoot: tempDir, overrideDir: tempDir) + let pm = ProcessManager() + let runner = ArgyllRunner(processManager: pm, binaryResolver: resolver) + + let config = TargenConfig( + colourSpace: .rgb, + patchCount: 800, + whitePatches: 4, + blackPatches: 4, + basename: "mock_test", + workingDirectory: tempDir + ) + + var logLines: [String] = [] + final class LogBox: @unchecked Sendable { + var lines: [String] = [] + let lock = NSLock() + func append(_ batch: [String]) { + lock.lock(); lines.append(contentsOf: batch); lock.unlock() + } + } + let box = LogBox() + let ti1URL = try await runner.runTargen(config: config) { batch in + box.append(batch) + } + logLines = box.lines + XCTAssertTrue(logLines.contains("Generating patches...")) + + XCTAssertTrue(FileManager.default.fileExists(atPath: ti1URL.path)) + XCTAssertEqual(ti1URL.lastPathComponent, "mock_test.ti1") + } + + func testFailedTargenExecution() async throws { + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let mockScript = """ + #!/bin/sh + echo "Error: something went wrong" >&2 + exit 1 + """ + let mockURL = tempDir.appendingPathComponent("targen") + try mockScript.write(to: mockURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: mockURL.path) + + let resolver = BinaryResolver(bundledRoot: tempDir, overrideDir: tempDir) + let pm = ProcessManager() + let runner = ArgyllRunner(processManager: pm, binaryResolver: resolver) + + let config = TargenConfig( + colourSpace: .rgb, + patchCount: 800, + whitePatches: 4, + blackPatches: 4, + basename: "fail_test", + workingDirectory: tempDir + ) + + await assertAsyncThrows(expectedType: ArgyllRunnerError.self) { + try await runner.runTargen(config: config) + } errorHandler: { error in + XCTAssertEqual(error, .toolFailed( + tool: "targen", code: 1, logs: ["Error: something went wrong"])) + } + } + + func testMissingArtefactThrows() async throws { + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let mockScript = """ + #!/bin/sh + echo "Exited 0 but did not create file" + exit 0 + """ + let mockURL = tempDir.appendingPathComponent("targen") + try mockScript.write(to: mockURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: mockURL.path) + + let resolver = BinaryResolver(bundledRoot: tempDir, overrideDir: tempDir) + let pm = ProcessManager() + let runner = ArgyllRunner(processManager: pm, binaryResolver: resolver) + + let config = TargenConfig( + colourSpace: .rgb, + patchCount: 800, + whitePatches: 4, + blackPatches: 4, + basename: "no_file", + workingDirectory: tempDir + ) + + await assertAsyncThrows(expectedType: ArgyllRunnerError.self) { + try await runner.runTargen(config: config) + } errorHandler: { error in + XCTAssertEqual(error, .missingArtefact( + tempDir.appendingPathComponent("no_file.ti1").path)) + } + } +} diff --git a/Tests/ICCeryCoreTests/TargetWorkflowViewModelTests.swift b/Tests/ICCeryCoreTests/TargetWorkflowViewModelTests.swift new file mode 100644 index 0000000..84e13e9 --- /dev/null +++ b/Tests/ICCeryCoreTests/TargetWorkflowViewModelTests.swift @@ -0,0 +1,39 @@ +import Foundation +import XCTest +@testable import ICCeryCore +@testable import ICCery + +/// Dataset-import error contracts through the +/// `importMeasurementDataset(from:)` seam (issue #80): parser and I/O +/// failures must surface identically as a single `.error` Notice. +@MainActor +final class TargetWorkflowViewModelTests: XCTestCase { + + func testMalformedDatasetNotice() throws { + let env = try TestAppEnvironment.make() + defer { env.cleanup() } + let vm = TargetWorkflowViewModel(environment: env.environment) + + let bad = env.root.appendingPathComponent("broken.ti3") + try Data("this is not CGATS data".utf8).write(to: bad) + + vm.importMeasurementDataset(from: bad) + + let notice = try XCTUnwrap(vm.wizard.notice) + XCTAssertEqual(notice.kind, .error) + XCTAssertTrue(notice.text.hasPrefix("Import failed:")) + } + + func testMissingDatasetNotice() throws { + let env = try TestAppEnvironment.make() + defer { env.cleanup() } + let vm = TargetWorkflowViewModel(environment: env.environment) + + let missing = env.root.appendingPathComponent("does-not-exist.ti3") + vm.importMeasurementDataset(from: missing) + + let notice = try XCTUnwrap(vm.wizard.notice) + XCTAssertEqual(notice.kind, .error) + XCTAssertTrue(notice.text.hasPrefix("Import failed:")) + } +} diff --git a/Tests/ICCeryCoreTests/TestAppEnvironment.swift b/Tests/ICCeryCoreTests/TestAppEnvironment.swift new file mode 100644 index 0000000..eb15c85 --- /dev/null +++ b/Tests/ICCeryCoreTests/TestAppEnvironment.swift @@ -0,0 +1,83 @@ +import Foundation +@testable import ICCeryCore +@testable import ICCery + +/// Shared app-test dependency factory (issue #82). +/// +/// Every store is pointed at a unique temporary directory so tests never +/// read or write the user's real Application Support tree, and a fresh +/// `ProcessManager` keeps child-process state isolated per test. The +/// global process environment is never mutated. +struct TestAppEnvironment { + + /// Root temp directory holding all per-test state files. + let root: URL + let environment: AppEnvironment + + var settingsURL: URL { root.appendingPathComponent("settings.json") } + var stateURL: URL { root.appendingPathComponent("wizard_state.json") } + var historyURL: URL { + root.appendingPathComponent("verification_history.json") + } + var mediaLibraryURL: URL { + root.appendingPathComponent("media_library.json") + } + var recentProjectsURL: URL { + root.appendingPathComponent("recent_projects.json") + } + + /// Creates an isolated environment under `NSTemporaryDirectory()`. + /// Call `cleanup()` when finished. + /// `argyllBinDir` overrides the `BinaryResolver` tool directory so + /// tests can point at mock sidecar scripts (#148). + /// `bundledArgyllRoot` replaces the real app-bundle sidecar root so + /// tests can simulate a missing sidecar even when the build phase + /// copied real binaries into the host app. + static func make( + argyllBinDir: URL? = nil, + bundledArgyllRoot: URL? = nil + ) throws -> TestAppEnvironment { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-test-env-\(UUID().uuidString)") + try FileManager.default.createDirectory( + at: root, withIntermediateDirectories: true + ) + + let processManager = ProcessManager() + let settingsStore = SettingsStore( + fileURL: root.appendingPathComponent("settings.json") + ) + let environment = AppEnvironment( + stateStore: WizardStateStore( + fileURL: root.appendingPathComponent("wizard_state.json") + ), + settingsStore: settingsStore, + presetStore: PresetStore(settingsStore: settingsStore), + runner: ArgyllRunner( + processManager: processManager, + binaryResolver: BinaryResolver( + bundledRoot: bundledArgyllRoot ?? AppPaths.bundledArgyllDir, + overrideDir: argyllBinDir) + ), + cupsService: CupsService( + processManager: processManager, + binaryDir: root.appendingPathComponent("cups-bin") + ), + historyStore: VerificationHistoryStore( + url: root.appendingPathComponent("verification_history.json") + ), + mediaStore: MediaLibraryStore( + url: root.appendingPathComponent("media_library.json") + ), + recentProjectsStore: RecentProjectsStore( + url: root.appendingPathComponent("recent_projects.json") + ) + ) + return TestAppEnvironment(root: root, environment: environment) + } + + /// Removes the temporary root directory. + func cleanup() { + try? FileManager.default.removeItem(at: root) + } +} diff --git a/Tests/ICCeryCoreTests/VerificationHistoryStoreTests.swift b/Tests/ICCeryCoreTests/VerificationHistoryStoreTests.swift new file mode 100644 index 0000000..7f2ae46 --- /dev/null +++ b/Tests/ICCeryCoreTests/VerificationHistoryStoreTests.swift @@ -0,0 +1,202 @@ +import Foundation +import XCTest +@testable import ICCeryCore + +final class VerificationHistoryStoreTests: XCTestCase { + + func testAppendAndCap() async throws { + let fm = FileManager.default + let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try fm.createDirectory(at: tmp, withIntermediateDirectories: true) + let url = tmp.appendingPathComponent("verification_history.json") + + let store = VerificationHistoryStore(url: url, capacity: 3) + for i in 0..<5 { + let record = VerificationRecord( + id: "vr-\(i)", + profileName: "p", + printerName: "", + avgDE: Double(i), + maxDE: Double(i), + rmsDE: Double(i), + patchCount: i, + status: .good, + timestamp: Date(timeIntervalSince1970: TimeInterval(i)) + ) + _ = try await store.append(record) + } + + let all = await store.all() + XCTAssertEqual(all.count, 3) + XCTAssertEqual(all.first?.avgDE, 2.0) + } + + func testParseFailurePreservesFile() async { + let fm = FileManager.default + let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try? fm.createDirectory(at: tmp, withIntermediateDirectories: true) + let url = tmp.appendingPathComponent("verification_history.json") + + try? "not json".write(to: url, atomically: true, encoding: .utf8) + + let store = VerificationHistoryStore(url: url) + do { + _ = try await store.load() + XCTFail("load() should throw on invalid JSON") + } catch { + XCTAssertTrue(fm.fileExists(atPath: url.path)) + } + } + + func testAppendLoadsExisting() async throws { + let fm = FileManager.default + let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try fm.createDirectory(at: tmp, withIntermediateDirectories: true) + let url = tmp.appendingPathComponent("verification_history.json") + + // Pre-populate the store on disk. + let existing = VerificationRecord( + id: "vr-existing", + profileName: "p", + printerName: "", + avgDE: 1.0, + maxDE: 1.0, + rmsDE: 1.0, + patchCount: 1, + status: .good, + timestamp: Date(timeIntervalSince1970: 0) + ) + let store1 = VerificationHistoryStore(url: url) + _ = try await store1.append(existing) + + // A fresh store appending a new record must keep the existing one. + let store2 = VerificationHistoryStore(url: url) + let new = VerificationRecord( + id: "vr-new", + profileName: "p", + printerName: "", + avgDE: 2.0, + maxDE: 2.0, + rmsDE: 2.0, + patchCount: 2, + status: .good, + timestamp: Date(timeIntervalSince1970: 10) + ) + _ = try await store2.append(new) + + let all = await store2.all() + XCTAssertEqual(all.count, 2) + XCTAssertTrue(all.contains { $0.id == "vr-existing" }) + XCTAssertTrue(all.contains { $0.id == "vr-new" }) + } + + func testAppendPreservesUnparseableFile() async { + let fm = FileManager.default + let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try? fm.createDirectory(at: tmp, withIntermediateDirectories: true) + let url = tmp.appendingPathComponent("verification_history.json") + + let badJSON = "not json" + try? badJSON.write(to: url, atomically: true, encoding: .utf8) + + let store = VerificationHistoryStore(url: url) + let record = VerificationRecord( + id: "vr-new", + profileName: "p", + printerName: "", + avgDE: 1.0, + maxDE: 1.0, + rmsDE: 1.0, + patchCount: 1, + status: .good, + timestamp: Date(timeIntervalSince1970: 0) + ) + + do { + _ = try await store.append(record) + XCTFail("append() should propagate the load error") + } catch { + XCTAssertTrue(fm.fileExists(atPath: url.path)) + if let data = try? Data(contentsOf: url), + let contents = String(data: data, encoding: .utf8) { + XCTAssertEqual(contents, badJSON) + } else { + XCTFail("Could not read preserved file") + } + } + } + + func testClearPreservesUnparseableFile() async { + let fm = FileManager.default + let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try? fm.createDirectory(at: tmp, withIntermediateDirectories: true) + let url = tmp.appendingPathComponent("verification_history.json") + + let badJSON = "not json" + try? badJSON.write(to: url, atomically: true, encoding: .utf8) + + let store = VerificationHistoryStore(url: url) + do { + try await store.clear() + XCTFail("clear() should propagate the load error") + } catch { + let contents = try? String(contentsOf: url, encoding: .utf8) + XCTAssertEqual(contents, badJSON) + } + } + + func testIso8601RoundTrip() async throws { + let fm = FileManager.default + let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try fm.createDirectory(at: tmp, withIntermediateDirectories: true) + let url = tmp.appendingPathComponent("verification_history.json") + + let timestamp = Date(timeIntervalSince1970: 1_700_000_000) + let record = VerificationRecord( + id: "vr-iso", + profileName: "p", + printerName: "", + avgDE: 1.0, + maxDE: 2.0, + rmsDE: 1.5, + patchCount: 1, + status: .good, + timestamp: timestamp + ) + let store1 = VerificationHistoryStore(url: url) + _ = try await store1.append(record) + + let text = try String(contentsOf: url, encoding: .utf8) + XCTAssertTrue(text.contains(ISO8601DateFormatter().string(from: timestamp))) + + let store2 = VerificationHistoryStore(url: url) + let loaded = try await store2.load() + XCTAssertEqual(loaded.count, 1) + XCTAssertEqual(loaded.first?.timestamp, timestamp) + } + + func testCsvQuoting() async throws { + let fm = FileManager.default + let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try fm.createDirectory(at: tmp, withIntermediateDirectories: true) + let url = tmp.appendingPathComponent("verification_history.json") + + let store = VerificationHistoryStore(url: url) + let record = VerificationRecord( + id: "a,b", + profileName: "\"quoted\"", + printerName: "", + avgDE: 1.0, + maxDE: 2.0, + rmsDE: 3.0, + patchCount: 1, + status: .good, + timestamp: Date(timeIntervalSince1970: 0) + ) + _ = try await store.append(record) + + let csv = await store.exportCSV() + XCTAssertTrue(csv.contains("\"a,b\"")) + XCTAssertTrue(csv.contains("\"\"quoted\"\"")) + } +} diff --git a/Tests/ICCeryCoreTests/WizardCalibrationSessionTests.swift b/Tests/ICCeryCoreTests/WizardCalibrationSessionTests.swift new file mode 100644 index 0000000..392fe4c --- /dev/null +++ b/Tests/ICCeryCoreTests/WizardCalibrationSessionTests.swift @@ -0,0 +1,85 @@ +import Foundation +import XCTest +@testable import ICCeryCore +@testable import ICCery + +/// Issue #29 — `CAL_` basename must be restored on relaunch and on any +/// attempt to navigate to a non-calibration stage that would use it. +@MainActor +final class WizardCalibrationSessionTests: XCTestCase { + + private func tempURL() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-cal-state-\(UUID().uuidString)") + .appendingPathComponent("wizard_state.json") + } + + private func tempDir() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-cal-dir-\(UUID().uuidString)") + try FileManager.default.createDirectory( + at: url, withIntermediateDirectories: true + ) + return url + } + + func testRelaunchRestoresOriginal() throws { + let url = tempURL() + let store = WizardStateStore(fileURL: url) + var saved = WizardState( + currentStage: WizardStage.calibrate.rawValue, + basename: "CAL_DemoTarget", + cwd: "/tmp/charts", + sessionMode: .calibration, + calibrationOriginalBasename: "DemoTarget" + ) + try store.save(saved) + + let model = WizardViewModel(stateStore: store) + + XCTAssertEqual(model.basename, "DemoTarget") + XCTAssertEqual(model.calibrationOriginalBasename, "") + XCTAssertEqual(model.sessionMode, .profile) + XCTAssertEqual(model.stage, .generate) + } + + func testGoToBuildProfileRefusesAndRestores() throws { + let dir = try tempDir() + let url = tempURL() + let store = WizardStateStore(fileURL: url) + let model = WizardViewModel(stateStore: store) + + model.setTarget(basename: "DemoTarget", workingDirectory: dir) + model.calibrationOriginalBasename = "DemoTarget" + model.basename = "CAL_DemoTarget" + model.sessionMode = .calibration + model.stage = .calibrate + + model.go(to: .buildProfile) + + XCTAssertEqual(model.basename, "DemoTarget") + XCTAssertEqual(model.calibrationOriginalBasename, "") + XCTAssertEqual(model.sessionMode, .profile) + XCTAssertEqual(model.stage, .calibrate) + } + + func testGoToLayoutStaysCal() throws { + let dir = try tempDir() + let url = tempURL() + let store = WizardStateStore(fileURL: url) + let model = WizardViewModel(stateStore: store) + + model.setTarget(basename: "DemoTarget", workingDirectory: dir) + model.calibrationOriginalBasename = "DemoTarget" + model.basename = "CAL_DemoTarget" + model.sessionMode = .calibration + model.stage = .calibrate + + model.go(to: .layOutPrint) + + XCTAssertEqual(model.basename, "CAL_DemoTarget") + XCTAssertEqual(model.calibrationOriginalBasename, "DemoTarget") + XCTAssertEqual(model.sessionMode, .calibration) + XCTAssertEqual(model.stage, .layOutPrint) + } +} diff --git a/Tests/ICCeryCoreTests/WizardGatingTests.swift b/Tests/ICCeryCoreTests/WizardGatingTests.swift new file mode 100644 index 0000000..3fbde91 --- /dev/null +++ b/Tests/ICCeryCoreTests/WizardGatingTests.swift @@ -0,0 +1,137 @@ +import Foundation +import XCTest +@testable import ICCeryCore + +private func artefacts( + ti1: Bool = false, ti2: Bool = false, ti3: Bool = false, profile: Bool = false +) -> StageArtefacts { + var a = StageArtefacts() + a.stage1Complete = ti1 + a.stage2Complete = ti2 + a.stage3Complete = ti3 + a.stage4Complete = profile + if profile { + a.profilePath = URL(fileURLWithPath: "/x/t.icc") + } + return a +} + +final class WizardGatingTests: XCTestCase { + + func testEmptyProjectOnlyStage1() { + let a = artefacts() + XCTAssertTrue(WizardGating.isUnlocked(.generate, artefacts: a)) + XCTAssertTrue(WizardGating.isUnlocked(.calibrate, artefacts: a)) + for s in [WizardStage.layOutPrint, .measure, .buildProfile, .verifyInstall] { + XCTAssertFalse(WizardGating.isUnlocked(s, artefacts: a), "\(s) should be locked") + } + } + + func testTi1UnlocksStage2Only() { + let a = artefacts(ti1: true) + XCTAssertTrue(WizardGating.isUnlocked(.layOutPrint, artefacts: a)) + XCTAssertFalse(WizardGating.isUnlocked(.measure, artefacts: a)) + XCTAssertFalse(WizardGating.isUnlocked(.buildProfile, artefacts: a)) + XCTAssertFalse(WizardGating.isUnlocked(.verifyInstall, artefacts: a)) + } + + func testStage3NeedsTi1AndTi2() { + XCTAssertFalse(WizardGating.isUnlocked(.measure, artefacts: artefacts(ti2: true))) + XCTAssertTrue(WizardGating.isUnlocked(.measure, artefacts: artefacts(ti1: true, ti2: true))) + } + + func testStage4NeedsTi3NotTi2() { + // #109/#110: .ti2 alone must never unlock Stage 4. + let a = artefacts(ti1: true, ti2: true) + XCTAssertFalse(WizardGating.isUnlocked(.buildProfile, artefacts: a)) + XCTAssertTrue(WizardGating.isUnlocked(.buildProfile, artefacts: artefacts(ti3: true))) + } + + func testStage5NeedsTi3AndProfile() { + XCTAssertFalse(WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(ti3: true))) + XCTAssertFalse(WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(profile: true))) + XCTAssertTrue(WizardGating.isUnlocked( + .verifyInstall, artefacts: artefacts(ti3: true, profile: true) + )) + } + + func testForwardGatedBackwardFree() { + let a = artefacts() + XCTAssertFalse(WizardGating.canNavigate(to: .layOutPrint, from: .generate, artefacts: a)) + // Backward always allowed even when artefacts vanished. + XCTAssertTrue(WizardGating.canNavigate(to: .generate, from: .measure, artefacts: a)) + // Same stage is a no-op. + XCTAssertTrue(WizardGating.canNavigate(to: .measure, from: .measure, artefacts: a)) + // Stage 0 is a side-trip, never gated. + XCTAssertTrue(WizardGating.canNavigate(to: .calibrate, from: .generate, artefacts: a)) + } + + func testDeepestUnlocked() { + XCTAssertEqual(WizardGating.deepestUnlocked(artefacts: artefacts()), .generate) + XCTAssertEqual(WizardGating.deepestUnlocked( + artefacts: artefacts(ti1: true, ti2: true) + ), .measure) + XCTAssertEqual(WizardGating.deepestUnlocked( + artefacts: artefacts(ti3: true, profile: true) + ), .verifyInstall) + } +} + +final class WizardStateStoreTests: XCTestCase { + private func tempURL() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-wiz-\(UUID().uuidString)") + .appendingPathComponent("wizard_state.json") + } + + func testRoundTrip() throws { + let url = tempURL() + let store = WizardStateStore(fileURL: url) + var s = WizardState() + s.currentStage = 3 + s.basename = "run-42" + s.cwd = "/tmp/charts" + s.sessionMode = .calibration + s.profileBasename = "imported" + s.calibrationOriginalBasename = "pre-cal" + try store.save(s) + XCTAssertEqual(store.load(), s) + } + + func testMissingFileDefaults() { + let s = WizardStateStore(fileURL: tempURL()).load() + XCTAssertEqual(s, .default) + XCTAssertEqual(s.stage, .generate) + XCTAssertEqual(s.sessionMode, .profile) + } + + func testCorruptStageFallsBackToGenerate() throws { + let url = tempURL() + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true + ) + try #"{"current_stage": 99, "basename": "", "cwd": "", "session_mode": "profile"}"# + .write(to: url, atomically: true, encoding: .utf8) + XCTAssertEqual(WizardStateStore(fileURL: url).load().stage, .generate) + } + + func testCorruptJsonReturnsDefaultAndKeepsBytes() throws { + let url = tempURL() + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true + ) + try "not json".write(to: url, atomically: true, encoding: .utf8) + XCTAssertEqual(WizardStateStore(fileURL: url).load(), .default) + let kept = try String(contentsOf: url, encoding: .utf8) + XCTAssertEqual(kept, "not json") + } + + func testSessionModeCalibrationRoundTrips() throws { + var s = WizardState(sessionMode: .calibration) + let data = try JSONEncoder().encode(s) + let decoded = try JSONDecoder().decode(WizardState.self, from: data) + XCTAssertEqual(decoded.sessionMode, .calibration) + s.sessionMode = .profile + XCTAssertEqual(s.sessionMode, .profile) + } +} diff --git a/Tests/ICCeryCoreTests/XCTestCase+AsyncThrows.swift b/Tests/ICCeryCoreTests/XCTestCase+AsyncThrows.swift new file mode 100644 index 0000000..c26ea15 --- /dev/null +++ b/Tests/ICCeryCoreTests/XCTestCase+AsyncThrows.swift @@ -0,0 +1,21 @@ +import XCTest + +extension XCTestCase { + func assertAsyncThrows( + expectedType: E.Type, + _ expression: () async throws -> T, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, + line: UInt = #line, + errorHandler: ((E) -> Void)? = nil + ) async { + do { + _ = try await expression() + XCTFail("Expected \(expectedType) to be thrown but expression succeeded. \(message())", file: file, line: line) + } catch let error as E { + errorHandler?(error) + } catch { + XCTFail("Expected \(expectedType) but caught \(type(of: error)): \(error). \(message())", file: file, line: line) + } + } +} diff --git a/Tests/ICCeryUITests/AboutHelpUITests.swift b/Tests/ICCeryUITests/AboutHelpUITests.swift new file mode 100644 index 0000000..17a6522 --- /dev/null +++ b/Tests/ICCeryUITests/AboutHelpUITests.swift @@ -0,0 +1,230 @@ +import XCTest + +/// About and help chrome UI tests (issue #31). +@MainActor +final class AboutHelpUITests: XCTestCase { + + private var app: XCUIApplication! + + override func setUp() async throws { + continueAfterFailure = false + app = XCUIApplication() + app.launchEnvironment = ["ICCERY_UI_TESTING": "1"] + } + + override func tearDown() async throws { + app?.terminate() + app = nil + } + + private func element(_ id: String) -> XCUIElement { + let inApp = app.descendants(matching: .any)[id].firstMatch + if inApp.exists { return inApp } + // Search all sheets (including nested sheets) for the element. + // The license window is a nested sheet (sheet presented from AboutView). + for sheet in app.sheets.allElementsBoundByIndex { + let inSheet = sheet.descendants(matching: .any)[id].firstMatch + if inSheet.exists { return inSheet } + } + // Fallback to original behavior + 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 launchApp() { + app.launch() + if !app.wait(for: .runningForeground, timeout: 10) { + app.activate() + } + } + + func testAboutDialogShowsVersionAndBuildDate() throws { + launchApp() + + let openAbout = app.buttons["openAboutBtn"] + 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) + XCTAssertTrue(element("aboutBuildDate").exists) + + let close = waitFor("closeAboutBtn", timeout: 10) + close.click() + + XCTAssertFalse(element("aboutDialog").exists) + } + + func testHelpOverlaysDoNotChangeSidebarHeight() throws { + launchApp() + + let toggle = app.buttons["btnToggleAllHelp"] + XCTAssertTrue(toggle.waitForExistence(timeout: 10)) + + // 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 = sidebarChild.frame + + XCTAssertEqual(before, after, + "Toggling global help must not reflow the sidebar.") + XCTAssertTrue(app.descendants(matching: .any)["openSettingsBtn"].exists) + } + + func testAboutDialogShowsViewLicensesButton() throws { + launchApp() + + let openAbout = app.buttons["openAboutBtn"] + XCTAssertTrue(openAbout.waitForExistence(timeout: 10)) + openAbout.click() + + _ = waitFor("aboutVersion", timeout: 10) + + let viewLicensesBtn = waitFor("viewLicensesBtn", timeout: 10) + XCTAssertTrue(viewLicensesBtn.exists) + viewLicensesBtn.click() + + // License window should open as a sheet + _ = waitFor("licenseWindow", timeout: 10) + XCTAssertTrue(element("licenseWindow").exists) + + // Close license window + let closeLicenseBtn = waitFor("closeLicenseBtn", timeout: 5) + closeLicenseBtn.click() + + // License window should be dismissed + XCTAssertFalse(element("licenseWindow").exists) + + // Close about dialog + let closeAboutBtn = waitFor("closeAboutBtn", timeout: 5) + closeAboutBtn.click() + XCTAssertFalse(element("aboutDialog").exists) + } + + func testLicenseWindowShowsICCeryLicense() throws { + launchApp() + + let openAbout = app.buttons["openAboutBtn"] + XCTAssertTrue(openAbout.waitForExistence(timeout: 10)) + openAbout.click() + + let viewLicensesBtn = waitFor("viewLicensesBtn", timeout: 10) + viewLicensesBtn.click() + + _ = waitFor("licenseWindow", timeout: 10) + + // Verify ICCery license section exists + let icceryLicenseSection = waitFor("icceryLicenseSectionHeader", timeout: 5) + XCTAssertTrue(icceryLicenseSection.exists) + + // Verify ICCery license content contains key phrases + let icceryLicenseContent = element("icceryLicenseSectionContent") + XCTAssertTrue(icceryLicenseContent.waitForExistence(timeout: 5)) + let licenseText = icceryLicenseContent.value as? String ?? "" + XCTAssertTrue(licenseText.contains("Copyright (c) 2026 Gordon Bolton")) + XCTAssertTrue(licenseText.contains("All Rights Reserved")) + XCTAssertTrue(licenseText.contains("AGPLv3")) + + // Close license window + let closeLicenseBtn = waitFor("closeLicenseBtn", timeout: 5) + closeLicenseBtn.click() + + let closeAboutBtn = waitFor("closeAboutBtn", timeout: 5) + closeAboutBtn.click() + } + + func testLicenseWindowShowsArgyllLicense() throws { + launchApp() + + let openAbout = app.buttons["openAboutBtn"] + XCTAssertTrue(openAbout.waitForExistence(timeout: 10)) + openAbout.click() + + let viewLicensesBtn = waitFor("viewLicensesBtn", timeout: 10) + viewLicensesBtn.click() + + _ = waitFor("licenseWindow", timeout: 10) + + // Verify ArgyllCMS license section exists + let argyllLicenseSection = waitFor("argyllLicenseSectionHeader", timeout: 5) + XCTAssertTrue(argyllLicenseSection.exists) + + // Verify Argyll license content exists (may be fallback if License.txt not bundled) + let argyllLicenseContent = element("argyllLicenseSectionContent") + XCTAssertTrue(argyllLicenseContent.waitForExistence(timeout: 5)) + let licenseText = argyllLicenseContent.value as? String ?? "" + // Should contain either the actual AGPLv3 license or the fallback notice + XCTAssertTrue(licenseText.contains("AGPLv3") || licenseText.contains("GNU Affero General Public License") || licenseText.contains("fetch-argyll")) + + // Close license window + let closeLicenseBtn = waitFor("closeLicenseBtn", timeout: 5) + closeLicenseBtn.click() + + let closeAboutBtn = waitFor("closeAboutBtn", timeout: 5) + closeAboutBtn.click() + } + + func testLicenseWindowShowsAttributionLinks() throws { + launchApp() + + let openAbout = app.buttons["openAboutBtn"] + XCTAssertTrue(openAbout.waitForExistence(timeout: 10)) + openAbout.click() + + let viewLicensesBtn = waitFor("viewLicensesBtn", timeout: 10) + viewLicensesBtn.click() + + _ = waitFor("licenseWindow", timeout: 10) + + // Verify attribution section exists + let attributionHeader = waitFor("attributionHeader", timeout: 5) + XCTAssertTrue(attributionHeader.exists) + + // Verify upstream link + let upstreamLink = element("argyllUpstreamLink") + XCTAssertTrue(upstreamLink.waitForExistence(timeout: 5)) + let upstreamLabel = upstreamLink.label + XCTAssertTrue(upstreamLabel.contains("Graeme Gill") || upstreamLabel.contains("argyllcms.com")) + + // Verify fork link + let forkLink = element("argyllForkLink") + XCTAssertTrue(forkLink.waitForExistence(timeout: 5)) + let forkLabel = forkLink.label + XCTAssertTrue(forkLabel.contains("Gronod") || forkLabel.contains("git.i3omb.com")) + + // Verify AGPL isolation note + let isolationNote = element("agplIsolationNote") + XCTAssertTrue(isolationNote.waitForExistence(timeout: 5)) + let noteText = isolationNote.value as? String ?? "" + XCTAssertTrue(noteText.contains("isolated subprocesses") || noteText.contains("AGPLv3 isolation")) + + // Close license window + let closeLicenseBtn = waitFor("closeLicenseBtn", timeout: 5) + closeLicenseBtn.click() + + let closeAboutBtn = waitFor("closeAboutBtn", timeout: 5) + closeAboutBtn.click() + } +} diff --git a/Tests/ICCeryUITests/Fixtures/bin/applycal b/Tests/ICCeryUITests/Fixtures/bin/applycal new file mode 100755 index 0000000..747cbeb --- /dev/null +++ b/Tests/ICCeryUITests/Fixtures/bin/applycal @@ -0,0 +1,18 @@ +#!/bin/sh +# Mock applycal for Milestone 5 UI tests. +# Copies the input profile to the optional output path. +while [ "$#" -gt 0 ]; do + case "$1" in + -v|-a|-u) shift ;; + *) break ;; + esac +done +cal="$1" +input="$2" +output="$3" +if [ -n "$output" ]; then + cp "$input" "$output" +else + cp "$cal" "$input.cal.ctl" +fi +exit 0 diff --git a/Tests/ICCeryUITests/Fixtures/bin/average b/Tests/ICCeryUITests/Fixtures/bin/average new file mode 100755 index 0000000..6bbee12 --- /dev/null +++ b/Tests/ICCeryUITests/Fixtures/bin/average @@ -0,0 +1,35 @@ +#!/bin/sh +# Mock average for Milestone4UITests. +# Usage: average -v pass1.ti3 pass2.ti3 ... output.ti3 +# The canonical output is the last argument. +# Set MOCK_AVERAGE_FAIL=1 to exit with code 1. +if [ "${MOCK_AVERAGE_FAIL:-0}" -ne 0 ]; then + echo "average: could not converge" >&2 + exit 1 +fi + +# Drop leading -v +shift + +output="$1" +if [ $# -ge 2 ]; then + output="$2" +fi + +# Find last argument +for arg in "$@"; do + output="$arg" +done + +# Sanity: the output is the last argument. +# Write a fake canonical .ti3 that identifies the inputs. +{ + echo "CTI3" + echo "INPUTS:" + for arg in "$@"; do + if [ "$arg" != "$output" ]; then + echo "$arg" + fi + done +} > "$output" +exit 0 diff --git a/Tests/ICCeryUITests/Fixtures/bin/chartread b/Tests/ICCeryUITests/Fixtures/bin/chartread new file mode 100755 index 0000000..8f38d75 --- /dev/null +++ b/Tests/ICCeryUITests/Fixtures/bin/chartread @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""Mock chartread for Milestone4UITests. + +Supports handheld (MOCK_CHARTREAD_MODE=strip) and XY (MOCK_CHARTREAD_MODE=xy). +Writes basename.ti3 on receiving 'd' and exits 0. +Exit 0 and no .ti3 on 'q' before done. +Usage: chartread -v -u [-c port] [-Y l] basename +""" +import json +import os +import sys + + +def read_line(): + try: + return sys.stdin.readline() + except Exception: + return "" + + +def emit_row(payload: dict): + text = "ROW_COLORS_JSON: " + json.dumps(payload) + print(text, flush=True) + + +def write_ti3(basename: str): + if basename: + with open(f"{basename}.ti3", "w") as f: + f.write("MOCK_TI3\n") + + +def main(): + mode = os.environ.get("MOCK_CHARTREAD_MODE", "strip") + basename = "" + for arg in sys.argv[1:]: + if arg.startswith("-"): + continue + basename = arg + + def read_input(): + line = read_line() + if line == "": + sys.exit(1) + return line.strip() + + if mode == "xy": + print("Place instrument on calibration tile and hit [Space] to calibrate.", flush=True) + read_input() + print("Calibration successful.", flush=True) + + print("Please place sheet 1 of 1 on the table", flush=True) + print("hit return to continue, Esc or 'q' to give up", flush=True) + read_input() + + print("locate patch A1 with the sight,", flush=True) + print("then hit return to continue", flush=True) + read_input() + + print("Reading sheet 1...", flush=True) + emit_row({ + "event": "row_complete", + "row_id": "A", + "row_index": 0, + "total_rows": 1, + "patch_count": 3, + "patches": [ + {"id": "1", "loc": "A1", "is_pad": False, "device": [0.0, 50.0, 100.0], "expected": {"XYZ": [18.4210, 20.1234, 15.6789], "Lab": [51.98, -8.45, 12.32]}, "measured": {"XYZ": [18.5120, 20.0451, 15.7100], "Lab": [51.89, -8.31, 12.15]}}, + {"id": "2", "loc": "A2", "is_pad": False, "device": [10.0, 60.0, 90.0], "expected": {"Lab": [60.0, 10.0, -20.0]}, "measured": {"Lab": [60.1, 10.5, -19.5]}}, + {"id": "3", "loc": "A3", "is_pad": True, "device": [100.0, 100.0, 100.0], "measured": {"Lab": [95.0, 0.0, 0.0]}}, + ] + }) + + print("Sheet 1 of 1 read OK", flush=True) + print("Please remove last sheet from table", flush=True) + print("'d' if/when done", flush=True) + while True: + line = read_input() + if line.startswith("d"): + write_ti3(basename) + sys.exit(0) + if line.startswith("q"): + sys.exit(0) + + # Handheld / strip mode (default) + print("Place instrument on calibration tile and hit [Space] to calibrate.", flush=True) + read_input() + print("Calibration successful.", flush=True) + + print("Hit [Space] to read strip A", flush=True) + read_input() + print("Reading strip A...", flush=True) + emit_row({ + "event": "row_complete", + "row_id": "A", + "row_index": 0, + "total_rows": 2, + "patch_count": 3, + "patches": [ + {"id": "1", "loc": "A1", "is_pad": False, "device": [0.0, 50.0, 100.0], "expected": {"XYZ": [18.4210, 20.1234, 15.6789], "Lab": [51.98, -8.45, 12.32]}, "measured": {"XYZ": [18.5120, 20.0451, 15.7100], "Lab": [51.89, -8.31, 12.15]}}, + {"id": "2", "loc": "A2", "is_pad": False, "device": [10.0, 60.0, 90.0], "expected": {"Lab": [60.0, 10.0, -20.0]}, "measured": {"Lab": [60.1, 10.5, -19.5]}}, + {"id": "3", "loc": "A3", "is_pad": True, "device": [100.0, 100.0, 100.0], "measured": {"Lab": [95.0, 0.0, 0.0]}}, + ] + }) + + print("Hit [Space] to read strip B", flush=True) + read_input() + print("Reading strip B...", flush=True) + emit_row({ + "event": "row_complete", + "row_id": "B", + "row_index": 1, + "total_rows": 2, + "patch_count": 2, + "patches": [ + {"id": "4", "loc": "B1", "is_pad": False, "device": [100.0, 0.0, 0.0], "expected": {"Lab": [40.0, 40.0, 40.0]}, "measured": {"Lab": [38.0, 41.0, 39.0]}}, + {"id": "5", "loc": "B2", "is_pad": False, "device": [0.0, 100.0, 0.0], "expected": {"Lab": [80.0, -50.0, 50.0]}, "measured": {"Lab": [79.0, -49.0, 51.0]}}, + ] + }) + + print("'d' if/when done", flush=True) + while True: + line = read_input() + if line.startswith("d"): + write_ti3(basename) + sys.exit(0) + if line.startswith("q"): + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/Tests/ICCeryUITests/Fixtures/bin/colprof b/Tests/ICCeryUITests/Fixtures/bin/colprof new file mode 100755 index 0000000..cdcd453 --- /dev/null +++ b/Tests/ICCeryUITests/Fixtures/bin/colprof @@ -0,0 +1,14 @@ +#!/bin/sh +# Mock colprof for Milestone 5 UI tests. +# Writes {basename}.icc next to the last argument and emits progress. +last="" +for arg in "$@"; do last="$arg"; done +if [ "${ICCERY_MOCK_COLPROF_EXIT:-0}" -ne 0 ]; then + echo "mock colprof failure" >&2 + exit "$ICCERY_MOCK_COLPROF_EXIT" +fi +echo "Gamut mapping calculation..." +echo "Fitting cLUT grid points..." +echo "Writing ICC profile..." +touch "$last.icc" +exit 0 diff --git a/Tests/ICCeryUITests/Fixtures/bin/iccgamut b/Tests/ICCeryUITests/Fixtures/bin/iccgamut new file mode 100755 index 0000000..dd21229 --- /dev/null +++ b/Tests/ICCeryUITests/Fixtures/bin/iccgamut @@ -0,0 +1,17 @@ +#!/bin/sh +# Mock iccgamut for Milestone 5/6 UI tests. +# Writes {stem}.gam next to the profile path. +last="" +for arg in "$@"; do last="$arg"; done +if [ "${ICCERY_MOCK_ICCGAMUT_EXIT:-0}" -ne 0 ]; then + echo "mock iccgamut failure" >&2 + exit "$ICCERY_MOCK_ICCGAMUT_EXIT" +fi +stem=$(basename "$last" | sed 's/\.icc$//; s/\.icm$//') +dir=$(dirname "$last") +if [ -n "${ICCERY_MOCK_GAMUT_SOURCE}" ] && [ -f "${ICCERY_MOCK_GAMUT_SOURCE}" ]; then + cp "${ICCERY_MOCK_GAMUT_SOURCE}" "$dir/$stem.gam" +else + touch "$dir/$stem.gam" +fi +exit 0 diff --git a/Tests/ICCeryUITests/Fixtures/bin/instlist b/Tests/ICCeryUITests/Fixtures/bin/instlist new file mode 100755 index 0000000..f99ea1a --- /dev/null +++ b/Tests/ICCeryUITests/Fixtures/bin/instlist @@ -0,0 +1,17 @@ +#!/bin/sh +# Mock instlist for Milestone4UITests. Emits a pretty JSON device list. +# Override the list with ICCERY_MOCK_INSTLIST_JSON. +if [ -n "${ICCERY_MOCK_INSTLIST_JSON}" ]; then + echo "${ICCERY_MOCK_INSTLIST_JSON}" + exit 0 +fi +printf '{ + "event": "instruments", + "devices": [ + {"port": 1, "name": "X-Rite i1Pro", "type": "usb"}, + {"port": 2, "name": "X-Rite i1Pro 2", "type": "usb"}, + {"port": 3, "name": "i1iO Table", "type": "usb"} + ] +} +' +exit 0 diff --git a/Tests/ICCeryUITests/Fixtures/bin/lp b/Tests/ICCeryUITests/Fixtures/bin/lp new file mode 100755 index 0000000..6e091f1 --- /dev/null +++ b/Tests/ICCeryUITests/Fixtures/bin/lp @@ -0,0 +1,14 @@ +#!/bin/sh +# Mock lp for Milestone3UITests. Appends its full argv to +# ICCERY_TEST_LP_ARGV so the test can assert flag order and option +# replay, then exits 0 (or ICCERY_MOCK_LP_EXIT for failure injection). +{ + printf 'lp' + for arg in "$@"; do printf ' %s' "$arg"; done + printf '\n' +} >> "${ICCERY_TEST_LP_ARGV:-/dev/null}" +if [ "${ICCERY_MOCK_LP_EXIT:-0}" -ne 0 ]; then + echo "mock lp failure" >&2 + exit "$ICCERY_MOCK_LP_EXIT" +fi +exit 0 diff --git a/Tests/ICCeryUITests/Fixtures/bin/lpoptions b/Tests/ICCeryUITests/Fixtures/bin/lpoptions new file mode 100755 index 0000000..d274f3b --- /dev/null +++ b/Tests/ICCeryUITests/Fixtures/bin/lpoptions @@ -0,0 +1,23 @@ +#!/bin/sh +# Mock lpoptions for Milestone3UITests. `-p ` prints printer-info; +# `-p -l` prints Key/Label listings incl. Epson bypass keys. +queue="" +list=0 +for arg in "$@"; do + case "$arg" in + -p) shift_flag=1 ;; + -l) list=1 ;; + -*) ;; + *) queue="$arg" ;; + esac +done +if [ "$list" = "1" ]; then + printf 'PageSize/Media Size: 4x6 5x7 *A4 Letter Legal\n' + printf 'InputSlot/Media Source: Auto *Main Rear\n' + printf 'MediaType/Media Type: *Stationery PhotographicGlossy PhotographicMatte\n' + printf 'EPIJ_CMat/Color Adjust: *0 1 2 3\n' + printf 'ColorModel/Output Mode: *RGB Gray\n' + exit 0 +fi +printf "printer-info='Mock %s' printer-type=42\n" "$queue" +exit 0 diff --git a/Tests/ICCeryUITests/Fixtures/bin/lpstat b/Tests/ICCeryUITests/Fixtures/bin/lpstat new file mode 100755 index 0000000..28a7347 --- /dev/null +++ b/Tests/ICCeryUITests/Fixtures/bin/lpstat @@ -0,0 +1,19 @@ +#!/bin/sh +# Mock lpstat for Milestone3UITests. Emits two canned queues so the UI +# can exercise select/refresh/status-badge without real CUPS. +case "$1" in + -e) + printf 'Mock_Epson_7450\nMock_Canon_Pro\n' + ;; + -p) + printf 'printer Mock_Epson_7450 is idle. enabled since Mon Sep 7 21:50:25 2026\n' + printf 'printer Mock_Canon_Pro disabled since Tue Sep 8 09:00:00 2026 -\n\tPaused\n' + ;; + -d) + printf 'system default destination: Mock_Epson_7450\n' + ;; + *) + exit 1 + ;; +esac +exit 0 diff --git a/Tests/ICCeryUITests/Fixtures/bin/printcal b/Tests/ICCeryUITests/Fixtures/bin/printcal new file mode 100755 index 0000000..a86c770 --- /dev/null +++ b/Tests/ICCeryUITests/Fixtures/bin/printcal @@ -0,0 +1,25 @@ +#!/bin/sh +# Mock printcal for Stage 0 calibration tests. Creates the .cal named by +# the -o argument in the process working directory. Exit code overridable +# via ICCERY_MOCK_PRINTCAL_EXIT. +output="" +basename="" +prev="" +for arg in "$@"; do + if [ "$prev" = "-o" ]; then + output="$arg" + fi + prev="$arg" +done +# If no -o, derive from the last positional argument. +if [ -z "$output" ]; then + for arg in "$@"; do basename="$arg"; done + output="$basename.cal" +fi +if [ "${ICCERY_MOCK_PRINTCAL_EXIT:-0}" -ne 0 ]; then + echo "mock printcal failure" >&2 + exit "$ICCERY_MOCK_PRINTCAL_EXIT" +fi +echo "ideal power 1.0, device power 0.8" +touch "$output" +exit 0 diff --git a/Tests/ICCeryUITests/Fixtures/bin/printtarg b/Tests/ICCeryUITests/Fixtures/bin/printtarg new file mode 100755 index 0000000..16f482d --- /dev/null +++ b/Tests/ICCeryUITests/Fixtures/bin/printtarg @@ -0,0 +1,14 @@ +#!/bin/sh +# Mock printtarg for Milestone2UITests. Writes one 2x2 TIFF, a pretty +# manifest on stdout, and a fake .ti2 next to the basename (last argv). +# Exit code is overridable via ICCERY_MOCK_PRINTTARG_EXIT. +last="" +for arg in "$@"; do last="$arg"; done +if [ "${ICCERY_MOCK_PRINTTARG_EXIT:-0}" -ne 0 ]; then + echo "mock printtarg failure" >&2 + exit "$ICCERY_MOCK_PRINTTARG_EXIT" +fi +echo 'SUkqAAgAAAAKAAABAwABAAAAAgAAAAEBAwABAAAAAgAAAAIBAwABAAAACAAAAAMBAwABAAAAAQAAAAYBAwABAAAAAQAAABEBBAABAAAAhgAAABUBAwABAAAAAQAAABYBAwABAAAAAgAAABcBBAABAAAABAAAABwBAwABAAAAAQAAAAAAAAA8eLTw' | /usr/bin/base64 -D > "page1.tif" +printf '{\n "event": "manifest",\n "pages": [\n {"filename": "page1.tif", "patches": 4, "width_mm": 210, "height_mm": 297}\n ]\n}\n' +touch "$last.ti2" +exit 0 diff --git a/Tests/ICCeryUITests/Fixtures/bin/profcheck b/Tests/ICCeryUITests/Fixtures/bin/profcheck new file mode 100755 index 0000000..df49df8 --- /dev/null +++ b/Tests/ICCeryUITests/Fixtures/bin/profcheck @@ -0,0 +1,12 @@ +#!/bin/sh +# Mock profcheck for Milestone 5 UI tests. +# Emits a JSON report and legacy text summary. +if [ "${ICCERY_MOCK_PROFCHECK_EXIT:-0}" -ne 0 ]; then + echo "mock profcheck failure" >&2 + exit "$ICCERY_MOCK_PROFCHECK_EXIT" +fi +echo "No of test patches = 52" +sleep 0.1 +printf '{"event": "report", "peak_de2000": 2.41, "avg_de2000": 0.85, "rms": 1.02}\n' +echo "Profile check complete, errors(CIEDE2000): max. = 2.41, avg. = 0.85, RMS = 1.02" +exit 0 diff --git a/Tests/ICCeryUITests/Fixtures/bin/spotread b/Tests/ICCeryUITests/Fixtures/bin/spotread new file mode 100755 index 0000000..fb88e77 --- /dev/null +++ b/Tests/ICCeryUITests/Fixtures/bin/spotread @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Mock spotread for Milestone10SpotReadUITests. + +Emits the real spotread prompt phrasing; each trigger line produces one +"Result is XYZ: …, D50 Lab: …" sample. Override the emitted colour with +MOCK_SPOTREAD_LAB / MOCK_SPOTREAD_XYZ. 'q' quits with exit 0. +Usage: spotread -v -e [-c port] [-Y l] +""" +import os +import sys + +LAB = os.environ.get("MOCK_SPOTREAD_LAB", "51.9 -8.3 12.2") +XYZ = os.environ.get("MOCK_SPOTREAD_XYZ", "18.51 20.05 15.71") + + +def read_line(): + try: + return sys.stdin.readline() + except Exception: + return "" + + +def main(): + print("Spot read needs a calibration before continuing") + print("Place instrument on spot reading white calibration tile,") + print(" and then hit any key to continue,") + print("or hit Esc or Q to abort:") + sys.stdout.flush() + if not read_line(): + return 0 + print("Calibration successful.") + + while True: + print("Place instrument on a spot to be measured,") + print(" and hit a key to take a reading,") + print("or hit Esc or Q to abort:") + sys.stdout.flush() + line = read_line() + if not line: + return 0 + if line.strip().lower().startswith("q"): + return 0 + print("Result is XYZ: %s, D50 Lab: %s" % (XYZ, LAB)) + sys.stdout.flush() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Tests/ICCeryUITests/Fixtures/bin/targen b/Tests/ICCeryUITests/Fixtures/bin/targen new file mode 100755 index 0000000..d5f46c5 --- /dev/null +++ b/Tests/ICCeryUITests/Fixtures/bin/targen @@ -0,0 +1,13 @@ +#!/bin/sh +# Mock targen for Milestone2UITests. Emits a fake .ti1 next to the +# basename (last argv) in the process working directory. Exit code is +# overridable via ICCERY_MOCK_TARGEN_EXIT. +last="" +for arg in "$@"; do last="$arg"; done +echo "targen mock: generating $last" +if [ "${ICCERY_MOCK_TARGEN_EXIT:-0}" -ne 0 ]; then + echo "mock targen failure" >&2 + exit "$ICCERY_MOCK_TARGEN_EXIT" +fi +touch "$last.ti1" +exit 0 diff --git a/Tests/ICCeryUITests/Milestone10GamutCompareUITests.swift b/Tests/ICCeryUITests/Milestone10GamutCompareUITests.swift new file mode 100644 index 0000000..5ec28d4 --- /dev/null +++ b/Tests/ICCeryUITests/Milestone10GamutCompareUITests.swift @@ -0,0 +1,291 @@ +import Foundation +import Metal +import XCTest + +/// Milestone 10 — Issue #147 gamut compare chrome tests. +/// +/// Sheet-opened only: no GPU hit-test assertions (no reliable SceneKit +/// click on the runner). Containment itself is covered by +/// `GamutContainmentTests`. +@MainActor +final class Milestone10GamutCompareUITests: XCTestCase { + + /// Metal on the test host — the app under test runs on the same + /// machine, so this predicts whether the sheet mounts SceneKit. + /// GPU-less runners still get the banner/Close assertions (#147). + private var hasGPU: Bool { MTLCreateSystemDefaultDevice() != nil } + + private var app: XCUIApplication! + private var testRoot: URL! + private var binDir: URL! + private var workDir: URL! + private var appDataDir: URL! + private var referenceGamutURL: URL! + + override func setUp() async throws { + continueAfterFailure = false + testRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-ui-m10-gamut-\(UUID().uuidString)") + binDir = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .appendingPathComponent("Fixtures/bin") + workDir = testRoot.appendingPathComponent("work") + appDataDir = testRoot.appendingPathComponent("AppData") + referenceGamutURL = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Resources/Argyll/reference_gamuts/sRGB.gam") + + try FileManager.default.createDirectory( + at: workDir, withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: appDataDir, withIntermediateDirectories: true) + + app = XCUIApplication() + app.launchEnvironment = [ + "ICCERY_UI_TESTING": "1", + "ICCERY_TEST_ROOT": testRoot.path, + "ICCERY_ARGYLL_BINARY_DIR": binDir.path, + "ICCERY_TEST_WORKDIR": workDir.path, + ] + } + + override func tearDown() async throws { + // Never leave the gamut sheet up for `terminate()` (#147). + if app != nil, element("btnCloseGamut").exists { + element("btnCloseGamut").click() + } + app?.terminate() + app = nil + if let testRoot { + try? FileManager.default.removeItem(at: testRoot) + } + testRoot = nil + } + + private func element(_ id: String) -> XCUIElement { + let inApp = app.descendants(matching: .any)[id].firstMatch + if inApp.exists { return inApp } + let inSheet = app.sheets.firstMatch.descendants(matching: .any)[id].firstMatch + if inSheet.exists { return inSheet } + // Menu popup items live outside the window hierarchy. + return app.menuItems[id].firstMatch + } + + private func waitFor(_ id: String, timeout: TimeInterval = 15) -> 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 + } + + /// Exists **and** `isEnabled`. Layer toggles render as disabled + /// placeholders until the async layer load lands — on the macOS 12 + /// runner `waitFor` alone wins the race against `parse`. + private func waitUntilEnabled(_ id: String, timeout: TimeInterval = 15) -> XCUIElement { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + let el = element(id) + if el.exists && el.isEnabled { return el } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + let el = element(id) + XCTAssertTrue( + el.exists && el.isEnabled, "Expected enabled element \(id)") + return el + } + + private func launchApp() { + app.launch() + if !app.wait(for: .runningForeground, timeout: 10) { + app.activate() + } + } + + private func openGamutSheet() { + launchApp() + waitFor("btnViewGamut").click() + _ = waitFor("gamutView") + } + + /// Inverse of `waitFor` — polls until the element leaves the tree. + private func waitForGone(_ id: String, timeout: TimeInterval = 10) { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if !element(id).exists { return } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + XCTAssertFalse(element(id).exists, "Expected element \(id) to disappear") + } + + /// `btnCloseGamut` dismisses the sheet so `tearDown`'s `terminate()` + /// is not stuck behind a key sheet (#147). No-op when already closed. + private func closeGamutSheet() { + let close = element("btnCloseGamut") + guard close.waitForExistence(timeout: 5) else { return } + close.click() + waitForGone("gamutView") + } + + func testLayerTogglesExistWithSRGB() throws { + openGamutSheet() + + let srgb = waitUntilEnabled("gamutLayer-sRGB") + XCTAssertTrue(srgb.exists) + // NSButton checkbox value is 1 when checked. + XCTAssertEqual(srgb.value as? Int, 1, "sRGB layer should be on") + + let compare = waitFor("gamutLayer-compare") + XCTAssertTrue(compare.exists) + XCTAssertFalse(compare.isEnabled, "Compare toggle must be disabled before a load") + + let status = waitFor("gamutStatusText") + let statusValue = status.value as? String ?? "" + XCTAssertTrue(statusValue.contains("sRGB"), "Status should list the sRGB layer, got: \(statusValue)") + closeGamutSheet() + } + + func testAddCompareButtonExists() throws { + openGamutSheet() + + waitFor("btnGamutAddCompare").click() + // No ICCERY_TEST_GAMUT_FILE set → the stubbed picker cancels; + // the sRGB status must stay non-empty. + waitFor("btnGamutOpenGam").click() + + let status = waitFor("gamutStatusText") + let value = status.value as? String ?? "" + XCTAssertTrue(value.contains("sRGB"), "Status should keep the sRGB clause, got: \(value)") + closeGamutSheet() + } + + func testCompareGamLoadEnablesToggle() throws { + let compareURL = workDir.appendingPathComponent("compare.gam") + try FileManager.default.copyItem(at: referenceGamutURL, to: compareURL) + app.launchEnvironment["ICCERY_TEST_GAMUT_FILE"] = compareURL.path + + openGamutSheet() + waitFor("btnGamutAddCompare").click() + waitFor("btnGamutOpenGam").click() + + // The pre-load placeholder also exists — wait for enabled. + let compare = waitUntilEnabled("gamutLayer-compare") + XCTAssertTrue(compare.isEnabled, "Compare toggle should enable after load") + XCTAssertEqual(compare.value as? Int, 1, "Compare layer should be on after load") + + let status = waitFor("gamutStatusText") + let value = status.value as? String ?? "" + XCTAssertTrue(value.contains("compare"), "Status should list the compare layer, got: \(value)") + + let remove = waitFor("btnGamutRemoveCompare") + XCTAssertTrue(remove.isEnabled) + closeGamutSheet() + } + + func testOpenProfileRunsIccgamutForCompare() throws { + // A profile with no sibling .gam → the mock iccgamut writes one. + let profileURL = workDir.appendingPathComponent("myprinter.icc") + try Data("MOCK_ICC".utf8).write(to: profileURL) + app.launchEnvironment["ICCERY_TEST_GAMUT_PROFILE"] = profileURL.path + app.launchEnvironment["ICCERY_MOCK_GAMUT_SOURCE"] = referenceGamutURL.path + + openGamutSheet() + waitFor("btnGamutAddCompare").click() + waitFor("btnGamutOpenProfile").click() + + let compare = waitUntilEnabled("gamutLayer-compare") + XCTAssertTrue(compare.isEnabled, "Compare toggle should enable after iccgamut") + XCTAssertEqual(compare.value as? Int, 1, "Compare layer should be on after iccgamut") + + // The compare slot's display name is the .gam stem ("myprinter"). + let status = waitFor("gamutStatusText") + let statusValue = status.value as? String ?? "" + XCTAssertTrue(statusValue.contains("myprinter"), "Status should list the compare layer, got: \(statusValue)") + closeGamutSheet() + } + + func testInspectPanelIdleStableHeight() throws { + openGamutSheet() + + let panel = waitFor("gamutInspectPanel") + XCTAssertTrue(panel.exists) + XCTAssertTrue(element("gamutInspectIdle").exists) + XCTAssertTrue(element("gamutStatusText").exists) + closeGamutSheet() + } + + func testManualLabInspectShowsContainment() throws { + openGamutSheet() + + waitFor("gamutLabEntryL").click() + element("gamutLabEntryL").typeText("50") + element("gamutLabEntryA").click() + element("gamutLabEntryA").typeText("0") + element("gamutLabEntryB").click() + element("gamutLabEntryB").typeText("0") + + waitFor("btnGamutInspectLab").click() + + let inside = waitFor("gamutInspect-sRGB") + let value = inside.value as? String ?? inside.label + XCTAssertTrue(value.contains("in"), "Lab(50,0,0) should be inside sRGB, got: \(value)") + XCTAssertTrue(element("gamutInspectL").exists) + XCTAssertTrue(element("gamutInspectSwatch").exists) + closeGamutSheet() + } + + func testResetIdentifierUnchanged() throws { + openGamutSheet() + let reset = waitFor("btnResetGamutCamera") + XCTAssertTrue(reset.isEnabled) + closeGamutSheet() + } + + /// `btnCloseGamut` is always enabled — including on the fallback + /// banner — and dismisses the sheet (#147). + func testCloseButtonDismissesSheet() throws { + openGamutSheet() + + let close = waitFor("btnCloseGamut") + XCTAssertTrue(close.isEnabled) + close.click() + waitForGone("gamutView") + } + + /// The fallback banner exists exactly when the host lacks Metal — + /// no `SCNView` is mounted on a GPU-less runner, and none may be + /// reported unavailable on a GPU host. + func testFallbackBannerMatchesGPUAvailability() throws { + openGamutSheet() + + if hasGPU { + XCTAssertFalse( + element("gamutViewerUnavailable").exists, + "GPU host must mount the SceneKit view, not the fallback") + } else { + _ = waitFor("gamutViewerUnavailable") + } + closeGamutSheet() + } + + /// `ICCERY_TEST_SKIP_SCENEKIT=1` forces the fallback even on a GPU + /// host — banner plus a working Close, no `SCNView` mounted (#147). + /// The env is set for this test only; the default launch env must + /// not carry it, or CI's future GPU run would skip SceneKit too. + func testForcedSceneKitSkipShowsBannerAndClose() throws { + app.launchEnvironment["ICCERY_TEST_SKIP_SCENEKIT"] = "1" + openGamutSheet() + + _ = waitFor("gamutViewerUnavailable") + let close = waitFor("btnCloseGamut") + XCTAssertTrue(close.isEnabled) + close.click() + waitForGone("gamutView") + } +} diff --git a/Tests/ICCeryUITests/Milestone10MediaLibraryUITests.swift b/Tests/ICCeryUITests/Milestone10MediaLibraryUITests.swift new file mode 100644 index 0000000..0daf084 --- /dev/null +++ b/Tests/ICCeryUITests/Milestone10MediaLibraryUITests.swift @@ -0,0 +1,164 @@ +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 +/// `/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() + + // The window banner (`noticeText`) sits behind this sheet on + // Monterey (#170). Assert the in-sheet copy instead. + let notice = waitFor("manageMediaNotice", timeout: 15) + 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) + } +} diff --git a/Tests/ICCeryUITests/Milestone10ProjectUITests.swift b/Tests/ICCeryUITests/Milestone10ProjectUITests.swift new file mode 100644 index 0000000..25db5f9 --- /dev/null +++ b/Tests/ICCeryUITests/Milestone10ProjectUITests.swift @@ -0,0 +1,263 @@ +import XCTest + +/// Milestone 10 UI tests — issue #149 project file. Panels are never +/// real: `ICCERY_TEST_PROJECT_OPEN` / `ICCERY_TEST_PROJECT_SAVE` +/// inject fixture paths through `UITestHooks`. Menu commands are driven +/// through the File menu when it is in the AX tree, else by their +/// keyboard shortcuts (⌘N) — the tests do not depend on menu AX +/// exposure (R19). All queries by identifier. +@MainActor +final class Milestone10ProjectUITests: XCTestCase { + + private var app: XCUIApplication! + private var testRoot: URL! + private var binDir: URL! + private var workDir: URL! + + override func setUp() async throws { + continueAfterFailure = false + testRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-ui10p-\(UUID().uuidString)") + workDir = testRoot.appendingPathComponent("WorkDir") + binDir = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .appendingPathComponent("Fixtures/bin") + + try FileManager.default.createDirectory( + at: testRoot.appendingPathComponent("AppData"), + withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: workDir, withIntermediateDirectories: true) + + 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 + workDir = 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 noticeText(timeout: TimeInterval = 10) -> String { + let el = waitFor("noticeText", timeout: timeout) + return (el.value as? String) ?? el.label + } + + /// Alert/sheet button by visible title, falling back to the a11y + /// id; nil when neither matches. macOS 12 SwiftUI alerts often + /// drop `accessibilityIdentifier` on their buttons, so the title + /// is the reliable handle there. + private func alertButton(title: String, id: String) -> XCUIElement? { + let inDialog = app.dialogs.firstMatch.buttons[title].firstMatch + if inDialog.exists { return inDialog } + let inSheet = app.sheets.firstMatch.buttons[title].firstMatch + if inSheet.exists { return inSheet } + let byId = element(id) + return byId.exists ? byId : nil + } + + /// Fires File ▸ New Project via the menu when it is in the AX + /// tree, else ⌘N. On macOS 12 `typeKey` may not reach the + /// `CommandGroup`, and menu item ids are unreliable — the menu + /// item is matched by its "New Project" label first. + private func triggerNewProject() { + let fileMenu = app.menuBarItems["File"] + if fileMenu.waitForExistence(timeout: 5) { + fileMenu.click() + let byTitle = app.menuItems["New Project"].firstMatch + let byId = app.menuItems["menuProjectNew"].firstMatch + let item = byTitle.exists ? byTitle : byId + if item.waitForExistence(timeout: 5) { + item.click() + return + } + app.typeKey(XCUIKeyboardKey.escape, modifierFlags: []) + } + app.typeKey("n", modifierFlags: .command) + } + + /// Writes a `.icceryproj` fixture under `testRoot` and points the + /// open-picker hook at it. + private func stageProjectFixture( + basename: String = "ui149job", + lastVerification: Bool = false + ) throws -> URL { + let verification = lastVerification ? """ + "last_verification": { + "date": "2026-09-12T00:00:00Z", + "avg_de00": 0.7, + "max_de00": 1.9, + "status": "excellent", + "profile_filename": "\(basename).icc" + }, + """ : "" + let json = """ + { + "schema_version": 1, + "name": "UI Fixture Project", + "notes": "", + "basename": "\(basename)", + "cwd": "\(workDir.path)", + "printer_id": null, + "media_recipe_id": null, + "preset_id": null, + "calibration_url": null, + \(verification) + "updated": "2026-09-13T00:00:00Z" + } + """ + let url = testRoot.appendingPathComponent("fixture.icceryproj") + try json.write(to: url, atomically: true, encoding: .utf8) + app.launchEnvironment["ICCERY_TEST_PROJECT_OPEN"] = url.path + return url + } + + private func artefact(_ ext: String, stem: String = "ui149job") throws { + try "x".write( + to: workDir.appendingPathComponent("\(stem).\(ext)"), + atomically: true, encoding: .utf8) + } + + // MARK: - Tests + + func testNewProjectClearsBasenameDoesNotDeleteFixtureTi3() throws { + try artefact("ti3") + _ = try stageProjectFixture() + launchApp() + + // Open the fixture via the chip — never a real panel. + waitFor("btnProjectOpen").click() + _ = waitFor("projectChipPath") + let basenameField = app.textFields["targetBasename"] + XCTAssertTrue(basenameField.waitForExistence(timeout: 10)) + XCTAssertEqual(basenameField.value as? String, "ui149job") + + // File ▸ New Project when the menu is in the AX tree, else + // ⌘N. Mock CUPS may have enumerated a queue that the fixture + // does not record, making the session dirty — in that case + // the dirty alert gates New first. macOS 12 alerts often lack + // button identifiers, so confirm by title with id fallback. + triggerNewProject() + let deadline = Date().addingTimeInterval(10) + var confirmed = false + while Date() < deadline { + // Dirty sessions show the dirty alert first; discarding it + // runs the New reset directly (no second confirm). + if let discard = alertButton( + title: "Don't Save", id: "btnProjectDirtyDiscard") { + discard.click() + confirmed = true + break + } + if let start = alertButton( + title: "Start", id: "btnProjectNewConfirm") { + start.click() + confirmed = true + break + } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + XCTAssertTrue(confirmed, "expected the New or dirty alert") + + // Basename cleared (legal "no target" state — not a + // placeholder), fixture `.ti3` untouched on disk. + let cleared = basenameField.value as? String ?? "" + XCTAssertEqual(cleared, "") + XCTAssertTrue(FileManager.default.fileExists( + atPath: workDir.appendingPathComponent("ui149job.ti3").path)) + XCTAssertTrue(element("projectChip").exists) + XCTAssertEqual( + element("projectChipName").value as? String, "No project") + } + + func testOpenProjectDiskWinsOverJsonStage() throws { + // JSON claims a finished profile; disk stops at .ti2 (R18). + try artefact("ti2") + _ = try stageProjectFixture(lastVerification: true) + launchApp() + + waitFor("btnProjectOpen").click() + + let notice = noticeText() + XCTAssertTrue( + notice.contains("artefacts on disk stop at .ti2"), + "got: \(notice)") + XCTAssertTrue(element("projectChipStale").exists) + XCTAssertEqual( + element("projectChipPath").value as? String, workDir.lastPathComponent) + } + + func testSaveDisabledWithoutBasename() throws { + launchApp() + + _ = waitFor("projectChip") + XCTAssertEqual(element("projectChipName").value as? String, "No project") + // Chip Save is hidden while unbound; Save As lives in the menu. + XCTAssertFalse(element("btnProjectSave").exists) + + // When the File menu is in the AX tree, Save must be disabled. + let fileMenu = app.menuBarItems["File"] + if fileMenu.waitForExistence(timeout: 3) { + fileMenu.click() + let save = app.menuItems["menuProjectSave"].firstMatch + if save.waitForExistence(timeout: 3) { + XCTAssertFalse(save.isEnabled) + } + app.typeKey(XCUIKeyboardKey.escape, modifierFlags: []) + } + } + + func testCalBasenameRefused() throws { + // A fixture whose stem is CAL_-prefixed binds a live CAL_ + // basename; the persisted original is empty, so Save must + // refuse and never write CAL_ back (R11). + let url = try stageProjectFixture(basename: "CAL_ui149") + let before = try Data(contentsOf: url) + launchApp() + + waitFor("btnProjectOpen").click() + let save = waitFor("btnProjectSave") + XCTAssertTrue(save.isEnabled) + save.click() + + XCTAssertTrue( + noticeText().contains("Finish or exit calibration"), + "got: \(noticeText())") + XCTAssertEqual(try Data(contentsOf: url), before) + } +} diff --git a/Tests/ICCeryUITests/Milestone10SpotReadUITests.swift b/Tests/ICCeryUITests/Milestone10SpotReadUITests.swift new file mode 100644 index 0000000..885bbeb --- /dev/null +++ b/Tests/ICCeryUITests/Milestone10SpotReadUITests.swift @@ -0,0 +1,235 @@ +import XCTest + +/// Milestone 10 UI tests — issue #148 spot-read console. Mock Argyll +/// sidecars (`ICCERY_ARGYLL_BINARY_DIR` → `Fixtures/bin`) provide +/// `instlist`, `chartread`, and `spotread`; no real USB Detect is ever +/// clicked. All queries are by identifier only. +@MainActor +final class Milestone10SpotReadUITests: XCTestCase { + + private var app: XCUIApplication! + private var testRoot: URL! + private var binDir: URL! + private var workDir: URL! + + override func setUp() async throws { + continueAfterFailure = false + testRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-ui10spot-\(UUID().uuidString)") + binDir = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .appendingPathComponent("Fixtures/bin") + workDir = testRoot.appendingPathComponent("work") + try FileManager.default.createDirectory( + at: workDir, withIntermediateDirectories: true) + + app = XCUIApplication() + app.launchEnvironment = [ + "ICCERY_UI_TESTING": "1", + "ICCERY_TEST_ROOT": testRoot.path, + "ICCERY_ARGYLL_BINARY_DIR": binDir.path, + // Redirect the bundled root too so the real sidecars copied + // into the product by the build phase cannot mask a missing + // override binary (`testMissingSidecarShowsMessage`). + "ICCERY_ARGYLL_BUNDLED_ROOT": binDir.path, + "ICCERY_CUPS_BIN_DIR": binDir.path, + "ICCERY_TEST_SAVE_TARGET": + workDir.appendingPathComponent("mytarget.ti1").path, + "ICCERY_TEST_WORKDIR": workDir.path, + "ICCERY_TEST_CSV_EXPORT": + workDir.appendingPathComponent("spot-history.csv").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() + } + } + + /// Seed `wizard_state.json` with a working directory so `btnSpotRead` + /// is enabled without driving the whole Stage 1/2 flow. + private func seedWorkingDirectory() throws { + let appData = testRoot.appendingPathComponent("AppData", isDirectory: true) + try FileManager.default.createDirectory( + at: appData, withIntermediateDirectories: true) + let state = """ + { + "currentStage": 0, + "basename": "spotui", + "cwd": "\(workDir.path)", + "sessionMode": "profile" + } + """ + try state.write( + to: appData.appendingPathComponent("wizard_state.json"), + atomically: true, encoding: .utf8) + } + + 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 + } + + // MARK: - Sidebar gating + + func testSpotReadButtonDisabledWithoutCwd() throws { + launchApp() + let button = app.buttons["btnSpotRead"] + XCTAssertTrue(button.waitForExistence(timeout: 10)) + XCTAssertFalse(button.isEnabled) + } + + func testSpotReadButtonDisabledDuringChartread() throws { + launchApp() + // Drive to Stage 3 with the mock targen/printtarg fixtures. + app.buttons["btnBrowse"].click() + app.buttons["btnGenerate"].click() + _ = waitFor("btnCreateLayout", timeout: 20) + app.buttons["btnCreateLayout"].click() + _ = waitFor("galleryPage-0", timeout: 20) + _ = waitFor("btnAdvanceToStage3", timeout: 10) + app.buttons["btnAdvanceToStage3"].click() + _ = waitFor("stage3TargetBasename", timeout: 10) + + // Start the mock chartread — it blocks on the calibrate prompt. + app.buttons["btnStartRead"].click() + _ = waitFor("btnCalibrate", timeout: 25) + + let button = app.buttons["btnSpotRead"] + XCTAssertTrue(button.exists) + XCTAssertFalse(button.isEnabled) + + // Clean up the live chartread child before teardown. + if app.buttons["btnCancel"].exists { + app.buttons["btnCancel"].click() + } + } + + // MARK: - Sheet contract + + func testSheetHasOwnInstrumentIds() throws { + try seedWorkingDirectory() + launchApp() + + let button = app.buttons["btnSpotRead"] + XCTAssertTrue(button.waitForExistence(timeout: 10)) + let deadline = Date().addingTimeInterval(10) + while !button.isEnabled, Date() < deadline { + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + XCTAssertTrue(button.isEnabled) + button.click() + + _ = waitFor("spotReadView", timeout: 10) + XCTAssertTrue(element("spotInstrumentSelect").waitForExistence(timeout: 10)) + // Stage 3 ids must not appear inside the sheet. + XCTAssertFalse( + app.sheets.firstMatch.descendants(matching: .any)["chartreadInstrumentSelect"].exists) + XCTAssertFalse( + app.sheets.firstMatch.descendants(matching: .any)["btnDetectInstruments"].exists) + XCTAssertTrue(element("btnCloseSpotRead").exists) + } + + func testMissingSidecarShowsMessage() throws { + // Point the override at an empty dir; the bundled root has no + // real sidecars in this checkout, so resolve() misses both. + let emptyBin = testRoot.appendingPathComponent("empty-bin") + try FileManager.default.createDirectory( + at: emptyBin, withIntermediateDirectories: true) + app.launchEnvironment["ICCERY_ARGYLL_BINARY_DIR"] = emptyBin.path + try seedWorkingDirectory() + launchApp() + + let button = app.buttons["btnSpotRead"] + XCTAssertTrue(button.waitForExistence(timeout: 10)) + let deadline = Date().addingTimeInterval(10) + while !button.isEnabled, Date() < deadline { + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + button.click() + + _ = waitFor("spotReadView", timeout: 10) + XCTAssertTrue(element("spotSidecarMissing").waitForExistence(timeout: 10)) + XCTAssertFalse(element("btnSpotStart").exists) + XCTAssertFalse(element("btnSpotDetectInstruments").exists) + XCTAssertTrue(element("btnCloseSpotRead").exists) + } + + func testHistoryCopyDisabledWhenEmpty() throws { + try seedWorkingDirectory() + launchApp() + + let button = app.buttons["btnSpotRead"] + XCTAssertTrue(button.waitForExistence(timeout: 10)) + let deadline = Date().addingTimeInterval(10) + while !button.isEnabled, Date() < deadline { + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + button.click() + + _ = waitFor("spotReadView", timeout: 10) + XCTAssertTrue(element("spotHistoryEmpty").waitForExistence(timeout: 10)) + XCTAssertTrue(element("spotLastEmpty").exists) + XCTAssertFalse(element("btnSpotCopyLab").isEnabled) + XCTAssertFalse(element("btnSpotExportCsv").isEnabled) + } + + /// Full mock session: Start → Calibrate → Read produces one Lab + /// sample and enables Copy/Export. + func testMockSessionProducesSample() throws { + try seedWorkingDirectory() + launchApp() + + let button = app.buttons["btnSpotRead"] + XCTAssertTrue(button.waitForExistence(timeout: 10)) + let deadline = Date().addingTimeInterval(10) + while !button.isEnabled, Date() < deadline { + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + button.click() + + _ = waitFor("spotReadView", timeout: 10) + let start = element("btnSpotStart") + XCTAssertTrue(start.waitForExistence(timeout: 10)) + start.click() + + XCTAssertTrue(element("btnSpotCalibrate").waitForExistence(timeout: 15)) + element("btnSpotCalibrate").click() + + XCTAssertTrue(element("btnSpotTrigger").waitForExistence(timeout: 15)) + element("btnSpotTrigger").click() + + XCTAssertTrue(element("spotLastSample").waitForExistence(timeout: 15)) + XCTAssertTrue(element("spotLabL").exists) + XCTAssertTrue(element("spotSwatch").exists) + XCTAssertTrue(element("btnSpotCopyLab").isEnabled) + XCTAssertTrue(element("btnSpotExportCsv").isEnabled) + + element("btnSpotStop").click() + element("btnCloseSpotRead").click() + } +} diff --git a/Tests/ICCeryUITests/Milestone2UITests.swift b/Tests/ICCeryUITests/Milestone2UITests.swift new file mode 100644 index 0000000..3a46126 --- /dev/null +++ b/Tests/ICCeryUITests/Milestone2UITests.swift @@ -0,0 +1,412 @@ +import XCTest + +/// Milestone 2 UI tests — issues #7–#11 (docs/21 element contract). +/// Every test launches the app with an isolated `ICCERY_TEST_ROOT`, +/// fixture sidecars via `ICCERY_ARGYLL_BINARY_DIR`, and +/// `ICCERY_UI_TESTING=1` so file dialogs resolve to env-provided +/// paths instead of modal panels. No hardware, no network, no real +/// Argyll install, and nothing is written to the developer's app data. +@MainActor +final class Milestone2UITests: XCTestCase { + + private var app: XCUIApplication! + private var testRoot: URL! + private var binDir: URL! + private var workDir: URL! + + override func setUp() async throws { + continueAfterFailure = false + // The xctrunner sandbox only permits writes inside its own + // container — the work dir lives there (the app can read/write + // it). Executable fixtures, however, must live outside the + // container or the app-under-test cannot posix_spawn them, so + // `bin` points at the committed Fixtures/bin scripts in the + // repo checkout (resolved via #filePath). + testRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-ui-\(UUID().uuidString)") + binDir = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // Tests/ICCeryUITests + .appendingPathComponent("Fixtures/bin") + workDir = testRoot.appendingPathComponent("work") + try FileManager.default.createDirectory( + at: workDir, withIntermediateDirectories: true) + + 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, + "ICCERY_TEST_SAVE_TARGET": + workDir.appendingPathComponent("mytarget.ti1").path, + "ICCERY_TEST_WORKDIR": workDir.path, + ] + } + + override func tearDown() async throws { + app?.terminate() + app = nil + if let testRoot { + try? FileManager.default.removeItem(at: testRoot) + } + testRoot = nil + } + + /// Force the fixture printtarg to exit with `code`. + private func failPrinttarg(exitCode: Int) { + app.launchEnvironment["ICCERY_MOCK_PRINTTARG_EXIT"] = "\(exitCode)" + } + + /// Launch and bring the app to the front — other app windows + /// (the IDE, notification banners) covering the test window count + /// as "interrupting elements" and stall synthesized clicks. + private func launchApp() { + app.launch() + app.activate() + } + + /// Sheet content on macOS lives under `app.sheets`, outside the + /// main window's descendant tree — probe both scopes. + private func element(_ id: String) -> XCUIElement { + let inApp = app.descendants(matching: .any)[id] + if inApp.exists { return inApp } + return app.sheets.firstMatch.descendants(matching: .any)[id] + } + + private func waitFor(_ id: String, timeout: TimeInterval = 10) -> XCUIElement { + // Poll both scopes so sheet-hosted elements resolve too. + 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 + } + + /// Exists **and** `isEnabled` — guards clicks against buttons that + /// appear a beat before their `.disabled` condition clears. + private func waitUntilEnabled(_ id: String, timeout: TimeInterval = 10) -> XCUIElement { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + let el = element(id) + if el.exists && el.isEnabled { return el } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + let el = element(id) + XCTAssertTrue( + el.exists && el.isEnabled, "Expected enabled element \(id)") + return el + } + + /// Non-asserting existence poll for the retry-or-fail pattern. + private func existsAfter(_ id: String, timeout: TimeInterval) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if element(id).exists { return true } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + return element(id).exists + } + + /// 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 } + return app.sheets.firstMatch.staticTexts[exact] + } + + private func buttonsMatching(_ predicateFormat: String) -> XCUIElementQuery { + let pred = NSPredicate(format: predicateFormat) + let inApp = app.buttons.matching(pred) + if inApp.count > 0 { return inApp } + return app.sheets.firstMatch.buttons.matching(pred) + } + + // MARK: - Tests + + /// Stage 1 opens with the Standard 800-patch default; Generate stays + /// disabled until basename + cwd are valid (issue #7). + func testStage1DefaultsAndGenerateGate() throws { + launchApp() + XCTAssertTrue(waitFor("btnGenerate").exists) + XCTAssertTrue(element("patchCountPreset").exists) + XCTAssertTrue(element("targetBasename").exists) + XCTAssertTrue(element("btnOpenExisting").exists) + XCTAssertFalse(app.buttons["btnGenerate"].isEnabled) + + // Browse fills basename + working dir via the test hook. + app.buttons["btnBrowse"].click() + XCTAssertTrue(app.buttons["btnGenerate"].isEnabled) + } + + /// RGB/CMYK + advanced controls expose the documented identifiers + /// and the ink-limit group is hidden for RGB (issue #7). + func testStage1AdvancedVisibility() throws { + launchApp() + XCTAssertTrue(waitFor("targenAdvancedDetails").exists) + // RGB default: ink-limit group must not exist. + XCTAssertFalse(element("targenInkLimitGroup").exists) + // The ink-limit group lives inside the Advanced disclosure — + // pre-expanded under UI testing (XCUI can't toggle a macOS + // DisclosureTriangle reliably). Switch the picker to CMYK. + XCTAssertTrue(element("targenAdvancedDetails").exists) + let cmyk = app.radioGroups["colourSpace"] + .radioButtons["CMYK (RIP output)"] + XCTAssertTrue(cmyk.waitForExistence(timeout: 5)) + cmyk.click() + XCTAssertTrue(element("targenInkLimitGroup").waitForExistence(timeout: 5)) + } + + /// Stage 1/2 process-log containers resolve under the shared + /// `ProcessLogView` identifiers (issue #80). + func testProcessLogContainersResolve() throws { + launchApp() + XCTAssertTrue(waitFor("targenLogContainer").exists) + + app.buttons["btnBrowse"].click() + app.buttons["btnGenerate"].click() + XCTAssertTrue(waitFor("btnCreateLayout", timeout: 20).exists) + XCTAssertTrue(element("printtargLogContainer").exists) + } + + /// Fixture-backed targen run creates .ti1 and unlocks Stage 2. + func testTargenFixtureUnlocksStage2() throws { + launchApp() + app.buttons["btnBrowse"].click() + app.buttons["btnGenerate"].click() + XCTAssertTrue(waitFor("btnCreateLayout", timeout: 20).exists) + XCTAssertTrue(FileManager.default.fileExists( + atPath: workDir.appendingPathComponent("mytarget.ti1").path)) + } + + /// Fixture printtarg → .ti2, gallery page renders, print controls + /// stay disabled, Stage 3 advance becomes available (issues #9/#10). + func testPrinttargFixtureGalleryAndStubbedPrint() throws { + launchApp() + + app.buttons["btnBrowse"].click() + app.buttons["btnGenerate"].click() + XCTAssertTrue(waitFor("btnCreateLayout", timeout: 20).exists) + + // Colour-management warning is always present on Stage 2. + XCTAssertTrue(element("cmWarningBanner").exists) + XCTAssertTrue(element("instrumentSelect").exists) + XCTAssertTrue(element("pageSizeSelect").exists) + XCTAssertTrue(element("tiffDpi").exists) + XCTAssertTrue(element("targetLabelPreview").exists) + + // On the slow macOS 12 runner a synthesized click can land + // while the button is still rebuilding — retry once if the + // gallery never materialises, then allow a generous window + // for the fixture printtarg + PNG render. + waitUntilEnabled("btnCreateLayout").click() + if !existsAfter("galleryPage-0", timeout: 15) { + waitUntilEnabled("btnCreateLayout").click() + } + XCTAssertTrue(waitFor("galleryPage-0", timeout: 30).exists) + XCTAssertTrue(FileManager.default.fileExists( + atPath: workDir.appendingPathComponent("mytarget.ti2").path)) + + // Print panel is live from M3. GitHub macos-14 has no system + // queues, so mock CUPS (`ICCERY_CUPS_BIN_DIR`) must enumerate + // before Print All / per-page enable (run 34867767434). + XCTAssertTrue(element("rawPrintPanel").exists) + _ = waitUntilEnabled("btnPrintAll", timeout: 15) + _ = waitUntilEnabled("btnPrintPage-0", timeout: 15) + XCTAssertTrue(app.buttons["btnAdvanceToStage3"].isEnabled) + } + + /// A failed printtarg run stays on Stage 2 (non-zero exit, #156). + func testPrinttargFailureStaysOnStage2() throws { + failPrinttarg(exitCode: 3) + launchApp() + app.buttons["btnBrowse"].click() + app.buttons["btnGenerate"].click() + XCTAssertTrue(waitFor("btnCreateLayout", timeout: 20).exists) + + app.buttons["btnCreateLayout"].click() + // The notice banner reports the failure and we never advance: + // btnCreateLayout is still the stage's action, and no gallery + // appears. + let failureText = element("noticeText") + XCTAssertTrue(failureText.waitForExistence(timeout: 20)) + XCTAssertTrue((failureText.value as? String ?? "") + .contains("printtarg failed")) + XCTAssertTrue(element("btnCreateLayout").exists) + XCTAssertFalse(element("galleryPage-0").exists) + XCTAssertFalse(FileManager.default.fileExists( + atPath: workDir.appendingPathComponent("mytarget.ti2").path)) + } + + /// Resume: .ti1 jumps to Stage 2 (issue #8). + func testResumeTi1() throws { + FileManager.default.createFile( + atPath: workDir.appendingPathComponent("old.ti1").path, + contents: Data("CGATS".utf8)) + app.launchEnvironment["ICCERY_TEST_EXISTING_TARGET"] = + workDir.appendingPathComponent("old.ti1").path + launchApp() + app.buttons["btnOpenExisting"].click() + XCTAssertTrue(waitFor("btnCreateLayout", timeout: 10).exists) + } + + /// Resume: .ti2 with sibling .ti1 reaches the Stage 3 shell and + /// shows the persisted "Resumed from .ti2" state (issue #8). + func testResumeTi2ShowsStage3AndNotice() throws { + FileManager.default.createFile( + atPath: workDir.appendingPathComponent("old.ti1").path, + contents: Data("CGATS".utf8)) + FileManager.default.createFile( + atPath: workDir.appendingPathComponent("old.ti2").path, + contents: Data(""" + CTI2 + TARGET_INSTRUMENT "i1" + NUMBER_OF_SETS 4 + NUMBER_OF_PAGES 1 + BEGIN_DATA_FORMAT + """.utf8)) + app.launchEnvironment["ICCERY_TEST_EXISTING_TARGET"] = + workDir.appendingPathComponent("old.ti2").path + launchApp() + app.buttons["btnOpenExisting"].click() + XCTAssertTrue(waitFor("stage3TargetBasename", timeout: 10).exists) + XCTAssertTrue(element("stage3LoadedTargetBanner").exists) + let notice = element("noticeText") + XCTAssertTrue(notice.exists) + XCTAssertTrue((notice.value as? String ?? "") + .contains("Resumed from .ti2")) + } + + /// A .ti2 without its sibling .ti1 must not advance (issue #8). + func testResumeTi2WithoutSiblingFails() throws { + FileManager.default.createFile( + atPath: workDir.appendingPathComponent("orphan.ti2").path, + contents: Data("CTI2".utf8)) + app.launchEnvironment["ICCERY_TEST_EXISTING_TARGET"] = + workDir.appendingPathComponent("orphan.ti2").path + launchApp() + app.buttons["btnOpenExisting"].click() + let err = element("noticeText") + XCTAssertTrue(err.waitForExistence(timeout: 10)) + XCTAssertTrue((err.value as? String ?? "").contains("Cannot resume")) + XCTAssertTrue(element("btnGenerate").exists) // still Stage 1 + } + + /// Preset apply is bidirectional: the draft preset's 150 dpi must + /// be visible on Stage 2; built-ins cannot be deleted (issue #11). + func testPresetApplyAndBuiltInProtection() throws { + // Land on Stage 2 via a .ti1 resume so tiffDpi is visible. + FileManager.default.createFile( + atPath: workDir.appendingPathComponent("p.ti1").path, + contents: Data("CGATS".utf8)) + app.launchEnvironment["ICCERY_TEST_EXISTING_TARGET"] = + workDir.appendingPathComponent("p.ti1").path + launchApp() + + // Sidebar preset picker is enabled; apply the draft preset. + let picker = app.popUpButtons["presetSelect"] + XCTAssertTrue(picker.waitForExistence(timeout: 10)) + XCTAssertTrue(picker.isEnabled) + picker.click() + let draftItem = app.menuItems["Fast RGB Draft (400 patches)"] + XCTAssertTrue(draftItem.waitForExistence(timeout: 5)) + draftItem.click() + + app.buttons["btnOpenExisting"].click() + XCTAssertTrue(waitFor("btnCreateLayout", timeout: 10).exists) + // StaticText content is exposed via `value` on macOS, not `label`. + XCTAssertTrue(app.staticTexts + .matching(NSPredicate(format: "value CONTAINS 'DPI: 150'")) + .firstMatch.waitForExistence(timeout: 5)) + + // Manage dialog: built-ins show "Built-in" and have no delete. + app.buttons["btnOpenPresetsDialog"].click() + XCTAssertTrue(waitFor("managePresetsList", timeout: 10).exists) + XCTAssertFalse(element("btnDeletePreset-preset-std-rgb").exists) + XCTAssertTrue(element("presetRow-preset-std-rgb").exists) + element("btnCloseManagePresetsDialog").click() + } + + /// Save a custom preset through the dialog; it appears in the list + /// and can be deleted (issue #11). + func testSaveAndDeleteCustomPreset() throws { + launchApp() + app.buttons["btnSavePresetModal"].click() + XCTAssertTrue(waitFor("savePresetDialog", timeout: 10).exists) + let nameField = element("savePresetName") + XCTAssertTrue(nameField.waitForExistence(timeout: 5)) + nameField.click() + nameField.typeText("UI Test Preset") + element("btnConfirmSavePreset").click() + + app.buttons["btnOpenPresetsDialog"].click() + XCTAssertTrue(waitFor("managePresetsList", timeout: 10).exists) + XCTAssertTrue(staticText("UI Test Preset") + .waitForExistence(timeout: 5)) + // The custom row is deletable (id prefix custom-). + let deleteButtons = buttonsMatching( + "identifier BEGINSWITH 'btnDeletePreset-'") + XCTAssertTrue(deleteButtons.firstMatch.waitForExistence(timeout: 5)) + deleteButtons.firstMatch.click() + assertAbsent(staticText("UI Test Preset")) + } + + /// Export a preset to JSON and re-import it (issue #11). + func testPresetExportImport() throws { + let exportURL = testRoot.appendingPathComponent("export.json") + let importURL = testRoot.appendingPathComponent("import.json") + app.launchEnvironment["ICCERY_TEST_PRESET_EXPORT"] = exportURL.path + app.launchEnvironment["ICCERY_TEST_PRESET_IMPORT"] = importURL.path + launchApp() + + // Save a custom preset first, then export it. + app.buttons["btnSavePresetModal"].click() + let nameField = element("savePresetName") + XCTAssertTrue(nameField.waitForExistence(timeout: 10)) + nameField.click() + nameField.typeText("RoundTrip") + element("btnConfirmSavePreset").click() + + // Export via the manage dialog. + app.buttons["btnOpenPresetsDialog"].click() + XCTAssertTrue(waitFor("managePresetsList", timeout: 10).exists) + let exportButtons = buttonsMatching( + "identifier BEGINSWITH 'btnExportPreset-'") + XCTAssertTrue(exportButtons.firstMatch.waitForExistence(timeout: 5)) + exportButtons.firstMatch.click() + XCTAssertTrue(waitForFile(exportURL), "preset export file missing") + + // Import must land back in the store (delete → re-import). + let deleteButtons = buttonsMatching( + "identifier BEGINSWITH 'btnDeletePreset-'") + deleteButtons.firstMatch.click() + assertAbsent(staticText("RoundTrip")) + + // Copy the export to the import path so the hook picks it up. + try FileManager.default.copyItem(at: exportURL, to: importURL) + element("btnImportPreset").click() + XCTAssertTrue(staticText("RoundTrip") + .waitForExistence(timeout: 5)) + } + + private func waitForFile(_ url: URL, timeout: TimeInterval = 5) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if FileManager.default.fileExists(atPath: url.path) { return true } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + return false + } +} diff --git a/Tests/ICCeryUITests/Milestone3UITests.swift b/Tests/ICCeryUITests/Milestone3UITests.swift new file mode 100644 index 0000000..bb20e1d --- /dev/null +++ b/Tests/ICCeryUITests/Milestone3UITests.swift @@ -0,0 +1,304 @@ +import XCTest + +/// Milestone 3 UI tests — issue #17 print panel end-to-end with mock +/// CUPS binaries and a stubbed `NSPrintPanel`. The real panel is a +/// system modal XCUITest cannot drive; `ICCERY_TEST_PRINT_PANEL` +/// returns a canned `PrintPropertiesResult` instead. Mock `lp` appends +/// its argv to `ICCERY_TEST_LP_ARGV` for assertions — that file is the +/// evidence that captured options are replayed (docs/11 §tests). +@MainActor +final class Milestone3UITests: XCTestCase { + + private var app: XCUIApplication! + private var testRoot: URL! + private var binDir: URL! + private var workDir: URL! + private var lpArgvURL: URL! + + override func setUp() async throws { + continueAfterFailure = false + testRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-ui3-\(UUID().uuidString)") + binDir = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .appendingPathComponent("Fixtures/bin") + workDir = testRoot.appendingPathComponent("work") + lpArgvURL = testRoot.appendingPathComponent("lp-argv.log") + try FileManager.default.createDirectory( + at: workDir, withIntermediateDirectories: true) + + 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, + "ICCERY_TEST_SAVE_TARGET": + workDir.appendingPathComponent("mytarget.ti1").path, + "ICCERY_TEST_WORKDIR": workDir.path, + "ICCERY_TEST_LP_ARGV": lpArgvURL.path, + ] + } + + override func tearDown() async throws { + app?.terminate() + app = nil + if let testRoot { + try? FileManager.default.removeItem(at: testRoot) + } + testRoot = nil + } + + private func waitForFileContent( + _ url: URL, + containing needle: String, + timeout: TimeInterval = 10 + ) -> String? { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if let data = try? Data(contentsOf: url), + let text = String(data: data, encoding: .utf8), + text.contains(needle) { + return text + } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + return (try? String(contentsOf: url, encoding: .utf8)) ?? "" + } + + private func launchApp() { + app.launch() + app.activate() + } + + private func element(_ id: String) -> XCUIElement { + let inApp = app.descendants(matching: .any)[id] + if inApp.exists { return inApp } + return app.sheets.firstMatch.descendants(matching: .any)[id] + } + + private func waitFor(_ id: String, timeout: TimeInterval = 15) -> 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 + } + + /// Drive the app through targen + printtarg so the print panel is + /// live with a manifest. + private func reachPrintPanel() { + app.buttons["btnBrowse"].click() + app.buttons["btnGenerate"].click() + _ = waitFor("btnCreateLayout", timeout: 25) + app.buttons["btnCreateLayout"].click() + _ = waitFor("galleryPage-0", timeout: 25) + } + + private func recordedLpArgv() -> String { + (try? String(contentsOf: lpArgvURL, encoding: .utf8)) ?? "" + } + + private func waitForLpLine(_ timeout: TimeInterval = 10) -> String { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + let out = recordedLpArgv() + if !out.isEmpty { return out } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + return recordedLpArgv() + } + + /// Drags `#galleryPage-0`'s TIFF upward so `identifier`'s button + /// moves up, clear of the Dock collision zone at the window's + /// bottom edge (#132). + /// + /// macOS overlay scrollbars are not in the AX tree — never use + /// `app.scrollBars` — and a synthesized scroll wheel is inert on + /// this LazyVGrid, so the scroll is a real drag on the gallery + /// cell's content. A stale/off-screen AX frame resolves to a screen + /// point that can be a Dock icon — a coordinate click there once + /// opened Calendar instead of Print. Callers must click only when + /// the returned element `isHittable`; never coordinate-click a + /// stale frame. + @discardableResult + private func scrollStage2UntilHittable( + _ identifier: String, + timeout: TimeInterval = 20 + ) -> XCUIElement { + var button = app.buttons[identifier] + let cell = app.descendants(matching: .any)["galleryPage-0"].firstMatch + XCTAssertTrue(cell.waitForExistence(timeout: 10), "galleryPage-0") + + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + let windowBottom = app.windows.firstMatch.frame.maxY + if button.exists, button.isHittable, + button.frame.maxY < windowBottom - 80 { + return button + } + // Grab the upper half of the cell (the TIFF, not the Print + // button / Dock) and drag toward the top of the window. + // Mouse moves UP ⇒ gallery content moves UP ⇒ Print leaves + // the Dock zone. + if cell.isHittable { + let start = cell.coordinate(withNormalizedOffset: + CGVector(dx: 0.5, dy: 0.25)) + let end = start.withOffset(CGVector(dx: 0, dy: -280)) + start.press(forDuration: 0.15, thenDragTo: end) + } else { + // Cell not hit-testable: drag the stage-2 content + // directly — still content, still never scrollBars. + let scrollView = app.scrollViews["stage-2"] + scrollView.coordinate(withNormalizedOffset: + CGVector(dx: 0.5, dy: 0.55)) + .press(forDuration: 0.15, thenDragTo: + scrollView.coordinate(withNormalizedOffset: + CGVector(dx: 0.5, dy: 0.15))) + } + RunLoop.current.run(until: Date().addingTimeInterval(0.4)) + button = app.buttons[identifier] + } + return button + } + + // MARK: - Tests + + /// Panel appears after the manifest; refresh populates the printer + /// select with the mock queues and shows a status badge. + func testPrintPanelEnumeratesPrinters() throws { + launchApp() + reachPrintPanel() + + XCTAssertTrue(waitFor("rawPrintPanel").exists) + // The panel auto-refreshes on appear; the default mock queue is + // selected and its status badge shows. + XCTAssertTrue(element("printerSelect").waitForExistence(timeout: 10)) + XCTAssertTrue(element("printerStatusBadge") + .waitForExistence(timeout: 10)) + XCTAssertTrue(element("printerTraySelect").exists) + XCTAssertTrue(element("printerMediaTypeSelect").exists) + XCTAssertTrue(element("btnOrientPortrait").exists) + XCTAssertTrue(element("btnOrientLandscape").exists) + XCTAssertTrue(app.buttons["btnPrintAll"].isEnabled) + } + + /// Preferences cancel → info notice, no error, no cache mutation. + func testPreferencesCancelIsInfo() throws { + app.launchEnvironment["ICCERY_TEST_PRINT_PANEL"] = "cancel" + launchApp() + reachPrintPanel() + _ = waitFor("printerStatusBadge") + + element("btnPrinterProperties").click() + let notice = element("printNotificationText") + XCTAssertTrue(notice.waitForExistence(timeout: 10)) + XCTAssertTrue((notice.value as? String ?? "") + .contains("cancelled")) + // Cancellation is informational, never an error (#80). + XCTAssertEqual(element("printNotificationIcon").value as? String, "info") + } + + /// Preferences OK → captured options are replayed verbatim in the + /// `lp` argv alongside the two mandatory AP_* headers (issue 17's + /// acceptance test: "captured options replayed in argv"). + func testCapturedOptionsReplayedInLpArgv() throws { + app.launchEnvironment["ICCERY_TEST_PRINT_PANEL"] = "ok" + app.launchEnvironment["ICCERY_TEST_PANEL_OPTIONS"] = + "InputSlot=Rear MediaType=PhotographicGlossy" + launchApp() + reachPrintPanel() + _ = waitFor("printerStatusBadge") + + element("btnPrinterProperties").click() + let notice = element("printNotificationText") + XCTAssertTrue(notice.waitForExistence(timeout: 10)) + XCTAssertTrue((notice.value as? String ?? "") + .contains("Settings captured")) + + app.buttons["btnPrintAll"].click() + let argv = waitForLpLine() + XCTAssertTrue(argv.contains( + "AP_ColorMatchingMode=AP_ApplicationColorMatching"), argv) + XCTAssertTrue(argv.contains( + "AP.ColorMatchingMode=AP_ApplicationColorMatching"), argv) + XCTAssertTrue(argv.contains("InputSlot=Rear"), argv) + XCTAssertTrue(argv.contains("MediaType=PhotographicGlossy"), argv) + // Detected bypass for the mock queue (EPIJ_CMat present in + // lpoptions -l) is appended when not captured. + XCTAssertTrue(argv.contains("EPIJ_CMat=3"), argv) + XCTAssertTrue(argv.contains("orientation-requested=3"), argv) + // Last token is the TIFF. + XCTAssertTrue(argv.trimmingCharacters(in: .whitespacesAndNewlines) + .hasSuffix("page1.tif"), argv) + } + + /// Per-page print uses the same spool path (btnPrintPage-N). + func testPerPagePrint() throws { + launchApp() + reachPrintPanel() + _ = waitFor("printerStatusBadge") + + // Wait for the async printer enumeration to select a queue; once + // `btnPrintAll` is enabled, `btnPrintPage-0` is too. + let deadline = Date().addingTimeInterval(15) + while Date() < deadline, !app.buttons["btnPrintAll"].isEnabled { + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + XCTAssertTrue(app.buttons["btnPrintAll"].isEnabled) + + // The gallery cell's Print button sits at the window's bottom + // edge; scroll until it is genuinely hittable (#132). Never + // coordinate-click a stale frame — that point can be the Dock. + let printPage = scrollStage2UntilHittable("btnPrintPage-0") + guard printPage.isHittable else { + print("AXTREE-BEGIN frame=\(printPage.frame)\n" + + "\(app.debugDescription)\nAXTREE-END") + XCTFail("btnPrintPage-0 never became hittable; frame=\(printPage.frame)") + return + } + printPage.click() + let argv = waitForLpLine() + XCTAssertTrue(argv.contains("AP_ColorMatchingMode"), argv) + XCTAssertTrue(argv.contains("page1.tif"), argv) + } + + /// lp failure surfaces in the in-panel notice, not the wizard banner. + func testLpFailureShowsPrintNotice() throws { + app.launchEnvironment["ICCERY_MOCK_LP_EXIT"] = "1" + launchApp() + reachPrintPanel() + _ = waitFor("printerStatusBadge") + + app.buttons["btnPrintAll"].click() + let predicate = NSPredicate(format: "label CONTAINS[c] %@", "Print failed") + let notice = app.staticTexts.containing(predicate).firstMatch + XCTAssertTrue(notice.waitForExistence(timeout: 10)) + XCTAssertTrue(notice.label.contains("Print failed")) + // Spool failure exposes the .error kind on the icon (#80). + XCTAssertEqual(element("printNotificationIcon").value as? String, "error") + } + + /// wizardState.printerName records the queue used for spooling (#95). + func testPrinterNamePersistedOnSpool() throws { + launchApp() + reachPrintPanel() + _ = waitFor("printerStatusBadge") + + app.buttons["btnPrintAll"].click() + _ = waitForLpLine() + let stateURL = testRoot + .appendingPathComponent("AppData") + .appendingPathComponent("wizard_state.json") + let state = waitForFileContent( + stateURL, containing: "Mock_Epson_7450", timeout: 15) + XCTAssertNotNil(state) + XCTAssertTrue((state ?? "").contains("Mock_Epson_7450")) + } + + +} diff --git a/Tests/ICCeryUITests/Milestone4UITests.swift b/Tests/ICCeryUITests/Milestone4UITests.swift new file mode 100644 index 0000000..4471f90 --- /dev/null +++ b/Tests/ICCeryUITests/Milestone4UITests.swift @@ -0,0 +1,212 @@ +import XCTest + +/// Milestone 4 UI tests — issues #18–#22. +/// Uses the same isolated-fixture strategy as M2/M3. +@MainActor +final class Milestone4UITests: XCTestCase { + + private var app: XCUIApplication! + private var testRoot: URL! + private var binDir: URL! + private var workDir: URL! + + override func setUp() async throws { + continueAfterFailure = false + testRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-ui-\(UUID().uuidString)") + binDir = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .appendingPathComponent("Fixtures/bin") + workDir = testRoot.appendingPathComponent("work") + try FileManager.default.createDirectory( + at: workDir, withIntermediateDirectories: true) + + app = XCUIApplication() + app.launchEnvironment = [ + "ICCERY_UI_TESTING": "1", + "ICCERY_TEST_ROOT": testRoot.path, + "ICCERY_ARGYLL_BINARY_DIR": binDir.path, + "ICCERY_TEST_SAVE_TARGET": + workDir.appendingPathComponent("mytarget.ti1").path, + "ICCERY_TEST_WORKDIR": workDir.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() + app.activate() + } + + private func element(_ id: String) -> XCUIElement { + app.descendants(matching: .any)[id] + } + + 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 + } + + /// Reach Stage 3 by generating a target, creating a layout, and + /// advancing from Stage 2. + private func reachStage3() { + launchApp() + app.buttons["btnBrowse"].click() + app.buttons["btnGenerate"].click() + _ = waitFor("btnCreateLayout", timeout: 20) + app.buttons["btnCreateLayout"].click() + _ = waitFor("galleryPage-0", timeout: 20) + _ = waitFor("btnAdvanceToStage3", timeout: 10) + app.buttons["btnAdvanceToStage3"].click() + _ = waitFor("stage3TargetBasename", timeout: 10) + } + + /// Fixture-driven instrument detection populates the picker. + func testInstrumentDetectionPopulatesPicker() throws { + reachStage3() + app.buttons["btnDetectInstruments"].click() + XCTAssertTrue(waitFor("chartreadInstrumentSelect", timeout: 20).exists) + + let picker = app.popUpButtons["chartreadInstrumentSelect"] + XCTAssertTrue(picker.waitForExistence(timeout: 5)) + picker.click() + + // The fixture provides three devices plus the default Auto entry. + XCTAssertTrue(app.menuItems.count >= 3) + } + + /// End-to-end handheld chartread with the mock fixture produces a + /// canonical .ti3 and unlocks Stage 4. + func testHandheldFixtureChartreadAndAverage() throws { + reachStage3() + + app.buttons["btnDetectInstruments"].click() + _ = waitFor("chartreadInstrumentSelect", timeout: 20) + + // Wait until Start is enabled before clicking. Existence-only + // clicks are no-ops on the disabled control (runs 35251, 35293). + driveOnePass(startButton: "btnStartRead") + + // Averaging panel appears with one pass snapshot. + _ = waitFor("chartreadAveragingPanel", timeout: 20) + _ = waitFor("passCounterBadge", timeout: 20) + XCTAssertTrue(app.buttons["btnFinishAndAverage"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.buttons["btnFinishAndAverage"].isEnabled) + + app.buttons["btnFinishAndAverage"].click() + + // Finish promotion should create the canonical .ti3 and + // advance the wizard to Stage 4. + let ti3 = workDir.appendingPathComponent("mytarget.ti3") + let deadline = Date().addingTimeInterval(20) + while Date() < deadline, !FileManager.default.fileExists(atPath: ti3.path) { + RunLoop.current.run(until: Date().addingTimeInterval(0.2)) + } + XCTAssertTrue(FileManager.default.fileExists(atPath: ti3.path)) + } + + /// Two passes + a failing `average` run promote pass 1 to the + /// canonical .ti3 and show the sticky finish error notice via + /// `chartreadFinishNotice` (issue #80). + func testTwoPassAverageFailurePromotesFirstPass() throws { + app.launchEnvironment["MOCK_AVERAGE_FAIL"] = "1" + reachStage3() + + app.buttons["btnDetectInstruments"].click() + _ = waitFor("chartreadInstrumentSelect", timeout: 20) + + driveOnePass(startButton: "btnStartRead") + _ = waitFor("chartreadAveragingPanel", timeout: 20) + + driveOnePass(startButton: "btnMeasureAnotherSheet") + + XCTAssertTrue(waitFor("btnFinishAndAverage", timeout: 20).exists) + app.buttons["btnFinishAndAverage"].click() + + // Averaging failed → pass 1 is promoted to the canonical .ti3 + // and the sticky error notice stays on Stage 3. + let ti3 = workDir.appendingPathComponent("mytarget.ti3") + let deadline = Date().addingTimeInterval(20) + while Date() < deadline, !FileManager.default.fileExists(atPath: ti3.path) { + RunLoop.current.run(until: Date().addingTimeInterval(0.2)) + } + XCTAssertTrue(FileManager.default.fileExists(atPath: ti3.path)) + + let notice = element("chartreadFinishNotice") + XCTAssertTrue(notice.waitForExistence(timeout: 10)) + XCTAssertEqual(notice.value as? String, "error") + } + + /// Runs the mock handheld chartread session to completion + /// (start → calibrate → strip A → strip B → Done & Save). + /// + /// `isEnabled` can be true in AX while the SwiftUI action is still + /// a no-op (runs 35251, 35443). Re-click until the session is + /// actually running (`btnCancel` is shown whenever + /// `isChartreadRunning`), then wait for Calibrate. + private func driveOnePass(startButton: String) { + let start = app.buttons[startButton] + XCTAssertTrue(start.waitForExistence(timeout: 10), startButton) + let enabledBy = Date().addingTimeInterval(10) + while Date() < enabledBy, !start.isEnabled { + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + XCTAssertTrue(start.isEnabled, "\(startButton) never enabled") + + let sessionBy = Date().addingTimeInterval(25) + while Date() < sessionBy, !chartreadSessionRunning { + if start.exists, start.isEnabled { + start.click() + } + RunLoop.current.run(until: Date().addingTimeInterval(0.4)) + } + XCTAssertTrue( + chartreadSessionRunning, + "\(startButton) click never started chartread; " + + "notice=\(element("noticeText").value as? String ?? "") " + + "start.exists=\(start.exists)" + ) + + _ = waitFor("btnCalibrate", timeout: 25) + app.buttons["btnCalibrate"].click() + driveStripsUntilDone() + XCTAssertTrue(element("btnDoneRead").exists) + app.buttons["btnDoneRead"].firstMatch.click() + } + + /// `btnCancel` is in the tree for the whole chartread session; + /// Calibrate/Trigger only appear after the first classified prompt. + private var chartreadSessionRunning: Bool { + element("btnCancel").exists + || element("btnCalibrate").exists + || element("btnTrigger").exists + } + + /// 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)) + } + } +} diff --git a/Tests/ICCeryUITests/Milestone5UITests.swift b/Tests/ICCeryUITests/Milestone5UITests.swift new file mode 100644 index 0000000..91150d7 --- /dev/null +++ b/Tests/ICCeryUITests/Milestone5UITests.swift @@ -0,0 +1,131 @@ +import Foundation +import XCTest + +/// Milestone 5 UI tests — issues #23–#27. +@MainActor +final class Milestone5UITests: XCTestCase { + + private var app: XCUIApplication! + private var testRoot: URL! + private var binDir: URL! + private var workDir: URL! + private var appDataDir: URL! + + override func setUp() async throws { + continueAfterFailure = false + testRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-ui-m5-\(UUID().uuidString)") + binDir = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .appendingPathComponent("Fixtures/bin") + workDir = testRoot.appendingPathComponent("work") + appDataDir = testRoot.appendingPathComponent("AppData") + + try FileManager.default.createDirectory( + at: workDir, withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: appDataDir, withIntermediateDirectories: true) + + // Pre-stage a measured .ti3 so the wizard is already on Stage 4. + FileManager.default.createFile( + atPath: workDir.appendingPathComponent("mytarget.ti3").path, + contents: Data("MOCK_TI3".utf8), + attributes: nil) + + let state: [String: Any] = [ + "currentStage": 4, + "basename": "mytarget", + "cwd": workDir.path, + "printerName": "MockPrinter", + "sessionMode": "profile", + "profileBasename": "mytarget", + "calibrationOriginalBasename": "" + ] + let stateData = try JSONSerialization.data(withJSONObject: state, options: []) + try stateData.write(to: appDataDir.appendingPathComponent("wizard_state.json")) + + app = XCUIApplication() + app.launchEnvironment = [ + "ICCERY_UI_TESTING": "1", + "ICCERY_TEST_ROOT": testRoot.path, + "ICCERY_ARGYLL_BINARY_DIR": binDir.path, + "ICCERY_TEST_WORKDIR": workDir.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 { + app.descendants(matching: .any)[id] + } + + private func waitFor(_ id: String, timeout: TimeInterval = 20) -> 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 + } + + /// Mock colprof produces a profile, unlocks Stage 5, and the + /// mock profcheck reports Good. + func testBuildProfileAndVerify() throws { + launchApp() + + let create = waitFor("btnCreateProfile") + XCTAssertTrue(create.isEnabled) + create.click() + + _ = waitFor("btnVerifyProfile", timeout: 30) + + // The mock iccgamut should have written a .gam next to the profile. + let gam = workDir.appendingPathComponent("mytarget.gam") + let icc = workDir.appendingPathComponent("mytarget.icc") + XCTAssertTrue(FileManager.default.fileExists(atPath: icc.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: gam.path)) + + app.buttons["btnVerifyProfile"].click() + _ = waitFor("profcheckStatus", timeout: 30) + + let statusValue = app.staticTexts["profcheckStatus"].firstMatch.value as? String ?? "" + XCTAssertTrue( + statusValue.contains("Good") || statusValue.contains("Excellent"), + "Expected verification status, got '\(statusValue)'" + ) + } + + /// A failing colprof run surfaces through the session-wide wizard + /// notice only — no duplicate stage-local error view (issue #80). + func testProfileFailureShowsWizardNotice() throws { + app.launchEnvironment["ICCERY_MOCK_COLPROF_EXIT"] = "2" + launchApp() + + let create = waitFor("btnCreateProfile") + XCTAssertTrue(create.isEnabled) + create.click() + + let notice = element("noticeText") + XCTAssertTrue(notice.waitForExistence(timeout: 20)) + XCTAssertTrue((notice.value as? String ?? "") + .contains("Profile creation failed")) + XCTAssertFalse(element("colprofLastError").exists) + } +} diff --git a/Tests/ICCeryUITests/Milestone6CGATSUITests.swift b/Tests/ICCeryUITests/Milestone6CGATSUITests.swift new file mode 100644 index 0000000..1abe962 --- /dev/null +++ b/Tests/ICCeryUITests/Milestone6CGATSUITests.swift @@ -0,0 +1,66 @@ +import Foundation +import XCTest + +/// Milestone 6 CGATS import UI tests (issue #30). +@MainActor +final class Milestone6CGATSUITests: XCTestCase { + + private var app: XCUIApplication! + private var testRoot: URL! + private var datasetURL: URL! + + override func setUp() async throws { + continueAfterFailure = false + + testRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-cgats-ui-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: testRoot, withIntermediateDirectories: true) + + let csv = """ + SAMPLE_ID,SAMPLE_LOC,RGB_R,RGB_G,RGB_B,XYZ_X,XYZ_Y,XYZ_Z,LAB_L,LAB_A,LAB_B + 1,A1,50,0,0,20,10,5,50,60,30 + 2,A2,0,50,0,10,30,5,60,-50,40 + """ + datasetURL = testRoot.appendingPathComponent("imported.csv") + try csv.write(to: datasetURL, atomically: true, encoding: .utf8) + + app = XCUIApplication() + app.launchEnvironment = [ + "ICCERY_UI_TESTING": "1", + "ICCERY_TEST_WORKDIR": testRoot.path, + "ICCERY_TEST_DATASET_IMPORT": datasetURL.path + ] + } + + override func tearDown() async throws { + app?.terminate() + app = nil + if let testRoot { + try? FileManager.default.removeItem(at: testRoot) + } + } + + /// `Milestone6CGATSUITests.importUsesOpenPanelNotSaveTi1` + /// Must fail if import presents a save panel or a `.ti1` filter. + func testImportUsesOpenPanelNotSaveTi1() throws { + app.launch() + + // CGATS import needs a working directory; the env provides one. + XCTAssertTrue(app.buttons["btnSelectWorkDir"].waitForExistence(timeout: 5)) + app.buttons["btnSelectWorkDir"].tap() + + XCTAssertTrue(app.buttons["btn-import-dataset"].waitForExistence(timeout: 10)) + app.buttons["btn-import-dataset"].click() + + // No save panel should appear; the open panel is stubbed under UI testing. + let savePanel = app.sheets.firstMatch + XCTAssertFalse(savePanel.exists, "Import must use an open panel, never a save panel.") + + // The dataset should be accepted and the user should advance to Stage 4. + XCTAssertTrue(app.staticTexts["stage4TargetBasename"].waitForExistence(timeout: 10)) + + // The canonical .ti3 should be written next to the source file. + let ti3URL = testRoot.appendingPathComponent("imported.ti3") + XCTAssertTrue(FileManager.default.fileExists(atPath: ti3URL.path)) + } +} diff --git a/Tests/ICCeryUITests/Milestone6CalibrationUITests.swift b/Tests/ICCeryUITests/Milestone6CalibrationUITests.swift new file mode 100644 index 0000000..fb4e52a --- /dev/null +++ b/Tests/ICCeryUITests/Milestone6CalibrationUITests.swift @@ -0,0 +1,198 @@ +import Foundation +import XCTest + +/// Milestone 6 — Stage 0 printer calibration UI acceptance. +/// +/// Uses the mock Argyll fixtures and UI-test environment flags so no real +/// instrument, printer, or modal file panel is required. +@MainActor +final class Milestone6CalibrationUITests: XCTestCase { + + private var app: XCUIApplication! + private var testWorkDir: URL! + + override func setUp() async throws { + continueAfterFailure = false + + testWorkDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cal-ui-test-\(UUID().uuidString)") + try FileManager.default.createDirectory( + at: testWorkDir, + withIntermediateDirectories: true + ) + + let binaryDir = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .appendingPathComponent("Fixtures/bin") + + app = XCUIApplication() + app.launchEnvironment = [ + "ICCERY_UI_TESTING": "1", + "ICCERY_ARGYLL_BINARY_DIR": binaryDir.path, + "ICCERY_TEST_WORKDIR": testWorkDir.path + ] + app.launch() + app.activate() + } + + override func tearDown() async throws { + app?.terminate() + app = nil + if let testWorkDir { + try? FileManager.default.removeItem(at: testWorkDir) + } + } + + func testCalibrationDashboardOpensAndCanGenerate() throws { + // Set up a target and working directory on Stage 1. + let basename = app.textFields["targetBasename"] + XCTAssertTrue(basename.waitForExistence(timeout: 5)) + basename.tap() + basename.typeText("DemoTarget") + + let workDir = app.buttons["btnSelectWorkDir"] + XCTAssertTrue(workDir.waitForExistence(timeout: 5)) + workDir.tap() + + let generate = app.buttons["btnGenerate"] + XCTAssertTrue(generate.waitForExistence(timeout: 5)) + generate.tap() + + // Open the calibration dashboard once Stage 2 is reached. + let advance = app.buttons["btnAdvanceToStage3"] + XCTAssertTrue(advance.waitForExistence(timeout: 10)) + + let calButton = app.buttons["btnCalibratePrinter"] + XCTAssertTrue(calButton.waitForExistence(timeout: 5)) + calButton.tap() + + XCTAssertTrue(app.staticTexts["Calibrate Printer"].waitForExistence(timeout: 5)) + + // Start the calibration wedge. The mock targen will create CAL_DemoTarget.ti1. + let calGenerate = app.buttons["btnCalGenerate"] + XCTAssertTrue(calGenerate.waitForExistence(timeout: 5)) + calGenerate.tap() + + // After generation the wizard should advance to Stage 2 (layout) because + // a CAL_ .ti1 now exists and the session is in calibration mode. + let layout = app.buttons["btnCreateLayout"] + if !layout.waitForExistence(timeout: 25) { + // The generate tap can be dropped while the dashboard is still + // settling after the stage transition; retry once before failing. + if calGenerate.waitForExistence(timeout: 2) { + calGenerate.tap() + } + XCTAssertTrue(layout.waitForExistence(timeout: 25)) + } + } + + /// Stage 0 must not push the sidebar off-screen: the macOS `Form` + /// rows with expanding spacers once gave the stage an unbounded ideal + /// width, and window centering shifted the 270 pt sidebar into + /// negative X (issue #163). AX-tree existence checks cannot see that, + /// so assert real frame geometry. + func testCalibrationViewDoesNotOverflowWindow() throws { + let calButton = app.buttons["btnCalibratePrinter"] + XCTAssertTrue(calButton.waitForExistence(timeout: 10)) + calButton.tap() + + XCTAssertTrue(app.staticTexts["Calibrate Printer"].waitForExistence(timeout: 5)) + + let window = app.windows.firstMatch + XCTAssertTrue(window.exists) + XCTAssertGreaterThanOrEqual(calButton.frame.minX, 0) + XCTAssertLessThanOrEqual(calButton.frame.maxX, window.frame.maxX) + let ret = app.buttons["btnCalReturn"] + XCTAssertTrue(ret.waitForExistence(timeout: 5)) + XCTAssertTrue(ret.isHittable) + } + + /// "Return to Profiling" is the single Stage 0 exit and carries the + /// cancel-action shortcut, so Escape must dismiss the dashboard too + /// (issue #163). `typeKey` delivery is unreliable on the macOS 12 CI + /// runner (m10 phase-08), so the Escape check falls back to the + /// deterministic button tap. + func testCalibrationReturnButtonAndEscapeDismiss() throws { + let calButton = app.buttons["btnCalibratePrinter"] + XCTAssertTrue(calButton.waitForExistence(timeout: 10)) + calButton.tap() + XCTAssertTrue(app.staticTexts["Calibrate Printer"].waitForExistence(timeout: 5)) + + let returnButton = app.buttons["btnCalReturn"] + XCTAssertTrue(returnButton.waitForExistence(timeout: 5)) + XCTAssertTrue(returnButton.isHittable) + returnButton.tap() + + let stage1 = app.descendants(matching: .any)["stage-1"] + XCTAssertTrue(stage1.waitForExistence(timeout: 5)) + + // Re-enter and try Escape; fall back to the button where the + // runtime does not deliver typeKey. + XCTAssertTrue(calButton.waitForExistence(timeout: 5)) + calButton.tap() + XCTAssertTrue(app.staticTexts["Calibrate Printer"].waitForExistence(timeout: 5)) + + app.typeKey(XCUIKeyboardKey.escape, modifierFlags: []) + if !stage1.waitForExistence(timeout: 4) { + XCTAssertTrue(returnButton.waitForExistence(timeout: 5)) + returnButton.tap() + XCTAssertTrue(stage1.waitForExistence(timeout: 5)) + } + } + + /// A failing calibration targen surfaces the error through the + /// wizard notice and restores the original basename (issue #80). + func testCalibrationTargenFailureRestoresBasename() throws { + let testRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("cal-fail-\(UUID().uuidString)") + let appData = testRoot.appendingPathComponent("AppData") + try FileManager.default.createDirectory( + at: appData, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: testRoot) } + + // Pre-stage wizard state so the failing mock targen is only + // exercised by the calibration run, not target generation. + let state: [String: Any] = [ + "currentStage": 1, + "basename": "DemoTarget", + "cwd": testWorkDir.path, + "sessionMode": "profile", + "calibrationOriginalBasename": "" + ] + let stateURL = appData.appendingPathComponent("wizard_state.json") + try JSONSerialization.data(withJSONObject: state).write(to: stateURL) + + app.terminate() + app.launchEnvironment["ICCERY_TEST_ROOT"] = testRoot.path + app.launchEnvironment["ICCERY_MOCK_TARGEN_EXIT"] = "2" + app.launch() + app.activate() + + let calButton = app.buttons["btnCalibratePrinter"] + XCTAssertTrue(calButton.waitForExistence(timeout: 10)) + calButton.tap() + + let calGenerate = app.buttons["btnCalGenerate"] + XCTAssertTrue(calGenerate.waitForExistence(timeout: 10)) + calGenerate.tap() + + let notice = app.descendants(matching: .any)["noticeText"] + XCTAssertTrue(notice.waitForExistence(timeout: 20)) + XCTAssertTrue((notice.value as? String ?? "") + .contains("Calibration target failed")) + + // The pre-CAL_ basename is restored and persisted. + let deadline = Date().addingTimeInterval(10) + var restoredBasename: String? + while Date() < deadline { + if let data = try? Data(contentsOf: stateURL), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let basename = object["basename"] as? String { + restoredBasename = basename + if basename == "DemoTarget" { break } + } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + XCTAssertEqual(restoredBasename, "DemoTarget") + } +} diff --git a/Tests/ICCeryUITests/Milestone6GamutUITests.swift b/Tests/ICCeryUITests/Milestone6GamutUITests.swift new file mode 100644 index 0000000..b404bb9 --- /dev/null +++ b/Tests/ICCeryUITests/Milestone6GamutUITests.swift @@ -0,0 +1,178 @@ +import Foundation +import Metal +import XCTest + +/// Milestone 6 — Issue #28 native SceneKit gamut viewer acceptance tests. +@MainActor +final class Milestone6GamutUITests: XCTestCase { + + /// Metal on the test host — the app under test runs on the same + /// machine, so this predicts whether the sheet mounts SceneKit. + private var hasGPU: Bool { MTLCreateSystemDefaultDevice() != nil } + + private var app: XCUIApplication! + private var testRoot: URL! + private var binDir: URL! + private var workDir: URL! + private var appDataDir: URL! + private var referenceGamutURL: URL! + + override func setUp() async throws { + continueAfterFailure = false + testRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-ui-m6-gamut-\(UUID().uuidString)") + binDir = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .appendingPathComponent("Fixtures/bin") + workDir = testRoot.appendingPathComponent("work") + appDataDir = testRoot.appendingPathComponent("AppData") + + // The bundled sRGB reference used by the app; copied into the test workdir + // by the mock iccgamut so the profile gamut is a real, parseable mesh. + referenceGamutURL = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Resources/Argyll/reference_gamuts/sRGB.gam") + + try FileManager.default.createDirectory( + at: workDir, withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: appDataDir, withIntermediateDirectories: true) + + // Pre-stage a measured .ti3 and start the wizard on Stage 4. + FileManager.default.createFile( + atPath: workDir.appendingPathComponent("mytarget.ti3").path, + contents: Data("MOCK_TI3".utf8), + attributes: nil) + + let state: [String: Any] = [ + "currentStage": 4, + "basename": "mytarget", + "cwd": workDir.path, + "printerName": "MockPrinter", + "sessionMode": "profile", + "profileBasename": "mytarget", + "calibrationOriginalBasename": "" + ] + let stateData = try JSONSerialization.data(withJSONObject: state, options: []) + try stateData.write(to: appDataDir.appendingPathComponent("wizard_state.json")) + + app = XCUIApplication() + app.launchEnvironment = [ + "ICCERY_UI_TESTING": "1", + "ICCERY_TEST_ROOT": testRoot.path, + "ICCERY_ARGYLL_BINARY_DIR": binDir.path, + "ICCERY_TEST_WORKDIR": workDir.path, + "ICCERY_MOCK_GAMUT_SOURCE": referenceGamutURL.path, + ] + } + + override func tearDown() async throws { + // Never leave the gamut sheet up for `terminate()` (#147). + if app != nil, element("btnCloseGamut").exists { + element("btnCloseGamut").click() + } + app?.terminate() + app = nil + if let testRoot { + try? FileManager.default.removeItem(at: testRoot) + } + testRoot = nil + } + + 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 = 15) -> 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 + } + + /// Inverse of `waitFor` — polls until the element leaves the tree. + private func waitForGone(_ id: String, timeout: TimeInterval = 10) { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if !element(id).exists { return } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + XCTAssertFalse(element(id).exists, "Expected element \(id) to disappear") + } + + /// `btnCloseGamut` dismisses the sheet so `tearDown`'s `terminate()` + /// is not stuck behind a key sheet (#147). No-op when already closed. + private func closeGamutSheet() { + let close = element("btnCloseGamut") + guard close.waitForExistence(timeout: 5) else { return } + close.click() + waitForGone("gamutView") + } + + /// Build and verify the mock profile, then open the native gamut viewer. + /// The viewer should load both the reference sRGB mesh and the profile + /// gamut copied from that reference. + private func openGamutSheet() { + app.launch() + if !app.wait(for: .runningForeground, timeout: 10) { + app.activate() + } + + waitFor("btnCreateProfile").click() + + waitFor("btnVerifyProfile").click() + + waitFor("btnViewGamut").click() + + _ = waitFor("gamutView") + } + + func testViewGamutOpensSceneKitSheet() throws { + openGamutSheet() + + let gamutView = waitFor("gamutView") + XCTAssertTrue(gamutView.exists) + + let status = waitFor("gamutStatusText") + let value = status.value as? String ?? "" + XCTAssertTrue(value.contains("faces"), "Gamut status should report mesh faces, got: \(value)") + + // The fallback banner appears exactly when the host lacks Metal + // — no SCNView is constructed without a GPU (#147). + if hasGPU { + XCTAssertFalse( + element("gamutViewerUnavailable").exists, + "GPU host must mount the SceneKit view, not the fallback") + } else { + _ = waitFor("gamutViewerUnavailable") + } + + // The reset button demonstrates that the viewer is interactive. + let reset = waitFor("btnResetGamutCamera") + XCTAssertTrue(reset.isEnabled) + + closeGamutSheet() + } + + /// Clicking Reset drives the live `SCNView` — runs only on Metal + /// hosts, skipped on GPU-less runners so the same suite exercises + /// 3D once CI has a GPU (#147). + func testResetCameraInteractsWithScene() throws { + guard hasGPU else { throw XCTSkip("No Metal") } + openGamutSheet() + + let reset = waitFor("btnResetGamutCamera") + reset.click() + + closeGamutSheet() + } +} diff --git a/Tests/ICCeryUITests/SettingsUITests.swift b/Tests/ICCeryUITests/SettingsUITests.swift new file mode 100644 index 0000000..3aa918f --- /dev/null +++ b/Tests/ICCeryUITests/SettingsUITests.swift @@ -0,0 +1,192 @@ +import Foundation +import XCTest + +/// Settings sheet UI tests (issue #165). +/// +/// The Verification thresholds once shared one non-wrapping `HStack` and +/// drew past the sheet's right clip on the macOS grouped `Form`. AX +/// existence cannot see clipping (#163), so containment is asserted on +/// real frame geometry against the sheet's bounds. +/// +/// All three numeric fields also passed their default value as the +/// `TextField` label; inside an `HStack` row that label renders inline — +/// it is not a placeholder — producing "Stale after 30 [30] days". The +/// fields are now direct `Form` children, so the descriptive label +/// renders once in the label column and the box fills the control +/// column; `testNumericFieldsCarryLabelsNotDuplicatedValues` pins it. +@MainActor +final class SettingsUITests: XCTestCase { + + private var app: XCUIApplication! + + override func setUp() async throws { + continueAfterFailure = false + app = XCUIApplication() + app.launchEnvironment = ["ICCERY_UI_TESTING": "1"] + app.launch() + app.activate() + } + + override func tearDown() async throws { + app?.terminate() + app = nil + } + + /// Sheet content lives under `app.sheets`, outside the main window's + /// a11y tree (Milestone2 pattern). + private var sheet: XCUIElement { + app.sheets.firstMatch + } + + private func openSettings() { + let gear = app.buttons["openSettingsBtn"] + XCTAssertTrue(gear.waitForExistence(timeout: 10)) + gear.click() + XCTAssertTrue(sheet.waitForExistence(timeout: 10)) + } + + private func thresholdField(_ fieldID: String) -> XCUIElement { + let field = sheet.textFields[fieldID] + XCTAssertTrue(field.waitForExistence(timeout: 10), "missing \(fieldID)") + return field + } + + private func replaceFieldValue(_ field: XCUIElement, with text: String) { + field.click() + app.typeKey("a", modifierFlags: .command) + field.typeText(text) + } + + private func waitForSheetDismiss(timeout: TimeInterval = 10) { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if !sheet.exists { return } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + XCTAssertFalse(sheet.exists, "Expected sheet to disappear") + } + + /// Both ΔE rows must render fully inside the 560×620 sheet: the + /// label-column `StaticText`s and the control-column fields all sit + /// within the sheet bounds, the two fields share the Form's control + /// column margin, and the Warning row sits below the Good row so the + /// two cannot overlap on one clipped line. + /// + /// The numeric fields are direct `Form` children, so macOS lifts + /// each `TextField` label into the right-aligned label column and + /// the editable box fills the control column — the same layout the + /// Pickers use. The control column ends only ~3.5 pt inside the + /// sheet (PopUpButtons reach it too), so the right-edge assertion is + /// "inside the sheet", not the older 12 pt compact-field inset. + func testVerificationRowsStayInsideSheet() throws { + openSettings() + + let goodField = thresholdField("settingsDeltaEGood") + let warningField = thresholdField("settingsDeltaEWarning") + + // Labels render as sibling staticTexts in the label column. + let goodLabel = sheet.staticTexts["Good ΔE ≤"] + let warningLabel = sheet.staticTexts["Warning ΔE ≤"] + XCTAssertTrue(goodLabel.waitForExistence(timeout: 5)) + XCTAssertTrue(warningLabel.waitForExistence(timeout: 5)) + + // Left boundary: labels must be inside the sheet with >=12 pt inset + XCTAssertTrue(goodLabel.frame.minX >= sheet.frame.minX + 12.0) + XCTAssertTrue(warningLabel.frame.minX >= sheet.frame.minX + 12.0) + + // Right boundary: fields must not draw past the sheet's clip + XCTAssertTrue( + goodField.frame.maxX <= sheet.frame.maxX, + "Good ΔE field clips the sheet's right edge") + XCTAssertTrue( + warningField.frame.maxX <= sheet.frame.maxX, + "Warning ΔE field clips the sheet's right edge") + + // Vertical separation + XCTAssertTrue( + warningField.frame.minY > goodField.frame.minY, + "thresholds must be two separate rows") + + // Both threshold fields align at the same control column margin + XCTAssertTrue( + abs(goodField.frame.minX - warningField.frame.minX) <= 1.0, + "Good and Warning ΔE fields should align at the same column margin") + + // Other labels must not overflow the left boundary + let defaultInstLabel = sheet.staticTexts["Default instrument"] + XCTAssertTrue(defaultInstLabel.waitForExistence(timeout: 5)) + XCTAssertTrue( + defaultInstLabel.frame.minX >= sheet.frame.minX + 12.0, + "Default instrument label must not overflow left edge") + + let bundledSidecarsLabel = sheet.staticTexts["Bundled sidecars"] + XCTAssertTrue(bundledSidecarsLabel.waitForExistence(timeout: 5)) + XCTAssertTrue( + bundledSidecarsLabel.frame.minX >= sheet.frame.minX + 12.0, + "Bundled sidecars label must not overflow left edge") + + sheet.buttons["Cancel"].click() + waitForSheetDismiss(timeout: 5) + } + + /// `warning <= good` fails `AppSettings.validate()` and keeps the + /// sheet open with the contract error text; restoring valid values + /// lets Save dismiss (issue #165 acceptance, strings are the #5 + /// contract). + func testDeltaEValidationBlocksSaveThenValidSaveDismisses() throws { + openSettings() + + replaceFieldValue(thresholdField("settingsDeltaEWarning"), with: "1") + sheet.buttons["Save"].click() + + let error = sheet.staticTexts[ + "Good ΔE threshold must be strictly less than the warning threshold." + ] + XCTAssertTrue(error.waitForExistence(timeout: 10)) + XCTAssertTrue(sheet.exists, "invalid ΔE must not dismiss the sheet") + + replaceFieldValue(thresholdField("settingsDeltaEWarning"), with: "5") + sheet.buttons["Save"].click() + waitForSheetDismiss(timeout: 10) + } + + /// macOS renders a `TextField`'s first argument as a label, not a + /// placeholder — inside the old `HStack` rows it drew inline, so the + /// sheet read "Stale after 30 [30] days" / "Good ΔE ≤ 2.0 [2.0]". + /// As direct `Form` children each label now renders exactly once, in + /// the label column; no `staticText` may echo the field's value. + func testNumericFieldsCarryLabelsNotDuplicatedValues() throws { + openSettings() + + // (identifier, label-column text, rendered default value, the + // literal that used to double-render as the field's label) + // `value:` shows the formatted number — 2.0 renders as "2". + let rows: [(id: String, label: String, value: String, dup: String)] = [ + ("settingsDeltaEGood", "Good ΔE ≤", "2", "2.0"), + ("settingsDeltaEWarning", "Warning ΔE ≤", "5", "5.0"), + ("settingsCalStaleDays", "Stale after (days)", "30", "30"), + ] + + for spec in rows { + let field = sheet.textFields[spec.id] + XCTAssertTrue(field.waitForExistence(timeout: 10), "missing \(spec.id)") + XCTAssertEqual( + field.value as? String, spec.value, + "\(spec.id) default value changed unexpectedly") + XCTAssertTrue( + sheet.staticTexts[spec.label].waitForExistence(timeout: 5), + "\(spec.id) must render \"\(spec.label)\" once in the label column") + for ghost in Set([spec.value, spec.dup]) { + XCTAssertFalse( + sheet.staticTexts[ghost].exists, + "\(spec.id) must not render \"\(ghost)\" as a second label") + } + XCTAssertTrue( + field.frame.maxX <= sheet.frame.maxX, + "\(spec.id) field clips the sheet's right edge") + } + + sheet.buttons["Cancel"].click() + waitForSheetDismiss(timeout: 5) + } +} diff --git a/brand/ICCery-logo.svg b/brand/ICCery-logo.svg new file mode 100644 index 0000000..2b7b7e2 --- /dev/null +++ b/brand/ICCery-logo.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ICCery + diff --git a/brand/app-icon.svg b/brand/app-icon.svg new file mode 100644 index 0000000..917269a --- /dev/null +++ b/brand/app-icon.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/brand/dmg-background.svg b/brand/dmg-background.svg new file mode 100644 index 0000000..ba4887f --- /dev/null +++ b/brand/dmg-background.svg @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ICCery + + + + + + + diff --git a/docs/01-overview.md b/docs/01-overview.md new file mode 100644 index 0000000..7191fd9 --- /dev/null +++ b/docs/01-overview.md @@ -0,0 +1,71 @@ +# 01 — Product overview + +ICCery is a native desktop GUI that walks a user through creating a printer ICC/ICM profile with ArgyllCMS. It is **not** a colour engine. All measurement, chart generation, and profile mathematics live in AGPLv3 Argyll binaries spawned as children. ICCery owns UI, artefact gating, unmanaged printing, and visualisation. + +Version analysed: **0.8.5** (`com.gronod.iccery`). + +## Platforms + +| OS | Packaged as | Floor | Notes | +|----|-------------|-------|-------| +| Windows x86_64 | NSIS `.exe` + WiX `.msi` | WebView2 | `.icm` profiles; GDI ICM-off printing; optional Argyll USB driver install | +| macOS Intel + Apple Silicon | `.dmg` / `.app` | **12.0 Monterey** (`LSMinimumSystemVersion`) | Universal binary preferred; WKWebView; NSPrintPanel ColorSync suppression | +| Linux x86_64 | `.AppImage` / `.deb` | Ubuntu 22.04 glibc | CUPS `lp -o raw`; `libcups` | + +macOS 11 and 10.15 are **not** supported after #225. Monterey Intel is best-effort: Stages 1–4 work if WebGL dies. + +## User-facing workflow + +``` +[optional Stage 0] printcal / applycal linearization + │ + ▼ +Stage 1 targen → basename.ti1 +Stage 2 printtarg + OS print → basename.ti2 + page TIFFs +Stage 3 chartread (+ average) → basename.ti3 +Stage 4 colprof (+ applycal) → basename.icc|.icm +Stage 5 profcheck + iccgamut + install → verification, 3D gamut, OS profile store +``` + +Navigation is a left stepper. Forward motion is **artefact-gated on disk**, not on in-memory flags (#60, #151). Backward motion is always allowed. + +## Colour spaces + +- **RGB (Printer Driver)** — `targen -d 2`. Host/driver colour management is expected to be **turned off** at print time; ICCery prints unmanaged. +- **CMYK (RIP)** — `targen -d 4`. Typical for RIP-driven presses. Calibration (Stage 0) is recommended; Stage 1 shows a reminder when no `.cal` is applied. + +## Instruments (Stage 2 layout + Stage 3 read) + +`printtarg -i` codes used in the UI: + +| UI | `-i` | Hardware | +|----|------|----------| +| i1 Pro / i1 Pro 2 | `i1` | Handheld strip (optional `-Y l` LEDs on i1Pro 2 Rev E) | +| ColorMunki | `CM` | Handheld | +| SpyderPrint | `p3` | Handheld (PrintFix Pro) | +| SpectroScan | `SS` | XY table | +| DTP20 / 22 / 41 / 51 | `20` `22` `41` `51` | Legacy X-Rite | + +XY tables (SpectroScan, i1iO) are auto-detected from `instlist` names matching `/spectro\s?scan|i1io/i` and from `chartread` prompts (#93). + +## AGPL boundary (non-negotiable) + +ArgyllCMS is AGPLv3. ICCery is proprietary. The original design **never** `dlopen`s or statically links Argyll. Communication is: + +- spawn with piped stdin/stdout/stderr +- `ARGYLL_NOT_INTERACTIVE=1` in the child environment +- structured JSON on stdout for tools compiled with the Gronod fork `-u` switch +- keystrokes on stdin for interactive `chartread` + +A rewrite that in-process-links Argyll **contaminates the GUI with AGPL**. Keep the process boundary. + +## Identity & assets + +- Wordmark: waffle cone + CMY scoops (C `#00BCEB`, M `#EC008C`, Y `#FFED00`) + K cherry, text `ICC` white + `ery` cyan→blue. File: `src/assets/ICCery-logo.svg`. +- App icon: `src/assets/app-icon.svg` and raster set under `src-tauri/icons/`. +- Accent in UI CSS is VS Code blue `#007acc` on dark `#1e1e1e` / `#252526`. +- Window: 1280×800, min 1100×700, starts **hidden** until first paint (#225). + +## What the rewrite must preserve + +Everything in this spec is behavioural, not Tauri-specific: CLI flags, JSON prefixes, stdin bytes, PPD keys, ColorSync SPI, artefact names, ΔE bands, collision dialogs, and the bugs listed in [24-issues-invariants.md](24-issues-invariants.md). diff --git a/docs/02-architecture.md b/docs/02-architecture.md new file mode 100644 index 0000000..288b1f1 --- /dev/null +++ b/docs/02-architecture.md @@ -0,0 +1,149 @@ +# 02 — Architecture + +## Host / child split + +``` +┌─ ICCery host (any stack) ─────────────────────────────────────┐ +│ Wizard UI ─► WizardState (basename, cwd, printerName) │ +│ │ │ +│ ├─ ProcessManager (spawn / stdin / kill / kill_all) │ +│ ├─ Print subsystem (Win GDI / macOS NSPrint+lp / Linux) │ +│ ├─ Settings + presets (settings.json) │ +│ ├─ Quality store (verification_history.json) │ +│ ├─ Calibration library (.cal files) │ +│ └─ Profile installer (OS colour stores) │ +└──────────────┬────────────────────────────────────────────────┘ + │ stdin / stdout / stderr pipes + │ env ARGYLL_NOT_INTERACTIVE=1 + ▼ +┌─ ArgyllCMS sidecars (AGPLv3) ─────────────────────────────────┐ +│ instlist targen printtarg chartread average │ +│ printcal applycal colprof profcheck iccgamut │ +└───────────────────────────────────────────────────────────────┘ +``` + +Original implementation: Tauri v2 webview (`withGlobalTauri: true`) so the vanilla JS frontend calls `window.__TAURI__.core.invoke` and `window.__TAURI__.event.listen`. A rewrite may replace this with any IPC (HTTP local server, native bindings, gRPC, etc.) but **must keep the same command semantics**. + +## Frontend modules (legacy) + +| Module | Role | +|--------|------| +| `app.js` | Boot, safeInit per stage, `show_main_window` double-rAF + 1500 ms fallback | +| `state.js` | `wizardState`, artefact gating, stage DOM, gamut pause/ensure | +| `targen.js` | Stage 1 | +| `printtarg.js` | Stage 2 + native print UI | +| `chartread.js` | Stage 3 state machine | +| `swatch_grid.js` | Live ΔE₀₀ patches from `process:json_row` | +| `colprof.js` | Stage 4 | +| `profcheck.js` | Stage 5 metrics, drift SVG, CSV | +| `gamut_viewer.js` | Three.js CIELAB viewer (lazy) | +| `calibration.js` | Stage 0 | +| `profile_install.js` | Stage 5 install | +| `settings.js` / `presets.js` | Persistence | +| `cgats_interop.js` | Import/export datasets | +| `delta_e.js` | CIEDE2000 | +| `color_convert.js` | Lab/device → CSS | +| `logger.js` | Forwards console to `log_frontend_message` | + +**Do not** create WebGL during boot. `ensureGamutViewer()` runs only when Stage 5 becomes visible (#225). + +## Backend modules (legacy Rust) + +| Module | Role | +|--------|------| +| `lib.rs` | Tauri builder, plugins, `generate_handler!`, RunEvent kill-all | +| `commands.rs` | Binary resolve, arg builders, dialogs, stage artefact verify | +| `process_manager.rs` | tokio spawn, JSON-row split, CREATE_NO_WINDOW | +| `events.rs` | `process:stdout|stderr|exit|error|json_row` | +| `print/*` | OS printing | +| `calibration.rs` | printcal/applycal + `.cal` parser | +| `profile_install.rs` | OS colour-store copy | +| `quality_store.rs` | verification history, atomic write | +| `settings.rs` | settings + presets | +| `cgats.rs` | CGATS/ti3 parser + canonical serializer | +| `macos_webview.rs` | Dark WKWebView backing | +| `window_lifecycle.rs` | Close vs Web Content death | + +## Sidecar layout + +`scripts/fetch-argyll.mjs` downloads Gronod/argyllcms GitHub (or Gitea) releases into: + +``` +src-tauri/argyll/ + linux-x86_64/instlist + windows-x86_64/instlist.exe + macos-x86_64/instlist + macos-aarch64/instlist + macos-universal/instlist + mocks/ # chartread.mock, colprof.mock, profcheck.mock + reference_gamuts/sRGB.gam +``` + +`resolve_binary(name)`: + +1. If `settings.argyll_binary_dir` is set and the file exists, use it. +2. Else resource `argyll//[.exe]`. +3. On macOS, prefer `macos-universal` if that folder contains `instlist`. +4. Windows always tries `name.exe` first (#85). + +Env override: `ARGYLL_RELEASE_TAG=vX.Y.Z npm run fetch-argyll`. + +## Working directory + +Every Argyll run is given an explicit cwd. Empty cwd falls back to Documents → Home → app data (`resolve_safe_cwd`, #59). Basename must not contain `/`, `\`, or `..`. + +Default artefacts live next to each other: + +``` +/.ti1 +/.ti2 +/.tif (and .1.tif, .2.tif … for multi-page) +/.ti3 +/_passN.ti3 # averaging snapshots (#109) +/.icc | .icm +/.gam +/CAL_.ti1|.ti2|.ti3|.cal # calibration, never collides +``` + +## Persistence locations + +| File | Where | Notes | +|------|-------|-------| +| `settings.json` | app data dir | thresholds, argyll dir, presets, LED flag | +| `verification_history.json` | app data dir | max 1000 records, atomic `.tmp` + rename (#213) | +| `iccery.log` | app log dir | 5 MiB rotate, keep 5 historical segments | +| Calibration library | app data / user-chosen | `.cal` files | + +macOS log path: `~/Library/Logs/com.gronod.iccery/iccery.log`. + +## Event bus (must be replicated) + +| Event | Payload | When | +|-------|---------|------| +| `process:stdout` | `{ id, line }` | Non-JSON stdout line | +| `process:stderr` | `{ id, line }` | stderr line | +| `process:exit` | `{ id, code }` | child exited (0 = success; killed may be 1) | +| `process:error` | `{ id, error }` | spawn failure | +| `process:json_row` | `{ id, json }` | stdout line starting `ROW_COLORS_JSON: ` — prefix **stripped** | + +Process ids are deterministic strings, e.g. `targen_${basename}`, `chartread_${basename}`, `instlist`, `iccgamut_${stem}`. Duplicate spawn of a still-running id is rejected (#116). + +Frontend listeners **must** filter on `payload.id`. A historical bug (#56) was process-id mismatch so UI never saw exit. + +## Logging + +- Host: `tauri-plugin-log` to log dir + stdout + webview. wry / tauri_runtime_wry at Info so Monterey "web content process terminated" is captured (#225). +- Subprocess stdout → `log::info!(target: "subprocess")`; stderr → warn. +- Paths in spawn logs are home-sanitized to `~` (`sanitize_arg_for_logging`). +- JS `logger.js` invokes `log_frontend_message`. +- Settings `log_level` is applied at startup **and** when saved (#158). + +## Window / WebView contract (macOS especially) + +See #225 and `macos_webview.rs`: + +- Window `visible: false`, `backgroundColor: #1A1A22`. +- After CSS first paint: invoke `show_main_window` (double `requestAnimationFrame` + 1500 ms fallback). +- `paint_dark_webview`: `setBackgroundColor`, KVC `drawsBackground = NO`, `setUnderPageBackgroundColor:` on macOS 12+. +- Do **not** set `transparent: true` (hit-testing / titlebar). +- On `Exit` / `CloseRequested`: `kill_all` Argyll children (#147, #149) **before** teardown so `chartread` can park an XY head if the UI already sent `q\n`. diff --git a/docs/03-ipc-and-process-manager.md b/docs/03-ipc-and-process-manager.md new file mode 100644 index 0000000..cf59e3f --- /dev/null +++ b/docs/03-ipc-and-process-manager.md @@ -0,0 +1,64 @@ +# 03 — IPC and process manager + +The host never waits on a child from the request that spawned it (except `printcal`/`applycal`, which use captured `.output()`). Streaming tools go through a process manager that: + +1. Rejects a duplicate `id` while that child is still mapped (#116). +2. Pipes stdin/stdout/stderr. +3. Sets `ARGYLL_NOT_INTERACTIVE=1`. +4. On Windows sets `CREATE_NO_WINDOW` (`0x08000000`) (#46). +5. Splits stdout: lines beginning `ROW_COLORS_JSON: ` become `process:json_row` (prefix stripped); everything else is `process:stdout`. +6. Reaps the child on natural exit **or** kill signal; then emits `process:exit`. +7. Drops stdin from the map on kill so writers fail fast. + +## Commands the rewrite must expose + +| Command | Args | Returns | +|---------|------|---------| +| `spawn_process` | `{ id, binary, args }` | `()` — unused by current JS (registered only) | +| `send_stdin` | `{ id, input }` | `()` — `input` is the **exact bytes**, already including `\n` | +| `kill_process` | `{ id }` | `()` | +| `kill_all_processes` | — | `usize` count signaled | +| `resolve_binary` | `{ binaryName }` | absolute path string | +| `run_targen` / `run_printtarg` / `run_chartread` / `run_average` / `run_colprof` / `run_profcheck` / `extract_gamut` / `detect_instruments` | typed configs | `()` after spawn (not after exit) | +| `generate_calibration_target` / `compute_calibration_curves` / `apply_calibration` | typed | captured result | + +Frontend waits for `process:exit` with matching `id`. **Never** assume invoke() resolves when the tool finishes. + +## Deadlock history (must not regress) + +### ICCery #84 (P0) + +Early ProcessManager held a `Mutex` across `Child::wait()`. `send_stdin` needed the same mutex → Calibrate/Retry hung. Dropping the mutex also dropped `ChildStdin` at spawn, closing the pipe immediately. + +**Invariant:** stdin handle lives in its own map, independent of wait. Wait runs in a background task with a oneshot kill channel. + +### ArgyllCMS fork #24 + ICCery #134 + +On Windows, `SetNamedPipeHandleState(PIPE_NOWAIT)` **fails on anonymous pipes** created by `Stdio::piped()`. Argyll's `con_char(wait=0)` then `ReadFile`s a blocking pipe during `uicallback`, so the instrument trigger thread never sees the button and the lamp never lights. + +Fork fix: `PeekNamedPipe` before `ReadFile` (`spectro/conv.c`). ICCery always sets `ARGYLL_NOT_INTERACTIVE=1` so Argyll uses the pipe path, not a console. + +**Invariant:** ship the Gronod fork (or equivalent PeekNamedPipe patch). Stock Argyll 3.5.0 will hang interactive chartread on Windows. + +### Kill on window close (#147, #149) + +`chartread` outlives the UI if not killed. XY tables need `q\n` first to park the head, then kill. On `CloseRequested` / `Exit`, `kill_all` is mandatory. Frontend Cancel in `TABLE_*` states sends `q\n` then kills. + +## stdin protocol + +`send_stdin` writes UTF-8 bytes and flushes. ICCery strings (see [05](05-argyll-fork.md) §12.5): + +| UI | Bytes | Meaning in real chartread | +|----|-------|---------------------------| +| Calibrate / Retry strip / Accept (many states) | `" \n"` or `"\n"` | Space or Return = trigger (`DUIH_TRIG`) | +| Done & Save | `"d\n"` | finish and write `.ti3` | +| Skip | `"s\n"` | **not a skip in real strip mode** — treated as trigger. Mock/UI invention. Rewrite should verify against fork `chartread.c` before advertising Skip. | +| Undo | `"u\n"` | same caveat | +| XY cancel | `"q\n"` | abort / park | +| Warning accept | `"\n"` | "use it anyway" | + +Always include the newline. Argyll line-buffers prompts. + +## Logging hygiene + +Spawn argv is logged with home directories rewritten to `~` (case-insensitive on Windows). Raw argv only at debug. diff --git a/docs/04-argyll-binaries.md b/docs/04-argyll-binaries.md new file mode 100644 index 0000000..f94dedc --- /dev/null +++ b/docs/04-argyll-binaries.md @@ -0,0 +1,914 @@ +# 04 — Argyll binary invocations + +> Line numbers refer to ICCery v0.8.5 (`/tmp/ICCery` at analysis time) and the Gronod ArgyllCMS 3.5.0 fork. + +Source tree: `/tmp/ICCery` (v0.8.5). ICCery never links Argyll; every binary is spawned as an isolated subprocess (`calibration.rs:1–4`). Sidecars come from the ICCery-patched fork `gronod/argyllcms` (GitHub `Gronod/argyllcms`), not stock Graeme Gill builds. + +Two spawn paths exist: + +| Path | Used by | Streams | Events | +|---|---|---|---| +| `ProcessManager::spawn` | targen, printtarg, chartread, average, colprof, profcheck, iccgamut, instlist | piped stdin/stdout/stderr, line-by-line | `process:stdout`, `process:stderr`, `process:exit`, `process:error`, `process:json_row` | +| `calibration::run_captured` | printcal, applycal | `.output().await` (full capture) | none — returns `(exit_code, stdout, stderr)` to the Tauri command | + +--- + +## 0. Shared infrastructure + +### 0.1 Binary resolution — `resolve_binary` + +`commands.rs:40–106`. Public Tauri command **and** internal helper. + +Order of search: + +1. Settings `argyll_binary_dir` (Settings dialog, `AppSettings.argyll_binary_dir`). If non-empty, join each candidate name; first existing path wins. +2. Bundled sidecar at `argyll/{platform}/{name}` under Tauri `BaseDirectory::Resource`. +3. If the resource path does not exist, **still return the constructed resource path** (does not search `$PATH`). Missing binaries surface later as spawn `process:error`. + +`get_binary_candidates` (`commands.rs:40–46`): + +- Windows: `["{name}.exe", "{name}"]` unless `name` already ends with `.exe`. +- Unix: `["{name}"]`. + +Platform directory selection (`commands.rs:65–83`, mirrored in `build.rs:26–44`): + +| OS / arch | Resource dir | +|---|---| +| linux x86_64 | `linux-x86_64` | +| windows x86_64 | `windows-x86_64` | +| macos aarch64 | `macos-universal` if `argyll/macos-universal/instlist` exists, else `macos-aarch64` | +| macos x86_64 | `macos-universal` if that marker exists, else `macos-x86_64` | +| anything else | `linux-x86_64` (fallback) | + +`default_instrument` is stored in settings (`settings.rs:76`) and shown in the Settings dialog, but **is never read when building any Argyll argv**. Instrument comes from Stage 2 `#instrumentSelect`. + +### 0.2 Working directory — `resolve_safe_cwd` + +`commands.rs:203–217`. If `cwd_input` is a real directory, use it. Else: `document_dir` → `home_dir` → `app_data_dir`. + +All specialised `run_*` commands pass `Some(resolve_safe_cwd(&app, &config.cwd))`. Exceptions: + +- `detect_instruments` / `spawn_process`: `cwd = None` (inherit parent cwd). +- `extract_gamut`: parent of the ICC file, or default cwd if empty. +- `apply_calibration`: parent of the input profile. + +### 0.3 Environment variables + +Set on **every** spawn (ProcessManager **and** `run_captured`): + +``` +ARGYLL_NOT_INTERACTIVE=1 +``` + +- ProcessManager: `process_manager.rs:97` +- `run_captured`: `calibration.rs:848` + +This is the ICCery-side half of `gronod/argyllcms#24` (Windows anonymous-pipe deadlock). It forces Argyll's `check_if_not_interactive()` path so `con_char()` uses pipe I/O instead of a console. Added in ICCery#134 (`3d514b6`). + +**Never set:** + +- `ARGYLL_3D_DISP` — grep of the tree is empty. `iccgamut` is invoked without any 3D-display env; the `.gam` file is parsed in JS (`gamut_viewer.js`). +- `PATH` — not mutated. Binaries are always absolute paths from `resolve_binary`. Inherited PATH is whatever the OS/session provides (needed only if a user-supplied `argyll_binary_dir` binary dlopens something). + +### 0.4 Windows `CREATE_NO_WINDOW` + +Both spawn paths: + +```rust +const CREATE_NO_WINDOW: u32 = 0x08000000; // 0x08000000 +command.creation_flags(CREATE_NO_WINDOW); +``` + +- ProcessManager: `process_manager.rs:99–103` +- `run_captured`: `calibration.rs:849–853` + +Fixes ICCery#46 (v0.1.6, `ec6205b`): Argyll tools are `IMAGE_SUBSYSTEM_WINDOWS_CUI`; without this flag a black console covers the UI. + +`CommandExt` is pulled in via `tokio::process::Command::creation_flags` (tokio re-exports the Windows ext). + +### 0.5 Event schema (ProcessManager only) + +`events.rs`: + +```rust +ProcessEventPayload { id: String, line: Option, code: Option, error: Option } +JsonRowPayload { id: String, json: String } +``` + +| Event | When | Fields | +|---|---|---| +| `process:stdout` | every stdout line **except** those starting `ROW_COLORS_JSON: ` | `id`, `line` | +| `process:stderr` | every stderr line | `id`, `line` | +| `process:json_row` | stdout line prefixed `ROW_COLORS_JSON: ` (prefix stripped) | `id`, `json` (raw JSON string) | +| `process:exit` | child reaped | `id`, `code` (`status.code().unwrap_or(0)` on natural exit; `unwrap_or(1)` on kill) | +| `process:error` | `Command::spawn` failed | `id`, `error` | + +Stdout reader (`process_manager.rs:128–139`): + +```rust +const JSON_ROW_PREFIX: &str = "ROW_COLORS_JSON: "; +if line.starts_with(JSON_ROW_PREFIX) { + emit_json_row(..., line[JSON_ROW_PREFIX.len()..]); +} else { + emit_stdout(...); +} +``` + +JSON-row lines are **not** forwarded as `process:stdout` and are **not** written to the subprocess log as info lines. + +Logging: spawn logs sanitised argv (`~` for `$HOME`/`%USERPROFILE%`) at info, raw argv at debug (`process_manager.rs:28–60, 105–119`). Each stdout line → `log::info!`, stderr → `log::warn!`, exit → `log::info!`. + +### 0.6 Sidecar fetch / bundle + +`scripts/fetch-argyll.mjs` downloads from `https://github.com/Gronod/argyllcms/releases` (override: `ARGYLL_SERVER_URL`, `ARGYLL_REPO`, `ARGYLL_RELEASE_TAG`). Marker binary is `instlist` / `instlist.exe`. Windows also copies `usb/` (`ArgyllCMS_install_USB.exe`, `ArgyllCMS.inf`) to `src-tauri/argyll/usb/`. + +`tauri.conf.json:38–40`: `"resources": ["argyll/**/*"]`. + +`build.rs:19–64` panics the compile if the platform marker is missing (`npm run fetch-argyll` required). + +NSIS (`windows/hooks.nsh:133–158`): admin install prompts “Install ArgyllCMS USB instrument drivers?” and `ExecWait`s `ArgyllCMS_install_USB.exe`. Uninstall does **not** run `ArgyllCMS_uninstall_USB.exe`. + +--- + +## 1. `targen` — Stage 1 patch generation **and** Stage 0 calibration chart + +### 1.1 When + +| UI | Command | Process id | +|---|---|---| +| Stage 1 **Generate** (`#btnGenerate`) | `run_targen` | `targen_{basename}` | +| Stage 0 **Generate Calibration Target** (`#btnCalGenerate`) | `generate_calibration_target` | `targen_{CAL_basename}` | + +`run_targen`: `commands.rs:952–964`. `generate_calibration_target`: `calibration.rs:598–613`. Both go through ProcessManager. + +Stage 0 prefixes the basename with `CAL_` (`calibration_basename`, never double-prefix). Calibration charts must not collide with the profiling `.ti1`. + +### 1.2 Profiling argv — `build_targen_args` (`commands.rs:788–905`) + +Always starts `-v -d {2|4}`. Colour space is the only discriminator for `-d`: RGB → `2`, CMYK → `4`. No other colourant counts. + +| UI field (`#id` / config) | Flag | Condition | +|---|---|---| +| `#colourSpace` radio (`colour_space`) | `-d 2` or `-d 4` | always | +| `#patchCountPreset` / `#patchCountCustom` (`patch_count`, else `total_patches`) | `-f N` | N > 0; JS default 800 | +| `#whitePatches` (`white_patches`) | `-e N` | Some | +| `#blackPatches` (`black_patches`) | `-B N` | Some. JS: RGB default 4, CMYK default 0 on colour-space change | +| `#targenGreySteps` (`grey_steps`) | `-g N` | Some and N > 0 | +| `#targenSingleChannelSteps` (`single_channel_steps`) | `-s N` | Some and N > 0 | +| `#targenPrecondProfile` (`preconditioning_profile`) | `-c PATH` | non-empty trim | +| `#targenNeutralSteps` (`neutral_steps`) | `-n N` | Some and N > 0 | +| `#targenNeutralConcentration` (`neutral_concentration`) | `-N x.xx` | Some and `|x-0.50| > 0.001` (slider default 0.50 → omitted) | +| `#targenHighQuality` (`ofps_high_quality`) | `-G` | `Some(true)` | +| `#targenAdaptation` (`ofps_adaptation`) | `-A x.xx` | Some (even 0.10 — **no** default-skip) | +| `#targenAlgorithm` (`full_spread_algorithm`) | `-t` `-r` `-R` `-q` `-Q` `-i` `-I` | value in that set; `"ofps"` / default → no flag | +| `#targenInkLimit` (`total_ink_limit`) | `-l N` | **CMYK only**, 1..=400 | +| `#targenDarkEmphasis` (`dark_emphasis`) | `-V x.xx` | Some and `|x-1.0| > 0.001` | +| `#targenDevicePower` (`device_power`) | `-p x.xx` | Some and `|x-1.0| > 0.001` and `x > 0` | +| `#targetBasename` | positional | last arg, no extension | + +**Not passed:** `-u` (Argyll fork has `targen -u` JSON progress — ICCery never enables it). `-v` always. + +JS config construction: `targen.js:295–314`. On success (`code === 0`) advances to Stage 2 via `setStage1Result` + `wizardState.navigateToStage(2)`. + +### 1.3 Calibration argv — `build_calibration_targen_args` (`calibration.rs:146–189`) + +Hard-wired for a short per-channel wedge, **not** a full-spread profile chart: + +``` +-v -d {2|4} -s {steps} -g {steps} [-n {steps}] -e {white|4} [-l TAC] -f 0 {CAL_basename} +``` + +| UI / config | Flag | Condition | +|---|---|---| +| `#calColourSpace` | `-d 2` / `-d 4` | rgb / cmyk | +| `#calSteps` (`steps_per_channel`) | `-s N` and `-g N` | clamped 11..=51 (`DEFAULT_STEPS=21`) | +| `#calNeutralEmphasis` | `-n N` (same N) | checked | +| `#cal` white_patches (JS always sends `4`) | `-e 4` | if `None`, code also defaults to `-e 4` | +| `#calInkExplore` | `-l N` | CMYK only, 200..=400 | +| (hardcoded) | `-f 0` | always — “Full-spread patches are not useful on a calibration wedge” | +| `channels` field | — | **unused** in the builder | + +Basename is sanitised (no `/` `\\` `..`). JS: `calibration.js:399–410`. + +### 1.4 Env / cwd / stdin + +- cwd = `resolve_safe_cwd(config.cwd)` (Stage 1 browse dir / wizard cwd). +- `ARGYLL_NOT_INTERACTIVE=1`, Windows `CREATE_NO_WINDOW`. +- **No stdin protocol.** targen is batch. + +### 1.5 stdout / exit + +Frontend appends every `process:stdout`/`stderr` line into `#targenLog` / `#calLog`. Success = `code === 0`. No regex parsing. + +### 1.6 Artefacts + +Consumes: nothing required (optional `-c` ICC/ICM/MPP). + +Produces in cwd: + +- `{basename}.ti1` (always) — Stage 1 complete gate (`verify_stage_artefacts`). +- Calibration: `CAL_{name}.ti1`. + +### 1.7 Tests + +Rust: `commands.rs:1481–1628` (RGB, CMYK, total_patches fallback, all-advanced, RGB ignores `-l`). Calibration: `calibration.rs:903–936` (RGB no `-l`, CMYK `-l 320` + `-n`, path-separator reject). JS: `calibration.test.js` basename prefix only (no argv). + +--- + +## 2. `printtarg` — Stage 2 layout (also used by Stage 0 “Create Layout”) + +### 2.1 When + +Stage 2 **Create Layout** (`#btnCreateLayout`) → `run_printtarg` (`commands.rs:966–978`). Process id `printtarg_{basename}`. + +Stage 0 **Create Layout & Print** (`#btnCalLayout`) does **not** spawn printtarg itself: it sets `wizardState.sessionMode = 'calibration'`, copies the `CAL_` basename into Stage 2 via `setStage1Result`, and navigates to Stage 2. The user then hits Create Layout. `getPrinttargCalibrationFields('CAL_…')` returns `{calibration_file: null}` so `-K` is **never** applied to the calibration chart itself (`calibration.js:108–116`, `AGENTS.md:7`). + +### 2.2 argv — `build_printtarg_args` (`commands.rs:907–950`) + +Always: + +``` +-v -u -i {instrument} -p {page_size} [ -r | -R {seed} ] [-d {label}] {-t|-T} {dpi} [-K|-I {cal}] {basename} +``` + +| UI field | Flag | Notes | +|---|---|---| +| `#instrumentSelect` | `-i {code}` | `i1` (default), `p3`, `CM`, `SS`, `20`, `22`, `41`, `51` (`PrinttargConfig` comment `commands.rs:768`) | +| `#pageSizeSelect` / custom W×H | `-p {size}` | A4, A4R, A3, A2, Letter, LetterR, Legal, 4x6, 11x17, or `{W}x{H}` mm (JS requires ≥50) | +| `#printtargLayoutOrder` | `-R 1` (default), `-R {seed}`, or `-r` | `no_randomize` **supersedes** seed (`commands.rs:917–922`). Default `random_seed: Some(1)` (`commands.rs:762–764`) for ICCery#163 determinism | +| `#targetLabelPreview` (`custom_label`) | `-d {string}` | assembled `ICCery - {run} - {printer} - {ink} - {driver paper} - {actual paper} - DD/MM/YYYY HH:MM`. Argyll fork `argyllcms#19` | +| bit-depth radios | `-t {dpi}` (8-bit) or `-T {dpi}` (16-bit) | dpi from `#tiffDpi`, default 300 | +| calibration (`getPrinttargCalibrationFields`) | `-K {cal}` apply, or `-I {cal}` embed-only | only if Apply Calibration on **and** basename is **not** `CAL_*` | +| Stage 1 basename | positional | last | + +**`-u` is always on.** That is the ICCery-patched JSON manifest (`argyllcms#3`). + +### 2.3 JSON event schema (`-u` manifest) + +Parsed in JS from the **accumulated stdout** after exit, not via `process:json_row` (`printtarg.js:680–691`): + +```js +stdout.match(/\{[\s\S]*?"event"\s*:\s*"manifest"[\s\S]*?\n\}/) +``` + +Expected object (from Argyll issue #3 and gallery use): + +```json +{ + "event": "manifest", + "pages": [ + { "filename": "target_01.tif", "patches": 800, "width_mm": 210, "height_mm": 297 } + ] +} +``` + +`renderTiffGallery` uses `page.filename`, `page.patches`, `pages[0].width_mm/height_mm`. TIFF is previewed via `read_tiff_preview_png` (decode TIFF → PNG ≤1200px, base64). + +### 2.4 stdin / env / cwd + +No stdin. cwd = Stage 1 working dir. `ARGYLL_NOT_INTERACTIVE=1`, `CREATE_NO_WINDOW`. + +### 2.5 Exit + +`code === 0` → parse manifest, show gallery + raw-print panel, `setStage2Result`. Non-zero → log error, stay on Stage 2. Native print (`print_target_native`) is **not** an Argyll call (GDI / CUPS / NSPrintPanel). + +### 2.6 Artefacts + +Consumes: `{basename}.ti1` in cwd. + +Produces: + +- `{basename}.ti2` — Stage 2 gate. +- One or more TIFF pages named in the manifest (typically `{basename}.tif` or `{basename}_NN.tif`). 8-bit (`-t`) vs 16-bit (`-T`). +- If `-K`/`-I`: calibration is applied to / embedded in the printed patches; `.cal` is not copied. + +### 2.7 Tests + +`commands.rs:1631–1802`: i1/A4/8-bit, CM/Letter/16-bit, custom `200x400`, custom label, custom seed 42, raster `-r` (seed ignored), `-K`, `-I` embed-only. JS: `calibration.test.js` asserts CAL_ charts skip `-K`. + +--- + +## 3. `chartread` — Stage 3 measurement (and Stage 0 “Measure Chart”) + +### 3.1 When + +Stage 3 **Start Measurement** (`#btnStartRead`) → `run_chartread` (`commands.rs:1045–1061`). Process id `chartread_{basename}`. + +Stage 0 **Measure Chart** navigates to Stage 3 with the `CAL_` basename; the same `run_chartread` path is used. + +`enable_i1pro2_leds` is **not** sent by JS. If the config field is `None`, the command loads `AppSettings.enable_i1pro2_leds` (`commands.rs:1051–1054`). Default `false` (stock Argyll compatibility; ICCery#204 / Argyll#37). The fork flag is `-Y l` (not the earlier proposed `-L`). + +### 3.2 argv — `build_chartread_args` (`commands.rs:1023–1043`) + +``` +-v -u [-c {port}] [-Y l] {basename} +``` + +| UI / state | Flag | Condition | +|---|---|---| +| (always) | `-v -u` | `-u` = `ROW_COLORS_JSON` stream (`argyllcms#1`) | +| `#chartreadInstrumentSelect` (`port`) | `-c {port}` | non-empty. Port `"1"` is stored as `""` by the detector so default port is used (`chartread.js:488–489`). ICCery#111: do **not** pass instlist device index as `-c`. | +| Settings `enable_i1pro2_leds` | `-Y l` | true. LEDs: white=cal, blue=ready, red=error, green=capture. Unpatched binaries reject `-Y l`; frontend captures `lastStderrLine` and opens the log. | + +No `-p` (spot), `-t` (transmissive), `-N` (skip cal), `-H`, `-F`, `-r` resume, `-n`. + +### 3.3 JSON event schema (`-u` / `ROW_COLORS_JSON`) + +Intercepted in ProcessManager, emitted as `process:json_row`. Frontend: `swatch_grid.js:95–116`. Ignored unless `data.event === "row_complete"`. + +```json +{ + "event": "row_complete", + "row_id": "A", + "row_index": 0, + "total_rows": 12, + "patch_count": 21, + "patches": [ + { + "id": "1", + "loc": "A1", + "is_pad": false, + "device": [0.0, 50.0, 100.0], + "expected": { "XYZ": [18.42, 20.12, 15.68], "Lab": [51.98, -8.45, 12.32] }, + "measured": { "XYZ": [...], "Lab": [...], "spectral": { "bands": 36, "start_nm": 380, "end_nm": 730, "norm": 100, "values": [...] } } + } + ] +} +``` + +`is_pad` patches are skipped only when they have no `measured` **and** all-zero `device` (`swatch_grid.js:137–141`) so white-reference pads from targen `-e` still render. + +On `row_index + 1 >= total_rows` the swatch listener forces `STATE.ALL_STRIPS_READ`. + +Mock: `src-tauri/argyll/mocks/chartread.mock` (handheld + `--xy` / `MOCK_XY_TABLE=1`). + +### 3.4 stdin protocol + +All via `send_stdin` (`commands.rs:16–23` → `ProcessManager::send_stdin`). Bytes are written **as-is** and flushed. No extra newline is added by Rust — JS includes `\n`. + +| Button | State(s) | Bytes | Why | +|---|---|---|---| +| `#btnCalibrate` | `CALIBRATING` | `" \n"` (space + LF) | Argyll “hit any key / space to calibrate” | +| `#btnAccept` | `WARNING`, `PROMPT_CONTINUE`, `TABLE_PLACE_SHEET`, `TABLE_ALIGN` | `"\n"` | Continue / accept strip / sheet placed / fiducial aligned. TABLE_* does **not** force `READING` | +| `#btnRetry` | `AWAITING_STRIP`, `ALL_STRIPS_READ`, `WARNING`, `ERROR` | `" \n"` | Re-read strip | +| `#btnDoneRead` | `AWAITING_STRIP`, `ALL_STRIPS_READ` | `"d\n"` | Write `.ti3` and exit (ICCery#175) | +| `#btnUndo` | strip states | `"u\n"` | Undo last strip | +| `#btnSkip` | `AWAITING_STRIP`, `ERROR` | `"s\n"` | Skip current strip | +| `#btnCancel` | any; XY extra | `"q\n"` then 500 ms then `kill_process` | Park XY head (`AGENTS.md:170`) then SIGKILL-equivalent | + +`send_stdin` errors with `"Process not found or stdin not available"` if the id is not in `stdins`. + +### 3.5 stdout state machine — `classifyChartreadLine` (`chartread.js:70–286`) + +Pure function. Priority order: + +1. “remove last sheet” → info, `isRemoveSheetNotice`, **state unchanged** (Argyll emits this just before writing `.ti3`). +2. `/sheet\s+(\d+)\s+of\s+(\d+)\s+read\s+ok/i` → `sheetOk` meta. +3. `/locate\s+patch\s+([A-Za-z0-9_]+)\s+with\s+(?:the\s+)?sight/i` → `TABLE_ALIGN`. +4. `/place\s+sheet\s+(\d+)\s+of\s+(\d+)/i` or “place sheet” / “remove previous sheet” → `TABLE_PLACE_SHEET`. +5. “hit return to continue” (and not “use it anyway”) → sticky `TABLE_*` if already there, else `PROMPT_CONTINUE`. +6. `'d' if/when done`, “all strips/patches read”, “done reading” → `ALL_STRIPS_READ`. +7. “(warning)”, “use it anyway”, “seem to have read strip pass”, “unexpected response” → `WARNING`. +8. place + (reference|white|calibrat|standard) **or** “hit any key to continue” **or** “calibration”, excluding place-sheet/locate-patch → `CALIBRATING`. +9. “hit … read … strip”, “ready to read”, “read … strip … key” → `AWAITING_STRIP`. +10. “reading strip/sheet”, “processing”, “scanning” → `READING`. +11. “error”, “too fast/slow”, “misread”, “failed to read” → `ERROR`. + +XY table is auto-detected from these prompts **or** from instlist `data-xy="1"` (`/spectro\s?scan|i1io/i`). + +### 3.6 Exit / snapshot + +`code === 0` → `snapshot_ti3` copies `{basename}.ti3` → `{basename}_pass{N}.ti3` and **deletes** the canonical `.ti3` so Stage 4 stays locked (ICCery#109/#110, `commands.rs:1112–1131`). `setStage3Result` is **not** called until Finish. + +`code !== 0` → prompt shows last stderr line; log `
` opened. + +Single pass Finish → `promote_ti3` restores `{basename}.ti3` from `*_pass1.ti3` (average is **not** invoked). Multi-pass → `run_average`. + +Cancel: `kill_process` after optional `q\n`. + +### 3.7 Artefacts + +Consumes: `{basename}.ti2` (and the printed chart). + +Produces: `{basename}.ti3` (ephemeral) then `{basename}_passN.ti3`. Canonical `.ti3` only after Finish/average. + +### 3.8 Tests + +Rust argv: `commands.rs:1805–1874` (auto, empty port, `-c 1`, `-Y l`, both, leds disabled). Snapshot roundtrip: `1898–1934`. JS classifier: `chartread.test.js` (39 cases, XY sticky continuation, strip mode, warnings). Mock script as above. + +--- + +## 4. `instlist` — instrument detection + +### 4.1 When + +Stage 3 **Detect** (`#btnDetectInstruments`) → `detect_instruments` (`commands.rs:615–619`). + +```rust +let binary = resolve_binary(..., "instlist")?; +state.spawn(app, "instlist".to_string(), binary, vec![], None).await +``` + +**Empty argv. cwd = None.** Process id is the literal `"instlist"` (not namespaced). Duplicate Detect clicks while running → `"Process 'instlist' is still running"` (#116). + +This is the Argyll fork USB enumeration API (`argyllcms#6`). ICCery does **not** pass `-u`; the fork’s `instlist` prints JSON on stdout by default (or ICCery treats the whole stdout as JSON). + +### 4.2 stdout parsing (`chartread.js:438–503`) + +Accumulate stdout. On exit: + +1. `JSON.parse(trimmed)` looking for `{ devices: [ { port, name, type } ] }`. +2. Fallback regex: `/^(\d+)[\s:=]+'?([^'\n]+)'?(?:\s+on\s+'?([^'\n]+)'?)?/i` plus `KNOWN_INST_TOKENS = /i1|ColorMunki|Spyder|spectro|Display|Huey|DTP|SpectroScan|Smile|Klein/i`. + +Each device → `