Compare commits
140
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8c41444b9 | ||
|
|
1767792f75 | ||
|
|
e8678586bd | ||
|
|
c9b423b229 | ||
|
|
79209eb911 | ||
|
|
51c30737c7 | ||
|
|
37d1f7eb24 | ||
|
|
bb3cc3c858 | ||
|
|
eb73bf0106 | ||
|
|
736fd080cb | ||
|
|
14cd544e05 | ||
|
|
2fefe98434 | ||
|
|
90d034baab | ||
|
|
8cb79e8ffa | ||
|
|
c1c37bb7d7 | ||
|
|
d2466d9a66 | ||
|
|
4af2d96294 | ||
|
|
d17aa962ce | ||
|
|
93acd32b51 | ||
|
|
67cf452e59 | ||
|
|
83ed53c7fc | ||
|
|
cba9476af7 | ||
|
|
288b08e3d9 | ||
|
|
18f1d1ff33 | ||
|
|
9dfa79ebd0 | ||
|
|
1b91b0a94e | ||
|
|
890e7281eb | ||
|
|
ccaad2d940 | ||
|
|
7e5e20cb69 | ||
|
|
e72039c123 | ||
|
|
fa02e774ca | ||
|
|
2a8ad4d5d4 | ||
|
|
c2ac9341c8 | ||
|
|
1c310706e4 | ||
|
|
7f6c47d85c | ||
|
|
3b6323378e | ||
|
|
ad7b91cf91 | ||
|
|
8b931e3625 | ||
|
|
73b18dec5b | ||
|
|
3bc0d14a34 | ||
|
|
4aa2815c6e | ||
|
|
8596f15d52 | ||
|
|
407136c5a2 | ||
|
|
83b5f6bf75 | ||
|
|
16df18127c | ||
|
|
78baa49e61 | ||
|
|
04a563c54a | ||
|
|
2cbd43470d | ||
|
|
14f9e6f78d | ||
|
|
f16825be72 | ||
|
|
9fcd21dc2d | ||
|
|
f5c57649e1 | ||
|
|
e967502ea7 | ||
|
|
eb3ae08cf9 | ||
|
|
3d8e70be62 | ||
|
|
f43c275152 | ||
|
|
48d2816c73 | ||
|
|
b494b4fffd | ||
|
|
c39afe0dfd | ||
|
|
1d78957d7a | ||
|
|
99c0d7e83d | ||
|
|
398d9b4756 | ||
|
|
538ef1e05f | ||
|
|
d40932f4cf | ||
|
|
1931da8448 | ||
|
|
f896f60d1a | ||
|
|
2f305c481e | ||
|
|
4249067345 | ||
|
|
800c980137 | ||
|
|
67fb3a9b37 | ||
|
|
eb1503aba9 | ||
|
|
db47a975f0 | ||
|
|
990c49a436 | ||
|
|
60972a0704 | ||
|
|
aa32cc45d3 | ||
|
|
17c47af9f1 | ||
|
|
5bea928042 | ||
|
|
5c8973de73 | ||
|
|
29ecc79585 | ||
|
|
7c5a5e7798 | ||
|
|
607a4e0df4 | ||
|
|
67199877a2 | ||
|
|
9a6da4648f | ||
|
|
b5250591ea | ||
|
|
c1ec07f726 | ||
|
|
a975f1a4e4 | ||
|
|
7c2282a7dd | ||
|
|
caeafb22be | ||
|
|
aac4f10d23 | ||
|
|
b7ac4f92ed | ||
|
|
3244efd366 | ||
|
|
7a05da3a35 | ||
|
|
5134e82e63 | ||
|
|
7fd93247cb | ||
|
|
a0fcede454 | ||
|
|
97eafd11fb | ||
|
|
ed7487c2b6 | ||
|
|
a2e3f11e70 | ||
|
|
c3c9bbc5ba | ||
|
|
b5683aa36a | ||
|
|
707455dcfb | ||
|
|
17ee5d6717 | ||
|
|
2295bcdbae | ||
|
|
c9bff50a1a | ||
|
|
1b3d3dd821 | ||
|
|
55dee0f0cd | ||
|
|
45fd2b988f | ||
|
|
7c6ad1e6ce | ||
|
|
9857ddb4d0 | ||
|
|
7a6b82816b | ||
|
|
32d2184d2e | ||
|
|
a76120d9f7 | ||
|
|
a30a8fc551 | ||
|
|
83f3a4f0e2 | ||
|
|
150d094632 | ||
|
|
3184c50fb9 | ||
|
|
b2b85ae835 | ||
|
|
09f433106d | ||
|
|
891a504ee7 | ||
|
|
f681e60778 | ||
|
|
0d0233e8d6 | ||
|
|
d117d7a510 | ||
|
|
78ffeff61b | ||
|
|
0298f69a2c | ||
|
|
da602e2775 | ||
|
|
5e3b183b9a | ||
|
|
e48c3f6840 | ||
|
|
7751701208 | ||
|
|
c539507d5d | ||
|
|
0ffcf5ea91 | ||
|
|
597cce7b60 | ||
|
|
bb4512e129 | ||
|
|
12584d156a | ||
|
|
0332a2bb4f | ||
|
|
0d98440a15 | ||
|
|
073e3aa308 | ||
|
|
85fa543c4a | ||
|
|
6a612c0c1d | ||
|
|
e203127794 | ||
|
|
d37dcedfd9 |
+140
-17
@@ -14,29 +14,83 @@ jobs:
|
|||||||
build-and-test:
|
build-and-test:
|
||||||
# Prefer a self-hosted Mac runner if your Gitea has one. If not,
|
# Prefer a self-hosted Mac runner if your Gitea has one. If not,
|
||||||
# macos-14 works for this pipeline.
|
# macos-14 works for this pipeline.
|
||||||
runs-on: macos-14
|
runs-on: macos-12
|
||||||
env:
|
env:
|
||||||
DERIVED: build/DerivedData-test
|
DERIVED: build/DerivedData-test
|
||||||
|
# Tag name on tag pushes, branch name otherwise — scripts/version.sh
|
||||||
|
# only honours values matching 'v[0-9]*'.
|
||||||
|
RELEASE_TAG: ${{ github.ref_name }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# Full history + tags: scripts/version.sh needs `git describe` and
|
||||||
|
# `git rev-list --count`; the default depth-1 clone has neither.
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- 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
|
- name: Generate Xcode project
|
||||||
|
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||||
run: xcodegen generate --spec project.yml
|
run: xcodegen generate --spec project.yml
|
||||||
|
|
||||||
- name: Build for testing (universal)
|
# Tests only ever run on the runner's own architecture; build
|
||||||
|
# just that slice. Packaging (scripts/package-release.sh) still
|
||||||
|
# produces the universal Release binary.
|
||||||
|
- name: Build for testing (host arch)
|
||||||
|
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||||
run: |
|
run: |
|
||||||
|
# Stamp the test build with the same version triple packaging uses
|
||||||
|
# so CI-built apps are traceable (and mis-tags fail early) (#189).
|
||||||
|
eval "$(scripts/version.sh)"
|
||||||
|
echo "version=$MARKETING_VERSION build=$CURRENT_PROJECT_VERSION tag=$ICCERY_RELEASE_TAG"
|
||||||
xcodebuild build-for-testing \
|
xcodebuild build-for-testing \
|
||||||
-scheme ICCery \
|
-scheme ICCery \
|
||||||
-destination 'platform=macOS' \
|
-destination 'platform=macOS' \
|
||||||
-derivedDataPath "$DERIVED" \
|
-derivedDataPath "$DERIVED" \
|
||||||
-configuration Debug \
|
-configuration Debug \
|
||||||
ARCHS='arm64 x86_64' \
|
ARCHS="$(uname -m)" \
|
||||||
ONLY_ACTIVE_ARCH=NO \
|
ONLY_ACTIVE_ARCH=NO \
|
||||||
CODE_SIGNING_ALLOWED=YES \
|
CODE_SIGNING_ALLOWED=YES \
|
||||||
CODE_SIGN_IDENTITY='-'
|
CODE_SIGN_IDENTITY='-' \
|
||||||
|
MARKETING_VERSION="$MARKETING_VERSION" \
|
||||||
|
CURRENT_PROJECT_VERSION="$CURRENT_PROJECT_VERSION" \
|
||||||
|
ICCERY_RELEASE_TAG="$ICCERY_RELEASE_TAG"
|
||||||
|
|
||||||
|
# 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)
|
- name: Test unit (ICCeryCoreTests)
|
||||||
|
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||||
run: |
|
run: |
|
||||||
XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)"
|
XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)"
|
||||||
if [ -z "$XCTESTRUN" ] || [ ! -f "$XCTESTRUN" ]; then
|
if [ -z "$XCTESTRUN" ] || [ ! -f "$XCTESTRUN" ]; then
|
||||||
@@ -52,52 +106,121 @@ jobs:
|
|||||||
|
|
||||||
# UI tests need macOS Automation / Accessibility permission on the
|
# UI tests need macOS Automation / Accessibility permission on the
|
||||||
# runner. The self-hosted Mac intermittently times out enabling
|
# runner. The self-hosted Mac intermittently times out enabling
|
||||||
# that mode (run 29700 / tag v2.0.0-pre2-grok). Retry once; if the
|
# 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
|
# runner still cannot attach, do not fail the required gate so
|
||||||
# tag packaging can proceed. Real XCTest failures still fail the job.
|
# tag packaging can proceed. Real XCTest assertion failures
|
||||||
|
# still fail the job.
|
||||||
- name: Test UI (ICCeryUITests)
|
- name: Test UI (ICCeryUITests)
|
||||||
|
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||||
run: |
|
run: |
|
||||||
set -o pipefail
|
set -o pipefail
|
||||||
XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)"
|
XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)"
|
||||||
LOG="$DERIVED/ui-test.log"
|
LOG="$DERIVED/ui-test.log"
|
||||||
attempt=1
|
pkill -x ICCery 2>/dev/null || true
|
||||||
while [ "$attempt" -le 2 ]; do
|
sleep 1
|
||||||
echo "::group::UI tests attempt $attempt"
|
|
||||||
|
run_ui() {
|
||||||
|
local label="$1"
|
||||||
|
shift
|
||||||
|
echo "::group::UI tests $label"
|
||||||
set +e
|
set +e
|
||||||
xcodebuild test-without-building \
|
xcodebuild test-without-building \
|
||||||
-xctestrun "$XCTESTRUN" \
|
-xctestrun "$XCTESTRUN" \
|
||||||
-only-testing:ICCeryUITests \
|
|
||||||
-destination 'platform=macOS' \
|
-destination 'platform=macOS' \
|
||||||
-derivedDataPath "$DERIVED" | tee "$LOG"
|
-derivedDataPath "$DERIVED" \
|
||||||
|
"$@" | tee "$LOG"
|
||||||
rc=${PIPESTATUS[0]}
|
rc=${PIPESTATUS[0]}
|
||||||
set -e
|
set -e
|
||||||
echo "::endgroup::"
|
echo "::endgroup::"
|
||||||
if [ "$rc" -eq 0 ]; then
|
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
|
exit 0
|
||||||
fi
|
fi
|
||||||
if grep -q "Timed out while enabling automation mode" "$LOG"; then
|
if is_runner_attach_failure; then
|
||||||
echo "warning: UI runner automation-mode timeout (attempt $attempt)"
|
echo "warning: UI runner lost activation mid-suite (attempt $attempt)"
|
||||||
|
pkill -x ICCery 2>/dev/null || true
|
||||||
attempt=$((attempt + 1))
|
attempt=$((attempt + 1))
|
||||||
sleep 8
|
sleep 8
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
echo "error: UI tests failed with a real test error" >&2
|
echo "error: UI tests failed with a real test error" >&2
|
||||||
exit "$rc"
|
exit 1
|
||||||
done
|
done
|
||||||
echo "warning: skipping UI tests after repeated automation-mode timeouts"
|
echo "warning: skipping UI tests after repeated runner attach/activate failures"
|
||||||
exit 0
|
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:
|
package:
|
||||||
needs: build-and-test
|
needs: build-and-test
|
||||||
runs-on: macos-14
|
runs-on: macos-12
|
||||||
if: github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/tags/v')
|
if: github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/tags/v')
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# Full history + tags: scripts/version.sh needs `git describe` and
|
||||||
|
# `git rev-list --count`; the default depth-1 clone has neither.
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
# 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
|
- name: Package release
|
||||||
run: scripts/package-release.sh
|
run: scripts/package-release.sh
|
||||||
env:
|
env:
|
||||||
|
RELEASE_TAG: ${{ github.ref_name }}
|
||||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
CODESIGN_IDENTITY: ${{ secrets.CODESIGN_IDENTITY }}
|
CODESIGN_IDENTITY: ${{ secrets.CODESIGN_IDENTITY }}
|
||||||
DEVELOPMENT_TEAM: ${{ secrets.DEVELOPMENT_TEAM }}
|
DEVELOPMENT_TEAM: ${{ secrets.DEVELOPMENT_TEAM }}
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
# 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
|
||||||
|
# Tag name on tag pushes, branch name otherwise — scripts/version.sh
|
||||||
|
# only honours values matching 'v[0-9]*'.
|
||||||
|
RELEASE_TAG: ${{ github.ref_name }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# Full history + tags: scripts/version.sh needs `git describe` and
|
||||||
|
# `git rev-list --count`; the default depth-1 clone has neither.
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- 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: |
|
||||||
|
# Stamp the test build with the same version triple packaging uses
|
||||||
|
# so CI-built apps are traceable (and mis-tags fail early) (#189).
|
||||||
|
eval "$(scripts/version.sh)"
|
||||||
|
echo "version=$MARKETING_VERSION build=$CURRENT_PROJECT_VERSION tag=$ICCERY_RELEASE_TAG"
|
||||||
|
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='-' \
|
||||||
|
MARKETING_VERSION="$MARKETING_VERSION" \
|
||||||
|
CURRENT_PROJECT_VERSION="$CURRENT_PROJECT_VERSION" \
|
||||||
|
ICCERY_RELEASE_TAG="$ICCERY_RELEASE_TAG"
|
||||||
|
|
||||||
|
# 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
|
||||||
|
with:
|
||||||
|
# Full history + tags: scripts/version.sh needs `git describe` and
|
||||||
|
# `git rev-list --count`; the default depth-1 clone has neither.
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
RELEASE_TAG: ${{ github.ref_name }}
|
||||||
|
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
|
||||||
+5
-1
@@ -18,6 +18,9 @@ Vendor/Argyll/
|
|||||||
# XcodeGen output (regenerate with `make gen`)
|
# XcodeGen output (regenerate with `make gen`)
|
||||||
ICCery.xcodeproj/
|
ICCery.xcodeproj/
|
||||||
|
|
||||||
|
# Local-only developer helpers (never committed)
|
||||||
|
/set-version.sh
|
||||||
|
|
||||||
# macOS
|
# macOS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
@@ -27,4 +30,5 @@ ICCery.xcodeproj/
|
|||||||
Release/
|
Release/
|
||||||
notarization/
|
notarization/
|
||||||
build/
|
build/
|
||||||
docs/megaplans/
|
docs/megaplans/*
|
||||||
|
docs/megaplans
|
||||||
|
|||||||
@@ -39,17 +39,36 @@ Empty cwd illegal (#59). Atomic writes = `.tmp` + rename (#213).
|
|||||||
User-supplied strings via SwiftUI `Text` only (#114).
|
User-supplied strings via SwiftUI `Text` only (#114).
|
||||||
TIFF never rendered directly — host-side PNG preview (#58).
|
TIFF never rendered directly — host-side PNG preview (#58).
|
||||||
|
|
||||||
|
## Versioning
|
||||||
|
`scripts/version.sh` is the single source: tag/describe → `ICCERY_RELEASE_TAG`
|
||||||
|
(About shows `tag (marketing)`), `MARKETING_VERSION` = strict `X.Y.Z`,
|
||||||
|
`CURRENT_PROJECT_VERSION` = `git rev-list --count HEAD` (#189).
|
||||||
|
`v*` tag builds hard-fail if tag's X.Y.Z ≠ `project.yml` MARKETING_VERSION —
|
||||||
|
bump `project.yml` on `develop` before tagging. CI needs `fetch-depth: 0`.
|
||||||
|
|
||||||
## Branching
|
## Branching
|
||||||
`develop` ← `milestone/mN-<name>` ← `feat/<issue#>-<slug>`.
|
`develop` ← `milestone/mN-<name>` ← `feat/<issue#>-<slug>`.
|
||||||
PRs via Gitea MCP. Every issue/PR: `Project/ICCery-v2` + `Feature/*` or `Bug/*` + `Priority/*`.
|
PRs via Gitea MCP. Every issue/PR: `Project/ICCery-v2` + `Feature/*` or `Bug/*` + `Priority/*`.
|
||||||
|
|
||||||
## Verify
|
## Verify
|
||||||
```
|
```
|
||||||
xcodebuild test -scheme ICCery -destination 'platform=macOS' ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
|
xcodebuild test -scheme ICCery -destination 'platform=macOS' ARCHS="$(uname -m)"
|
||||||
codesign -dvv <sidecar>
|
codesign -dvv <sidecar>
|
||||||
```
|
```
|
||||||
|
Universal (`ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO`) is still required for release verification / packaging.
|
||||||
|
|
||||||
## Private ColorSync SPI
|
## Private ColorSync SPI
|
||||||
2-arg `(PMPrintSession, CFStringRef) -> OSStatus`. Never pass integer `1`.
|
2-arg `(PMPrintSession, CFStringRef) -> OSStatus`. Never pass integer `1`.
|
||||||
Modes: `AP_ApplicationColorMatching` then `ApplicationColorMatching`.
|
Modes: `AP_ApplicationColorMatching` then `ApplicationColorMatching`.
|
||||||
`lp` path and Quartz/`ICCeryPrintKit` path use **different** ColorSync dictionaries. Never mix.
|
`lp` path and Quartz/`ICCeryPrintKit` path use **different** ColorSync dictionaries. Never mix.
|
||||||
|
|
||||||
|
## Gitea issue dependencies
|
||||||
|
Use the `gitea` MCP (custom build with blocking support — verified working):
|
||||||
|
|
||||||
|
- `issue_write` methods:
|
||||||
|
- `add_dependency` — `blocking_issue` blocks `issue_number`.
|
||||||
|
- `remove_dependency` — removes `blocking_issue` from `issue_number`'s blockers.
|
||||||
|
- `block_issue` / `unblock_issue` — `issue_number` blocks/unblocks `blocked_issue`.
|
||||||
|
- `issue_read` methods: `list_dependencies` (issues blocking N),
|
||||||
|
`list_blocks` (issues N blocks).
|
||||||
|
- All issue numbers are *display numbers*, not db ids.
|
||||||
|
|||||||
+8
-2
@@ -1,6 +1,6 @@
|
|||||||
# BUILD-PLAN.md — ICCery v2 Mac
|
# BUILD-PLAN.md — ICCery v2 Mac
|
||||||
|
|
||||||
Spec snapshot: `docs/`. Source of tickets: Gitea milestones M1–M6 + Later.
|
Spec snapshot: `docs/`. Source of tickets: Gitea milestones M1–M6 + M10 (id 32) + M11 (id 34) + Later.
|
||||||
|
|
||||||
## Sprint rule
|
## Sprint rule
|
||||||
Do not start milestone N+1 implementation until milestone N **CI/mock gate** is green.
|
Do not start milestone N+1 implementation until milestone N **CI/mock gate** is green.
|
||||||
@@ -17,9 +17,15 @@ Hardware gates block *release of that sprint*, not filing, and not starting codi
|
|||||||
| M5 | Profile / verify / install | 23–27 | colprof → `.icc`; profcheck parse; atomic history; install into temp dir | Full `.ti1`→`.icc`; profile visible in ColorSync Utility |
|
| 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 |
|
| 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 |
|
| 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` |
|
||||||
|
| M11 | Printer settings completeness & dialog binding | 183, 180, 181, 186 | Quality/media/paper caps parse + `LpArgs` emit + picker UI smoke + panel capture-return | Real Epson: 7 `EPIJ_Qual` options; real Canon Pro9500: English media labels; dialog bound to non-default queue with paper/quality/media/orientation preselected; captured settings reach `lp` argv |
|
||||||
| Later | Quartz / TargetPrint | 16 | `ICCeryPrintKit` standalone + seam test | 1:1 on paper vs TIFF |
|
| Later | Quartz / TargetPrint | 16 | `ICCeryPrintKit` standalone + seam test | 1:1 on paper vs TIFF |
|
||||||
|
|
||||||
Issue **16 is not an M3 or M6 exit gate.**
|
M8–M10 are merged to `develop`; M11 assembles on `milestone/m11-print-settings` from `736fd08`.
|
||||||
|
|
||||||
|
Issue **16 is not an M3, M6, M10, or M11 exit gate.**
|
||||||
|
|
||||||
## Branch taxonomy
|
## Branch taxonomy
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
// swift-tools-version: 6.0
|
// swift-tools-version: 5.7
|
||||||
import PackageDescription
|
import PackageDescription
|
||||||
|
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "ICCeryCore",
|
name: "ICCeryCore",
|
||||||
platforms: [.macOS(.v14)],
|
platforms: [.macOS(.v12)],
|
||||||
products: [
|
products: [
|
||||||
.library(name: "ICCeryCore", targets: ["ICCeryCore"]),
|
.library(name: "ICCeryCore", targets: ["ICCeryCore"]),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -192,6 +192,18 @@ public struct ArgyllRunner: Sendable {
|
|||||||
|
|
||||||
// MARK: - Shared collection
|
// 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
|
/// Cancels any previous child with the same id and waits for it to
|
||||||
/// finalize, so `runStreaming` / `runCaptured` never sees a
|
/// finalize, so `runStreaming` / `runCaptured` never sees a
|
||||||
/// `duplicateID` from a leftover process (#50, #52).
|
/// `duplicateID` from a leftover process (#50, #52).
|
||||||
@@ -200,7 +212,7 @@ public struct ArgyllRunner: Sendable {
|
|||||||
await processManager.kill(id: id)
|
await processManager.kill(id: id)
|
||||||
var attempts = 0
|
var attempts = 0
|
||||||
while await processManager.isRunning(id), attempts < 30 {
|
while await processManager.isRunning(id), attempts < 30 {
|
||||||
try? await Task.sleep(for: .milliseconds(100))
|
try? await Task.sleep(nanoseconds: Self.testAwareDelay(100_000_000))
|
||||||
attempts += 1
|
attempts += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -243,7 +255,7 @@ public struct ArgyllRunner: Sendable {
|
|||||||
if flushPartialLines {
|
if flushPartialLines {
|
||||||
dotFlushTask = Task { [processManager] in
|
dotFlushTask = Task { [processManager] in
|
||||||
while !Task.isCancelled {
|
while !Task.isCancelled {
|
||||||
try? await Task.sleep(for: .milliseconds(500))
|
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||||
if Task.isCancelled { break }
|
if Task.isCancelled { break }
|
||||||
await processManager.flushPartialLine(id: processId)
|
await processManager.flushPartialLine(id: processId)
|
||||||
}
|
}
|
||||||
@@ -592,7 +604,7 @@ public struct ArgyllRunner: Sendable {
|
|||||||
await processManager.setPreKillHook(id: processId) { [processManager] in
|
await processManager.setPreKillHook(id: processId) { [processManager] in
|
||||||
if isXY {
|
if isXY {
|
||||||
try? await processManager.sendStdin(id: processId, bytes: ChartreadInput.quit.bytes)
|
try? await processManager.sendStdin(id: processId, bytes: ChartreadInput.quit.bytes)
|
||||||
try? await Task.sleep(for: .milliseconds(500))
|
try? await Task.sleep(nanoseconds: Self.testAwareDelay(500_000_000))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -730,6 +742,148 @@ public struct ArgyllRunner: Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - spotread (spot-read console, issue #148)
|
||||||
|
|
||||||
|
/// Runs `spotread` and returns an `AsyncStream` of typed events.
|
||||||
|
///
|
||||||
|
/// Same subscribe-before-spawn shape as `runChartread`, but there is
|
||||||
|
/// no artefact: the stream ends with `.exit(code)`. The single-lease
|
||||||
|
/// process id is `ProcessID.spotread` — never `chartread_{basename}`.
|
||||||
|
/// Missing sidecar surfaces as `.failed`; there is no `$PATH` or
|
||||||
|
/// `chartread` fallback (#116, R14/R21).
|
||||||
|
public func runSpotread(config: SpotReadConfig) -> AsyncStream<SpotReadEvent> {
|
||||||
|
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
||||||
|
let args = SpotReadArgs.build(config: config)
|
||||||
|
let binaryURL = binaryResolver.resolve("spotread")
|
||||||
|
let processId = ProcessID.spotread
|
||||||
|
let processManager = self.processManager
|
||||||
|
let isXY = config.isXY
|
||||||
|
let instrumentName = config.instrumentName
|
||||||
|
let instrumentPort = config.instrumentPort
|
||||||
|
|
||||||
|
return AsyncStream { continuation in
|
||||||
|
let task = Task {
|
||||||
|
await ensureNotRunning(id: processId)
|
||||||
|
let events = processManager.events()
|
||||||
|
|
||||||
|
// XY parking hook before any kill, same as chartread.
|
||||||
|
await processManager.setPreKillHook(id: processId) { [processManager] in
|
||||||
|
if isXY {
|
||||||
|
try? await processManager.sendStdin(id: processId, bytes: ChartreadInput.quit.bytes)
|
||||||
|
try? await Task.sleep(nanoseconds: Self.testAwareDelay(500_000_000))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard binaryResolver.exists(binaryURL) else {
|
||||||
|
continuation.yield(.failed(ArgyllRunnerError.toolFailed(
|
||||||
|
tool: "spotread", code: -1,
|
||||||
|
logs: ["spotread sidecar missing — run fetch-argyll"])))
|
||||||
|
continuation.finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await processManager.runStreaming(
|
||||||
|
id: processId,
|
||||||
|
binary: binaryURL,
|
||||||
|
arguments: args,
|
||||||
|
workingDirectory: cwd
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
continuation.yield(.failed(ArgyllRunnerError.toolFailed(
|
||||||
|
tool: "spotread", code: -1, logs: [error.localizedDescription])))
|
||||||
|
continuation.finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var state: ChartreadState = .idle
|
||||||
|
var pendingLogs: [String] = []
|
||||||
|
var lastFlush = Date()
|
||||||
|
var exitCode: Int32?
|
||||||
|
|
||||||
|
func flushLogs() {
|
||||||
|
guard !pendingLogs.isEmpty else { return }
|
||||||
|
let batch = pendingLogs
|
||||||
|
pendingLogs.removeAll(keepingCapacity: true)
|
||||||
|
continuation.yield(.log(batch))
|
||||||
|
}
|
||||||
|
|
||||||
|
for await event in events {
|
||||||
|
guard event.id == processId else { continue }
|
||||||
|
|
||||||
|
switch event {
|
||||||
|
case .stdout(_, let line):
|
||||||
|
if let parsed = SpotReadParser.parse(line: line) {
|
||||||
|
continuation.yield(.sample(SpotReadSample(
|
||||||
|
lab: parsed.lab,
|
||||||
|
xyz: parsed.xyz,
|
||||||
|
instrumentName: instrumentName,
|
||||||
|
port: instrumentPort,
|
||||||
|
rawLine: line
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
let classified = SpotReadClassifier.classify(
|
||||||
|
line: line, previousState: state)
|
||||||
|
if classified.state != state
|
||||||
|
|| classified.requestedWarningKey != nil {
|
||||||
|
state = classified.state
|
||||||
|
continuation.yield(.prompt(classified))
|
||||||
|
}
|
||||||
|
pendingLogs.append(line)
|
||||||
|
|
||||||
|
case .stderr(_, let line):
|
||||||
|
pendingLogs.append(line)
|
||||||
|
|
||||||
|
case .jsonRow:
|
||||||
|
// spotread is never run with `-u`.
|
||||||
|
break
|
||||||
|
|
||||||
|
case .error(_, let message):
|
||||||
|
pendingLogs.append("Error: \(message)")
|
||||||
|
|
||||||
|
case .exit(_, let code):
|
||||||
|
exitCode = code
|
||||||
|
}
|
||||||
|
|
||||||
|
if exitCode == nil,
|
||||||
|
pendingLogs.count >= 20 || Date().timeIntervalSince(lastFlush) >= 0.1 {
|
||||||
|
flushLogs()
|
||||||
|
lastFlush = Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
if exitCode != nil {
|
||||||
|
flushLogs()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
continuation.yield(.exit(exitCode ?? -1))
|
||||||
|
continuation.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
continuation.onTermination = { _ in
|
||||||
|
task.cancel()
|
||||||
|
Task {
|
||||||
|
await processManager.kill(id: processId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send input bytes to the running `spotread` child. Reuses
|
||||||
|
/// `ChartreadInput` — the stdin protocol is identical.
|
||||||
|
public func sendSpotreadInput(_ input: ChartreadInput) async throws {
|
||||||
|
try await processManager.sendStdin(id: ProcessID.spotread, bytes: input.bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Terminate a running `spotread` child. The XY park (`q\n` +
|
||||||
|
/// ~500 ms) runs in the pre-kill hook registered by `runSpotread`.
|
||||||
|
public func cancelSpotread() {
|
||||||
|
Task {
|
||||||
|
await processManager.kill(id: ProcessID.spotread)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Stage 0 calibration
|
// MARK: - Stage 0 calibration
|
||||||
|
|
||||||
/// Generates a calibration wedge `.ti1`.
|
/// Generates a calibration wedge `.ti1`.
|
||||||
@@ -816,6 +970,20 @@ public enum ChartreadEvent: Sendable {
|
|||||||
case failed(ArgyllRunnerError)
|
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.
|
/// Exact bytes sent to `chartread` stdin.
|
||||||
public enum ChartreadInput: Sendable {
|
public enum ChartreadInput: Sendable {
|
||||||
case trigger // " \n"
|
case trigger // " \n"
|
||||||
|
|||||||
@@ -56,23 +56,19 @@ public enum PrinttargArgs {
|
|||||||
}
|
}
|
||||||
args.append(contentsOf: ["-R", "\(config.customSeed)"])
|
args.append(contentsOf: ["-R", "\(config.customSeed)"])
|
||||||
case .raster:
|
case .raster:
|
||||||
args.append("-r")
|
args.append(contentsOf: ArgsBuilder.flag("-r", when: true))
|
||||||
}
|
}
|
||||||
|
|
||||||
if let label = config.label?.trimmingCharacters(in: .whitespacesAndNewlines),
|
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-d", config.label))
|
||||||
!label.isEmpty {
|
|
||||||
args.append(contentsOf: ["-d", label])
|
|
||||||
}
|
|
||||||
|
|
||||||
guard (72...600).contains(config.dpi) else {
|
guard (72...600).contains(config.dpi) else {
|
||||||
throw PrinttargArgError.invalidDPI(config.dpi)
|
throw PrinttargArgError.invalidDPI(config.dpi)
|
||||||
}
|
}
|
||||||
args.append(contentsOf: [config.bitDepth.flag, "\(config.dpi)"])
|
args.append(contentsOf: [config.bitDepth.flag, "\(config.dpi)"])
|
||||||
|
|
||||||
if !CalibrationIdentity.isCalibration(cleanBasename),
|
if !CalibrationIdentity.isCalibration(cleanBasename) {
|
||||||
let cal = config.calibrationFile?.trimmingCharacters(in: .whitespacesAndNewlines),
|
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty(
|
||||||
!cal.isEmpty {
|
config.calibrationEmbedOnly ? "-I" : "-K", config.calibrationFile))
|
||||||
args.append(contentsOf: [config.calibrationEmbedOnly ? "-I" : "-K", cal])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
args.append(cleanBasename)
|
args.append(cleanBasename)
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Pure argv builder for Argyll's `spotread` tool (issue #148).
|
||||||
|
public enum SpotReadArgs {
|
||||||
|
|
||||||
|
/// Builds `spotread` argv per the Gronod fork protocol.
|
||||||
|
///
|
||||||
|
/// - Always `-v -e` (paper / reflective; never display `-d`).
|
||||||
|
/// - `-c N` is emitted only for `selectedPort != nil` and `N > 1`
|
||||||
|
/// (Auto and port 1 omit it, #111).
|
||||||
|
/// - `-Y l` (letter L) is emitted only when `enableLEDs` is `true` (#204).
|
||||||
|
/// - Never `-u`: the v2.0 `-u` policy covers printtarg + chartread +
|
||||||
|
/// profcheck only.
|
||||||
|
/// - No basename — `spotread` writes no artefact.
|
||||||
|
public static func build(config: SpotReadConfig) -> [String] {
|
||||||
|
var args: [String] = ["-v", "-e"]
|
||||||
|
|
||||||
|
if let port = config.selectedPort, port > 1 {
|
||||||
|
args.append(contentsOf: ["-c", "\(port)"])
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.enableLEDs {
|
||||||
|
args.append(contentsOf: ["-Y", "l"])
|
||||||
|
}
|
||||||
|
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Configuration for a `spotread` invocation (issue #148).
|
||||||
|
///
|
||||||
|
/// `spotread` writes no artefact; `workingDirectory` is still required
|
||||||
|
/// for the spawn (#59 — empty cwd is illegal).
|
||||||
|
public struct SpotReadConfig: Codable, Equatable, Sendable {
|
||||||
|
public var workingDirectory: URL?
|
||||||
|
/// Communication port for `spotread -c`.
|
||||||
|
/// `nil` means omit `-c` (Auto or port 1, #111). Never an array index.
|
||||||
|
public var selectedPort: Int?
|
||||||
|
/// Enable i1Pro 2 visual LEDs (`-Y l`, #204).
|
||||||
|
public var enableLEDs: Bool
|
||||||
|
/// Whether the selected instrument is an XY table — controls the
|
||||||
|
/// `q\n` + ~500 ms park before kill on cancel.
|
||||||
|
public var isXY: Bool
|
||||||
|
/// Display name stamped onto each `SpotReadSample`.
|
||||||
|
public var instrumentName: String
|
||||||
|
/// Instrument port stamped onto each sample (nil for Auto).
|
||||||
|
public var instrumentPort: Int?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
workingDirectory: URL? = nil,
|
||||||
|
selectedPort: Int? = nil,
|
||||||
|
enableLEDs: Bool = false,
|
||||||
|
isXY: Bool = false,
|
||||||
|
instrumentName: String = "",
|
||||||
|
instrumentPort: Int? = nil
|
||||||
|
) {
|
||||||
|
self.workingDirectory = workingDirectory
|
||||||
|
self.selectedPort = selectedPort
|
||||||
|
self.enableLEDs = enableLEDs
|
||||||
|
self.isXY = isXY
|
||||||
|
self.instrumentName = instrumentName
|
||||||
|
self.instrumentPort = instrumentPort
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,18 +70,12 @@ public enum TargenArgs {
|
|||||||
if let n = config.neutralSteps, n > 0 {
|
if let n = config.neutralSteps, n > 0 {
|
||||||
args.append(contentsOf: ["-n", "\(n)"])
|
args.append(contentsOf: ["-n", "\(n)"])
|
||||||
}
|
}
|
||||||
if let nConc = config.neutralConcentration, abs(nConc - 0.50) >= 0.001 {
|
args.append(contentsOf: ArgsBuilder.optionUnlessApprox("-N", config.neutralConcentration, skip: 0.50))
|
||||||
args.append(contentsOf: ["-N", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), nConc)])
|
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-c", config.preconditioningProfile))
|
||||||
}
|
args.append(contentsOf: ArgsBuilder.flag("-G", when: config.ofpsHighQuality == true))
|
||||||
if let c = config.preconditioningProfile?.trimmingCharacters(in: .whitespacesAndNewlines), !c.isEmpty {
|
args.append(contentsOf: ArgsBuilder.option("-A", config.ofpsAdaptation.map {
|
||||||
args.append(contentsOf: ["-c", c])
|
String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), $0)
|
||||||
}
|
}))
|
||||||
if config.ofpsHighQuality == true {
|
|
||||||
args.append("-G")
|
|
||||||
}
|
|
||||||
if let a = config.ofpsAdaptation {
|
|
||||||
args.append(contentsOf: ["-A", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), a)])
|
|
||||||
}
|
|
||||||
if let algFlag = config.fullSpreadAlgorithm?.flag {
|
if let algFlag = config.fullSpreadAlgorithm?.flag {
|
||||||
args.append(algFlag)
|
args.append(algFlag)
|
||||||
}
|
}
|
||||||
@@ -91,11 +85,9 @@ public enum TargenArgs {
|
|||||||
}
|
}
|
||||||
args.append(contentsOf: ["-l", "\(inkLimit)"])
|
args.append(contentsOf: ["-l", "\(inkLimit)"])
|
||||||
}
|
}
|
||||||
if let v = config.darkEmphasis, abs(v - 1.0) >= 0.001 {
|
args.append(contentsOf: ArgsBuilder.optionUnlessApprox("-V", config.darkEmphasis, skip: 1.0))
|
||||||
args.append(contentsOf: ["-V", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), v)])
|
if let p = config.devicePower, p > 0 {
|
||||||
}
|
args.append(contentsOf: ArgsBuilder.optionUnlessApprox("-p", p, skip: 1.0))
|
||||||
if let p = config.devicePower, p > 0, abs(p - 1.0) >= 0.001 {
|
|
||||||
args.append(contentsOf: ["-p", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), p)])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
args.append(cleanBasename)
|
args.append(cleanBasename)
|
||||||
|
|||||||
@@ -72,8 +72,8 @@ public enum CGATSParser {
|
|||||||
public static func parse(
|
public static func parse(
|
||||||
_ contents: String,
|
_ contents: String,
|
||||||
sourceURL: URL? = nil
|
sourceURL: URL? = nil
|
||||||
) throws(CGATSParseError) -> CGATSDataset {
|
) throws -> CGATSDataset {
|
||||||
guard !contents.isEmpty else { throw .emptyFile }
|
guard !contents.isEmpty else { throw CGATSParseError.emptyFile }
|
||||||
|
|
||||||
let ext = sourceURL?.pathExtension.lowercased() ?? ""
|
let ext = sourceURL?.pathExtension.lowercased() ?? ""
|
||||||
let isCSV = ext == "csv" || contents.trimmingCharacters(in: .whitespacesAndNewlines)
|
let isCSV = ext == "csv" || contents.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
@@ -101,10 +101,10 @@ public enum CGATSParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard let formatStart, let formatEnd, formatEnd > formatStart + 1 else {
|
guard let formatStart, let formatEnd, formatEnd > formatStart + 1 else {
|
||||||
throw .missingBeginDataFormat
|
throw CGATSParseError.missingBeginDataFormat
|
||||||
}
|
}
|
||||||
guard let dataStart, let dataEnd, dataEnd > dataStart + 1 else {
|
guard let dataStart, let dataEnd, dataEnd > dataStart + 1 else {
|
||||||
throw .missingBeginData
|
throw CGATSParseError.missingBeginData
|
||||||
}
|
}
|
||||||
|
|
||||||
let rawFieldNames = splitFields(lines[formatStart + 1])
|
let rawFieldNames = splitFields(lines[formatStart + 1])
|
||||||
@@ -139,7 +139,7 @@ public enum CGATSParser {
|
|||||||
let lineIndex = dataStart + offset
|
let lineIndex = dataStart + offset
|
||||||
let rawRow = splitFields(lines[lineIndex])
|
let rawRow = splitFields(lines[lineIndex])
|
||||||
guard rawRow.count == fieldNames.count else {
|
guard rawRow.count == fieldNames.count else {
|
||||||
throw .incorrectArity(line: lineIndex + 1, expected: fieldNames.count, got: rawRow.count)
|
throw CGATSParseError.incorrectArity(line: lineIndex + 1, expected: fieldNames.count, got: rawRow.count)
|
||||||
}
|
}
|
||||||
|
|
||||||
var sample = RawSample(id: String(offset), lineIndex: lineIndex)
|
var sample = RawSample(id: String(offset), lineIndex: lineIndex)
|
||||||
@@ -153,7 +153,7 @@ public enum CGATSParser {
|
|||||||
groupMax[group, default: 0] = max(groupMax[group, default: 0], number)
|
groupMax[group, default: 0] = max(groupMax[group, default: 0], number)
|
||||||
}
|
}
|
||||||
} else if !cleaned.isEmpty {
|
} else if !cleaned.isEmpty {
|
||||||
throw .nonNumericValue(field: name, value: raw, line: lineIndex + 1)
|
throw CGATSParseError.nonNumericValue(field: name, value: raw, line: lineIndex + 1)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
sample.strings[name] = raw
|
sample.strings[name] = raw
|
||||||
@@ -213,7 +213,7 @@ public enum CGATSParser {
|
|||||||
private static func preprocess(
|
private static func preprocess(
|
||||||
_ contents: String,
|
_ contents: String,
|
||||||
isCSV: Bool
|
isCSV: Bool
|
||||||
) throws(CGATSParseError) -> (CGATSFormat, [String]) {
|
) throws -> (CGATSFormat, [String]) {
|
||||||
let allLines = contents.components(separatedBy: .newlines)
|
let allLines = contents.components(separatedBy: .newlines)
|
||||||
var lines = [String]()
|
var lines = [String]()
|
||||||
|
|
||||||
@@ -239,7 +239,7 @@ public enum CGATSParser {
|
|||||||
lines.append(line)
|
lines.append(line)
|
||||||
}
|
}
|
||||||
|
|
||||||
guard !lines.isEmpty else { throw .emptyFile }
|
guard !lines.isEmpty else { throw CGATSParseError.emptyFile }
|
||||||
|
|
||||||
// Wrap a bare CSV / ISO28178 file in the canonical CGATS block
|
// Wrap a bare CSV / ISO28178 file in the canonical CGATS block
|
||||||
// structure so the boundary-based parser below can handle it.
|
// structure so the boundary-based parser below can handle it.
|
||||||
|
|||||||
@@ -20,8 +20,14 @@ public enum ArtefactFiles {
|
|||||||
bundle: Bundle = .main
|
bundle: Bundle = .main
|
||||||
) -> (version: String, build: String, buildDate: String) {
|
) -> (version: String, build: String, buildDate: String) {
|
||||||
let info = bundle.infoDictionary ?? [:]
|
let info = bundle.infoDictionary ?? [:]
|
||||||
let version = info["CFBundleShortVersionString"] as? String ?? "0.0.0"
|
let short = info["CFBundleShortVersionString"] as? String ?? "0.0.0"
|
||||||
let build = info["CFBundleVersion"] as? String ?? "0"
|
let build = info["CFBundleVersion"] as? String ?? "0"
|
||||||
|
var tag = ""
|
||||||
|
if let url = bundle.url(forResource: "ICCeryReleaseTag", withExtension: nil),
|
||||||
|
let raw = try? String(contentsOf: url, encoding: .utf8) {
|
||||||
|
tag = raw
|
||||||
|
}
|
||||||
|
let version = displayVersion(shortVersion: short, releaseTag: tag)
|
||||||
|
|
||||||
let url = bundle.executableURL ?? bundle.bundleURL
|
let url = bundle.executableURL ?? bundle.bundleURL
|
||||||
let buildDate: String
|
let buildDate: String
|
||||||
@@ -37,4 +43,15 @@ public enum ArtefactFiles {
|
|||||||
|
|
||||||
return (version, build, buildDate)
|
return (version, build, buildDate)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// About "Version:" string from the release tag + marketing version
|
||||||
|
/// (#189): `v2.0.0-pre2 (2.0.0)`; dedupes to `v2.0.0` on an exact
|
||||||
|
/// release tag; falls back to the short version when untagged.
|
||||||
|
static func displayVersion(shortVersion: String, releaseTag: String) -> String {
|
||||||
|
let tag = releaseTag.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if tag.isEmpty || tag == shortVersion || tag == "v\(shortVersion)" {
|
||||||
|
return tag.isEmpty ? shortVersion : tag
|
||||||
|
}
|
||||||
|
return "\(tag) (\(shortVersion))"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,14 +79,17 @@ public enum ArtefactProbe {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve an explicit profile URL, flipping `.icc` ↔ `.icm` when the
|
/// Resolve an explicit profile URL, flipping `.icc` ↔ `.icm` when the
|
||||||
/// requested path is missing (#69 / issue #83).
|
/// requested path is missing (#69 / issue #83). Any other extension
|
||||||
|
/// (`.mpp`, `.txt`, …) is returned unchanged — never rewritten.
|
||||||
public static func resolveProfile(
|
public static func resolveProfile(
|
||||||
_ url: URL,
|
_ url: URL,
|
||||||
fileManager: FileManager = .default
|
fileManager: FileManager = .default
|
||||||
) -> URL {
|
) -> URL {
|
||||||
if fileManager.fileExists(atPath: url.path) { return url }
|
if fileManager.fileExists(atPath: url.path) { return url }
|
||||||
let altExt = url.pathExtension.lowercased() == "icc" ? "icm" : "icc"
|
let ext = url.pathExtension.lowercased()
|
||||||
let alt = url.deletingPathExtension().appendingPathExtension(altExt)
|
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
|
return fileManager.fileExists(atPath: alt.path) ? alt : url
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,10 +27,7 @@ public struct JSONFileStore<T: Codable & Sendable>: Sendable {
|
|||||||
self.fileURL = fileURL
|
self.fileURL = fileURL
|
||||||
self.corrupt = corrupt
|
self.corrupt = corrupt
|
||||||
self.defaultValue = defaultValue
|
self.defaultValue = defaultValue
|
||||||
let encoder = JSONEncoder()
|
self.encoder = JSONEncoder.icceryPretty(dateEncoding: dateEncoding)
|
||||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
|
||||||
encoder.dateEncodingStrategy = dateEncoding
|
|
||||||
self.encoder = encoder
|
|
||||||
let decoder = JSONDecoder()
|
let decoder = JSONDecoder()
|
||||||
decoder.dateDecodingStrategy = dateDecoding
|
decoder.dateDecodingStrategy = dateDecoding
|
||||||
self.decoder = decoder
|
self.decoder = decoder
|
||||||
@@ -75,10 +72,14 @@ public struct JSONFileStore<T: Codable & Sendable>: Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension JSONEncoder {
|
extension JSONEncoder {
|
||||||
/// Pretty-printed, sorted-keys encoder used by preset export.
|
/// Shared pretty-printed, sorted-keys encoder used by `JSONFileStore`
|
||||||
public static func icceryPretty() -> JSONEncoder {
|
/// and preset export.
|
||||||
|
static func icceryPretty(
|
||||||
|
dateEncoding: JSONEncoder.DateEncodingStrategy = .deferredToDate
|
||||||
|
) -> JSONEncoder {
|
||||||
let encoder = JSONEncoder()
|
let encoder = JSONEncoder()
|
||||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
encoder.dateEncodingStrategy = dateEncoding
|
||||||
return encoder
|
return encoder
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Approximate sRGB → CIELab D50 conversion for the gamut inspect panel
|
||||||
|
/// (issue #147).
|
||||||
|
///
|
||||||
|
/// This is a fixed-matrix helper, **not** a colour-management module: it
|
||||||
|
/// never touches ICC profiles, ColorSync, or lcms. The UI labels its
|
||||||
|
/// output "approx. Lab, not ColorSync".
|
||||||
|
public enum ApproximateLab {
|
||||||
|
|
||||||
|
/// linear-sRGB → XYZ (D65) matrix, IEC 61966-2-1.
|
||||||
|
private static let srgbToXYZ: [[Double]] = [
|
||||||
|
[0.4124, 0.3576, 0.1805],
|
||||||
|
[0.2126, 0.7152, 0.0722],
|
||||||
|
[0.0193, 0.1192, 0.9505],
|
||||||
|
]
|
||||||
|
|
||||||
|
/// 8-bit sRGB triple → Lab D50 (approximate).
|
||||||
|
public static func srgb8ToLab(r: Int, g: Int, b: Int) -> LabColor {
|
||||||
|
srgbToLab(DisplayRGB(
|
||||||
|
r: Double(r) / 255.0,
|
||||||
|
g: Double(g) / 255.0,
|
||||||
|
b: Double(b) / 255.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 } }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import Foundation
|
||||||
|
import simd
|
||||||
|
|
||||||
|
/// Result of a point-in-gamut test (issue #147).
|
||||||
|
public enum GamutContainment: String, Sendable, Equatable {
|
||||||
|
/// The point lies inside the mesh volume.
|
||||||
|
case inside
|
||||||
|
/// The point lies outside the mesh volume.
|
||||||
|
case outside
|
||||||
|
/// The mesh has no faces to test against.
|
||||||
|
case unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Point-in-mesh containment and volume estimation for ``GamutMesh``.
|
||||||
|
///
|
||||||
|
/// Both tests run on the scene-space positions stored on
|
||||||
|
/// ``GamutVertex/position`` (`x = a*`, `y = L*`, `z = b*`), the same
|
||||||
|
/// mapping the SceneKit viewer uses. No colour management is involved.
|
||||||
|
public enum GamutGeometry {
|
||||||
|
|
||||||
|
/// Whether `lab` is inside `mesh`.
|
||||||
|
///
|
||||||
|
/// Ray-casts through the face list: an odd crossing count means the
|
||||||
|
/// point is inside a closed surface. A ray that grazes a vertex or
|
||||||
|
/// edge gives an ambiguous count, so the test retries with off-axis
|
||||||
|
/// directions before answering. Meshes without faces report
|
||||||
|
/// ``GamutContainment/unknown``.
|
||||||
|
public static func containment(of lab: LabColor, in mesh: GamutMesh) -> GamutContainment {
|
||||||
|
guard !mesh.faces.isEmpty else { return .unknown }
|
||||||
|
let origin = SIMD3<Double>(lab.a, lab.l, lab.b)
|
||||||
|
for direction in rayDirections {
|
||||||
|
if let inside = castRay(from: origin, direction: direction, mesh: mesh) {
|
||||||
|
return inside ? .inside : .outside
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return .unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Approximate mesh volume in Lab-cubic units.
|
||||||
|
///
|
||||||
|
/// Sums signed tetrahedra from the vertex centroid to each face; for
|
||||||
|
/// a closed surface the magnitude equals the enclosed volume
|
||||||
|
/// regardless of face winding. Returns 0 for empty or face-less
|
||||||
|
/// meshes.
|
||||||
|
public static func volume(of mesh: GamutMesh) -> Double {
|
||||||
|
guard !mesh.faces.isEmpty, !mesh.vertices.isEmpty else { return 0 }
|
||||||
|
var centroid = SIMD3<Double>.zero
|
||||||
|
for vertex in mesh.vertices {
|
||||||
|
centroid += SIMD3<Double>(vertex.position)
|
||||||
|
}
|
||||||
|
centroid /= Double(mesh.vertices.count)
|
||||||
|
|
||||||
|
var sum = 0.0
|
||||||
|
let count = mesh.vertices.count
|
||||||
|
for face in mesh.faces {
|
||||||
|
guard Int(face.a) < count, Int(face.b) < count, Int(face.c) < count else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let a = SIMD3<Double>(mesh.vertices[Int(face.a)].position) - centroid
|
||||||
|
let b = SIMD3<Double>(mesh.vertices[Int(face.b)].position) - centroid
|
||||||
|
let c = SIMD3<Double>(mesh.vertices[Int(face.c)].position) - centroid
|
||||||
|
sum += simd_dot(a, simd_cross(b, c)) / 6.0
|
||||||
|
}
|
||||||
|
return abs(sum)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Ray casting
|
||||||
|
|
||||||
|
/// Primary +X ray, then off-axis retries for degenerate edge hits.
|
||||||
|
private static let rayDirections: [SIMD3<Double>] = [
|
||||||
|
SIMD3(1, 0, 0),
|
||||||
|
simd_normalize(SIMD3<Double>(0.71, 1.0, 0.53)),
|
||||||
|
simd_normalize(SIMD3<Double>(0.53, 0.71, 1.0)),
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Mö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<Double>,
|
||||||
|
direction dir: SIMD3<Double>,
|
||||||
|
mesh: GamutMesh
|
||||||
|
) -> Bool? {
|
||||||
|
let epsilon = 1e-9
|
||||||
|
var crossings = 0
|
||||||
|
let count = mesh.vertices.count
|
||||||
|
for face in mesh.faces {
|
||||||
|
guard Int(face.a) < count, Int(face.b) < count, Int(face.c) < count else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let va = SIMD3<Double>(mesh.vertices[Int(face.a)].position)
|
||||||
|
let vb = SIMD3<Double>(mesh.vertices[Int(face.b)].position)
|
||||||
|
let vc = SIMD3<Double>(mesh.vertices[Int(face.c)].position)
|
||||||
|
|
||||||
|
let e1 = vb - va
|
||||||
|
let e2 = vc - va
|
||||||
|
let p = simd_cross(dir, e2)
|
||||||
|
let det = simd_dot(e1, p)
|
||||||
|
if abs(det) < 1e-12 { continue } // ray parallel to face
|
||||||
|
let inv = 1.0 / det
|
||||||
|
let tvec = origin - va
|
||||||
|
let u = simd_dot(tvec, p) * inv
|
||||||
|
let q = simd_cross(tvec, e1)
|
||||||
|
let v = simd_dot(dir, q) * inv
|
||||||
|
let t = simd_dot(e2, q) * inv
|
||||||
|
|
||||||
|
guard t > epsilon else { continue }
|
||||||
|
if u < -epsilon || v < -epsilon || u + v > 1 + epsilon { continue }
|
||||||
|
// Crossing on an edge or vertex — parity is ambiguous.
|
||||||
|
if u < epsilon || v < epsilon || u + v > 1 - epsilon { return nil }
|
||||||
|
crossings += 1
|
||||||
|
}
|
||||||
|
return crossings % 2 == 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A parsed gamut mesh plus the display metadata the compare viewer
|
||||||
|
/// needs (issue #147).
|
||||||
|
///
|
||||||
|
/// `displayName` is user-derived (a file name); views must render it
|
||||||
|
/// through `Text` only (#114).
|
||||||
|
public struct NamedGamut: Sendable, Equatable, Identifiable {
|
||||||
|
|
||||||
|
/// What the layer is used for in the compare UI.
|
||||||
|
public enum Role: String, Sendable, Equatable {
|
||||||
|
/// Bundled reference space (sRGB). Cannot be removed, only hidden.
|
||||||
|
case reference
|
||||||
|
/// The workflow's own profile gamut.
|
||||||
|
case profileA
|
||||||
|
/// The user-added compare gamut. Replaced, never stacked.
|
||||||
|
case profileB
|
||||||
|
}
|
||||||
|
|
||||||
|
public var id: String
|
||||||
|
public var displayName: String
|
||||||
|
public var role: Role
|
||||||
|
public var mesh: GamutMesh
|
||||||
|
public var sourceURL: URL
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: String,
|
||||||
|
displayName: String,
|
||||||
|
role: Role,
|
||||||
|
mesh: GamutMesh,
|
||||||
|
sourceURL: URL
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.displayName = displayName
|
||||||
|
self.role = role
|
||||||
|
self.mesh = mesh
|
||||||
|
self.sourceURL = sourceURL
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Persistence for `MediaRecipe` entries (issue #146).
|
||||||
|
///
|
||||||
|
/// `media_library.json` is a sibling of `settings.json`, never a field
|
||||||
|
/// inside it. Writes are atomic via `JSONFileStore` → `AtomicFileWriter`
|
||||||
|
/// (`.tmp` + rename, #213). A corrupt file throws on load/upsert and is
|
||||||
|
/// never overwritten — the view model turns the throw into an empty
|
||||||
|
/// list plus a persistent warning banner.
|
||||||
|
public actor MediaLibraryStore {
|
||||||
|
|
||||||
|
/// Default cap.
|
||||||
|
public static let defaultCapacity = 200
|
||||||
|
|
||||||
|
/// Path to the JSON store.
|
||||||
|
public let url: URL
|
||||||
|
|
||||||
|
/// In-memory cache, kept in sync with disk.
|
||||||
|
private var recipes: [MediaRecipe] = []
|
||||||
|
|
||||||
|
/// Explicit load flag — an empty file is still "loaded".
|
||||||
|
private var loaded = false
|
||||||
|
|
||||||
|
private let capacity: Int
|
||||||
|
private let fileStore: JSONFileStore<[MediaRecipe]>
|
||||||
|
|
||||||
|
public init(
|
||||||
|
url: URL = AppPaths.appDataDir.appendingPathComponent("media_library.json"),
|
||||||
|
capacity: Int = defaultCapacity
|
||||||
|
) {
|
||||||
|
self.url = url
|
||||||
|
self.capacity = capacity
|
||||||
|
self.fileStore = JSONFileStore(
|
||||||
|
fileURL: url,
|
||||||
|
corrupt: .throwCorrupt,
|
||||||
|
defaultValue: { [] },
|
||||||
|
dateEncoding: .iso8601,
|
||||||
|
dateDecoding: .iso8601
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads recipes from disk. Returns the existing cache if already
|
||||||
|
/// loaded.
|
||||||
|
///
|
||||||
|
/// Throws when the file exists but cannot be parsed; the existing
|
||||||
|
/// file is never overwritten in that case and `loaded` stays false
|
||||||
|
/// so the next call re-reads.
|
||||||
|
public func load() throws -> [MediaRecipe] {
|
||||||
|
guard !loaded else { return recipes }
|
||||||
|
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||||
|
loaded = true
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
recipes = try fileStore.load()
|
||||||
|
loaded = true
|
||||||
|
return recipes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns all cached recipes.
|
||||||
|
public func all() -> [MediaRecipe] {
|
||||||
|
recipes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inserts or replaces a recipe matched by `id`, then writes
|
||||||
|
/// atomically. Replacement preserves `created` and bumps `updated`;
|
||||||
|
/// inserts beyond `capacity` throw `.capacityReached` — no silent
|
||||||
|
/// eviction.
|
||||||
|
///
|
||||||
|
/// Loads the existing library first and propagates any load error
|
||||||
|
/// so an unparseable file is never overwritten.
|
||||||
|
@discardableResult
|
||||||
|
public func upsert(_ recipe: MediaRecipe) throws -> [MediaRecipe] {
|
||||||
|
let validated = try recipe.validated()
|
||||||
|
try load()
|
||||||
|
|
||||||
|
var updated = recipes
|
||||||
|
if let index = updated.firstIndex(where: { $0.id == validated.id }) {
|
||||||
|
var existing = validated
|
||||||
|
existing.created = updated[index].created
|
||||||
|
existing.updated = Date()
|
||||||
|
updated[index] = existing
|
||||||
|
} else {
|
||||||
|
guard updated.count < capacity else {
|
||||||
|
throw MediaLibraryError.capacityReached(capacity)
|
||||||
|
}
|
||||||
|
updated.append(validated)
|
||||||
|
}
|
||||||
|
|
||||||
|
try fileStore.save(updated)
|
||||||
|
recipes = updated
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a recipe by id and writes atomically. Returns false when
|
||||||
|
/// no recipe with that id exists.
|
||||||
|
@discardableResult
|
||||||
|
public func delete(id: String) throws -> Bool {
|
||||||
|
try load()
|
||||||
|
let before = recipes.count
|
||||||
|
let updated = recipes.filter { $0.id != id }
|
||||||
|
guard updated.count != before else { return false }
|
||||||
|
try fileStore.save(updated)
|
||||||
|
recipes = updated
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum MediaLibraryError: LocalizedError, Equatable {
|
||||||
|
case capacityReached(Int)
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .capacityReached(let cap):
|
||||||
|
return "Media library is full (\(cap)). Delete a recipe first."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A media recipe: a named binding of CUPS queue + paper + ink set +
|
||||||
|
/// optional `.cal` to a `ProfilingPreset` (issue #146, docs/22 §Media
|
||||||
|
/// library).
|
||||||
|
///
|
||||||
|
/// snake_case keys match the v1 JSON schema so `media_library.json`
|
||||||
|
/// stays import/export compatible. Identity + binding fields are
|
||||||
|
/// required; every other field is optional-defaulted. Unknown keys are
|
||||||
|
/// ignored on decode; missing required fields fail the whole array
|
||||||
|
/// decode (corrupt-file policy, never silently dropped).
|
||||||
|
public struct MediaRecipe: Codable, Equatable, Sendable, Identifiable {
|
||||||
|
|
||||||
|
/// `"recipe-<uuid>"`, never user-typed.
|
||||||
|
public var id: String
|
||||||
|
public var name: String
|
||||||
|
public var notes: String
|
||||||
|
/// CUPS queue id (`Printer.name` — `Printer` has no `id` member).
|
||||||
|
public var printerID: String
|
||||||
|
/// Human label from `Printer.displayName`.
|
||||||
|
public var printerDisplayName: String
|
||||||
|
/// Library metadata only — never written to targen `-P`/`-I` flags.
|
||||||
|
public var paperName: String
|
||||||
|
/// Last captured CUPS `media_type`, read-only.
|
||||||
|
public var driverMediaType: String?
|
||||||
|
/// Free text: `"PK"`, `"MK"`, `"Photo Black"`, …
|
||||||
|
public var inkSet: String
|
||||||
|
/// `"rgb"` | `"cmyk"` — must match the bound preset.
|
||||||
|
public var colourSpace: String
|
||||||
|
/// `ProfilingPreset.id` (built-in or custom).
|
||||||
|
public var presetID: String
|
||||||
|
/// Absolute `.cal` path stored verbatim; `nil` = none.
|
||||||
|
public var calibrationURL: String?
|
||||||
|
public var applyCalibration: Bool
|
||||||
|
public var created: Date
|
||||||
|
public var updated: Date
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
notes: String = "",
|
||||||
|
printerID: String,
|
||||||
|
printerDisplayName: String = "",
|
||||||
|
paperName: String = "",
|
||||||
|
driverMediaType: String? = nil,
|
||||||
|
inkSet: String = "",
|
||||||
|
colourSpace: String,
|
||||||
|
presetID: String,
|
||||||
|
calibrationURL: String? = nil,
|
||||||
|
applyCalibration: Bool = false,
|
||||||
|
created: Date = Date(),
|
||||||
|
updated: Date = Date()
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.notes = notes
|
||||||
|
self.printerID = printerID
|
||||||
|
self.printerDisplayName = printerDisplayName
|
||||||
|
self.paperName = paperName
|
||||||
|
self.driverMediaType = driverMediaType
|
||||||
|
self.inkSet = inkSet
|
||||||
|
self.colourSpace = colourSpace
|
||||||
|
self.presetID = presetID
|
||||||
|
self.calibrationURL = calibrationURL
|
||||||
|
self.applyCalibration = applyCalibration
|
||||||
|
self.created = created
|
||||||
|
self.updated = updated
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, name, notes
|
||||||
|
case printerID = "printer_id"
|
||||||
|
case printerDisplayName = "printer_display_name"
|
||||||
|
case paperName = "paper_name"
|
||||||
|
case driverMediaType = "driver_media_type"
|
||||||
|
case inkSet = "ink_set"
|
||||||
|
case colourSpace = "colour_space"
|
||||||
|
case presetID = "preset_id"
|
||||||
|
case calibrationURL = "calibration_url"
|
||||||
|
case applyCalibration = "apply_calibration"
|
||||||
|
case created, updated
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strict decode: required identity + binding fields must be
|
||||||
|
/// present; optionals default. Unknown keys are ignored.
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try c.decode(String.self, forKey: .id)
|
||||||
|
name = try c.decode(String.self, forKey: .name)
|
||||||
|
notes = try c.decodeIfPresent(String.self, forKey: .notes) ?? ""
|
||||||
|
printerID = try c.decode(String.self, forKey: .printerID)
|
||||||
|
printerDisplayName = try c.decodeIfPresent(String.self, forKey: .printerDisplayName) ?? ""
|
||||||
|
paperName = try c.decodeIfPresent(String.self, forKey: .paperName) ?? ""
|
||||||
|
driverMediaType = try c.decodeIfPresent(String.self, forKey: .driverMediaType)
|
||||||
|
inkSet = try c.decodeIfPresent(String.self, forKey: .inkSet) ?? ""
|
||||||
|
colourSpace = try c.decode(String.self, forKey: .colourSpace)
|
||||||
|
presetID = try c.decode(String.self, forKey: .presetID)
|
||||||
|
calibrationURL = try c.decodeIfPresent(String.self, forKey: .calibrationURL)
|
||||||
|
applyCalibration = try c.decodeIfPresent(Bool.self, forKey: .applyCalibration) ?? false
|
||||||
|
created = try c.decodeIfPresent(Date.self, forKey: .created) ?? Date()
|
||||||
|
updated = try c.decodeIfPresent(Date.self, forKey: .updated) ?? Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ValidationError: LocalizedError, Equatable {
|
||||||
|
case emptyName
|
||||||
|
case emptyPrinterID
|
||||||
|
case invalidColourSpace(String)
|
||||||
|
case emptyPresetID
|
||||||
|
case invalidCalibrationURL(String)
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .emptyName: return "Media recipe is missing a name."
|
||||||
|
case .emptyPrinterID: return "Media recipe is missing a printer."
|
||||||
|
case .invalidColourSpace(let v):
|
||||||
|
return "colour_space must be \"rgb\" or \"cmyk\", got \"\(v)\"."
|
||||||
|
case .emptyPresetID: return "Media recipe is missing a preset."
|
||||||
|
case .invalidCalibrationURL(let v):
|
||||||
|
return "calibration_url must be an absolute path without \"..\" or NUL, got \"\(v)\"."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates the binding fields. `colourSpace` is normalized to
|
||||||
|
/// lowercase before comparison. `CAL_` cal names are **not**
|
||||||
|
/// rejected — that is an apply-time policy, not schema.
|
||||||
|
@discardableResult
|
||||||
|
public func validated() throws -> MediaRecipe {
|
||||||
|
var r = self
|
||||||
|
r.id = id.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
r.name = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
r.colourSpace = colourSpace.lowercased()
|
||||||
|
guard !r.name.isEmpty else { throw ValidationError.emptyName }
|
||||||
|
guard !r.printerID.isEmpty else { throw ValidationError.emptyPrinterID }
|
||||||
|
guard r.colourSpace == "rgb" || r.colourSpace == "cmyk" else {
|
||||||
|
throw ValidationError.invalidColourSpace(colourSpace)
|
||||||
|
}
|
||||||
|
guard !r.presetID.isEmpty else { throw ValidationError.emptyPresetID }
|
||||||
|
if let cal = r.calibrationURL, !cal.isEmpty {
|
||||||
|
guard cal.hasPrefix("/"), !cal.contains(".."), !cal.contains("\0") else {
|
||||||
|
throw ValidationError.invalidCalibrationURL(cal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Line classifier for `spotread` stdout (issue #148).
|
||||||
|
///
|
||||||
|
/// `spotread` shares `chartread`'s white-tile calibration phrasing, so
|
||||||
|
/// this wraps `ChartreadClassifier` and only intercepts the lines that
|
||||||
|
/// would otherwise misclassify:
|
||||||
|
///
|
||||||
|
/// - `… and then hit any key to continue,` / `or hit Esc or Q to abort:`
|
||||||
|
/// continuation lines that trail the calibration and spot prompts —
|
||||||
|
/// sticky to the current prompt state instead of `PROMPT_CONTINUE`.
|
||||||
|
/// - `Place instrument on a spot to be measured,` /
|
||||||
|
/// `and hit a key to take a reading,` → `AWAITING_STRIP` (the Read
|
||||||
|
/// prompt; the generic chartread matcher does not know "take a
|
||||||
|
/// reading").
|
||||||
|
///
|
||||||
|
/// Sample lines (`Result is XYZ: …, D50 Lab: …`) are parsed by
|
||||||
|
/// `SpotReadParser`, not classified here.
|
||||||
|
public enum SpotReadClassifier {
|
||||||
|
|
||||||
|
public static func classify(
|
||||||
|
line: String,
|
||||||
|
previousState: ChartreadState
|
||||||
|
) -> ChartreadClassifyResult {
|
||||||
|
let text = line.lowercased()
|
||||||
|
|
||||||
|
// Spot-read prompt continuations keep the current prompt state.
|
||||||
|
if previousState == .calibrating || previousState == .awaitingStrip {
|
||||||
|
if text.contains("hit any key")
|
||||||
|
|| text.contains("hit space")
|
||||||
|
|| text.contains("esc or")
|
||||||
|
|| text.contains("abort")
|
||||||
|
|| text.contains("to abort") {
|
||||||
|
return ChartreadClassifyResult(state: previousState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Place instrument on a spot to be measured," /
|
||||||
|
// " and hit a key to take a reading," — the Read trigger prompt.
|
||||||
|
if text.contains("spot to be measured")
|
||||||
|
|| text.contains("take a reading")
|
||||||
|
|| text.contains("measure the spot") {
|
||||||
|
return ChartreadClassifyResult(state: .awaitingStrip)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ChartreadClassifier.classify(line: line, previousState: previousState)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// One patch measurement emitted by a running `spotread` child (#148).
|
||||||
|
public struct SpotReadSample: Codable, Sendable, Equatable, Identifiable {
|
||||||
|
public var id: UUID
|
||||||
|
public var timestamp: Date
|
||||||
|
/// D50 Lab (always present — derived from XYZ when needed).
|
||||||
|
public var lab: LabColor
|
||||||
|
/// XYZ on the 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: <x> <y> <z>, D50 Lab: <L> <a> <b>`; a Lab-only line
|
||||||
|
/// also parses, and an XYZ-only line derives Lab via
|
||||||
|
/// `LabColorMath.xyzToLab` (D50).
|
||||||
|
public enum SpotReadParser {
|
||||||
|
|
||||||
|
public static func parse(line: String) -> (xyz: XYZColor?, lab: LabColor)? {
|
||||||
|
guard line.range(of: "result is", options: .caseInsensitive) != nil
|
||||||
|
|| line.range(of: #"\bLab\b"#, options: .regularExpression) != nil
|
||||||
|
|| line.range(of: #"\bXYZ\b"#, options: .regularExpression) != nil
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
var xyz: XYZColor?
|
||||||
|
var lab: LabColor?
|
||||||
|
|
||||||
|
if let m = triple(#"\bXYZ\b[:\s]"#, in: line) {
|
||||||
|
xyz = XYZColor(x: m.0, y: m.1, z: m.2)
|
||||||
|
}
|
||||||
|
if let m = triple(#"\bLab\b[:\s]"#, in: line) {
|
||||||
|
lab = LabColor(l: m.0, a: m.1, b: m.2)
|
||||||
|
}
|
||||||
|
if lab == nil, let xyz {
|
||||||
|
lab = LabColorMath.xyzToLab(xyz)
|
||||||
|
}
|
||||||
|
guard let lab else { return nil }
|
||||||
|
return (xyz, lab)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func triple(_ marker: String, in line: String) -> (Double, Double, Double)? {
|
||||||
|
let pattern = marker + #"\s*(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)"#
|
||||||
|
guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive),
|
||||||
|
let match = regex.firstMatch(
|
||||||
|
in: line, options: [], range: NSRange(line.startIndex..., in: line)),
|
||||||
|
match.numberOfRanges == 4,
|
||||||
|
let r1 = Range(match.range(at: 1), in: line),
|
||||||
|
let r2 = Range(match.range(at: 2), in: line),
|
||||||
|
let r3 = Range(match.range(at: 3), in: line),
|
||||||
|
let a = Double(line[r1]),
|
||||||
|
let b = Double(line[r2]),
|
||||||
|
let c = Double(line[r3])
|
||||||
|
else { return nil }
|
||||||
|
return (a, b, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,8 +25,9 @@ public enum CupsOptionsFilter {
|
|||||||
"EPIJ_OSColMat", "ColorCorrection", "StpColorCorrection",
|
"EPIJ_OSColMat", "ColorCorrection", "StpColorCorrection",
|
||||||
"EpsonColorMode", "ColorModel",
|
"EpsonColorMode", "ColorModel",
|
||||||
// Quality
|
// Quality
|
||||||
"Resolution", "cupsPrintQuality", "Quality", "EPIJ_Quality",
|
"Resolution", "cupsPrintQuality", "Quality", "EPIJ_Qual",
|
||||||
"CNIJQuality", "StpQuality", "OutputMode",
|
"EPIJ_Quality", "CNIJQuality", "CNIJPrintQuality",
|
||||||
|
"PrintQuality", "StpQuality", "OutputMode",
|
||||||
// Duplex
|
// Duplex
|
||||||
"Duplex", "sides",
|
"Duplex", "sides",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -174,8 +174,13 @@ public enum CupsParsers {
|
|||||||
|
|
||||||
/// PPD `*<key> <id>/<Human Label>:` lines → `id → label` map.
|
/// PPD `*<key> <id>/<Human Label>:` lines → `id → label` map.
|
||||||
/// Language-qualified forms (`*en_US.<key> id/Label:`) also match.
|
/// Language-qualified forms (`*en_US.<key> id/Label:`) also match.
|
||||||
|
/// Precedence is deterministic, not positional (#181): unqualified
|
||||||
|
/// `*Key` > `en_US.` > `en.` > first-qualified-seen, so a trailing
|
||||||
|
/// locale block (Canon `th.CNIJMediaType`) can never overwrite the
|
||||||
|
/// base English labels — and a qualified-only id still gets its
|
||||||
|
/// first-seen qualified label (R9).
|
||||||
public static func ppdChoiceLabels(_ ppd: String, key: String) -> [String: String] {
|
public static func ppdChoiceLabels(_ ppd: String, key: String) -> [String: String] {
|
||||||
var map: [String: String] = [:]
|
var hits: [String: [(qualifier: String?, label: String)]] = [:]
|
||||||
for rawLine in ppd.split(separator: "\n") {
|
for rawLine in ppd.split(separator: "\n") {
|
||||||
var line = rawLine.trimmingCharacters(in: .whitespaces)
|
var line = rawLine.trimmingCharacters(in: .whitespaces)
|
||||||
guard line.hasPrefix("*"), !line.hasPrefix("**") else { continue }
|
guard line.hasPrefix("*"), !line.hasPrefix("**") else { continue }
|
||||||
@@ -183,7 +188,9 @@ public enum CupsParsers {
|
|||||||
// Optional locale qualifier: `en_US.InputSlot` → `InputSlot`.
|
// Optional locale qualifier: `en_US.InputSlot` → `InputSlot`.
|
||||||
// Only strip when the part before the first `.` looks like
|
// Only strip when the part before the first `.` looks like
|
||||||
// a locale (short `xx`/`xx_YY`); real keys containing dots
|
// a locale (short `xx`/`xx_YY`); real keys containing dots
|
||||||
// are left alone.
|
// are left alone. The qualifier is recorded for precedence
|
||||||
|
// rather than dropped (#181).
|
||||||
|
var qualifier: String?
|
||||||
if let dot = line.firstIndex(of: ".") {
|
if let dot = line.firstIndex(of: ".") {
|
||||||
let prefix = line[..<dot]
|
let prefix = line[..<dot]
|
||||||
let looksLikeLocale = (2...5).contains(prefix.count)
|
let looksLikeLocale = (2...5).contains(prefix.count)
|
||||||
@@ -191,6 +198,7 @@ public enum CupsParsers {
|
|||||||
&& (prefix.count == 2 || prefix.contains("_"))
|
&& (prefix.count == 2 || prefix.contains("_"))
|
||||||
let candidate = line[line.index(after: dot)...]
|
let candidate = line[line.index(after: dot)...]
|
||||||
if looksLikeLocale && candidate.hasPrefix(key) {
|
if looksLikeLocale && candidate.hasPrefix(key) {
|
||||||
|
qualifier = prefix.lowercased()
|
||||||
line = String(candidate)
|
line = String(candidate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -201,15 +209,50 @@ public enum CupsParsers {
|
|||||||
rest = String(rest[..<colon])
|
rest = String(rest[..<colon])
|
||||||
// `<id>/<Human label>` — human label after the last `/`.
|
// `<id>/<Human label>` — human label after the last `/`.
|
||||||
guard let slash = rest.firstIndex(of: "/") else { continue }
|
guard let slash = rest.firstIndex(of: "/") else { continue }
|
||||||
let id = String(rest[..<slash])
|
let id = ppdUnescape(String(rest[..<slash])
|
||||||
.trimmingCharacters(in: .whitespaces)
|
.trimmingCharacters(in: .whitespaces))
|
||||||
let human = String(rest[rest.index(after: slash)...])
|
let human = ppdUnescape(String(rest[rest.index(after: slash)...])
|
||||||
.trimmingCharacters(in: .whitespaces)
|
.trimmingCharacters(in: .whitespaces))
|
||||||
if !id.isEmpty { map[id] = human.isEmpty ? id : human }
|
guard !id.isEmpty else { continue }
|
||||||
|
hits[id, default: []].append(
|
||||||
|
(qualifier, human.isEmpty ? id : human))
|
||||||
|
}
|
||||||
|
var map: [String: String] = [:]
|
||||||
|
for (id, candidates) in hits {
|
||||||
|
// First occurrence wins inside each qualifier class, in
|
||||||
|
// file order — same as the old sequential behaviour.
|
||||||
|
map[id] = candidates.first { $0.qualifier == nil }?.label
|
||||||
|
?? candidates.first { $0.qualifier == "en_us" }?.label
|
||||||
|
?? candidates.first { $0.qualifier == "en" }?.label
|
||||||
|
?? candidates[0].label
|
||||||
}
|
}
|
||||||
return map
|
return map
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// PPD `<XX>` hex escapes → the literal byte (`<2F>` → `/`,
|
||||||
|
/// `<20>` → space). Anything that is not `<` + two hex digits +
|
||||||
|
/// `>` passes through untouched (#181).
|
||||||
|
private static func ppdUnescape(_ text: String) -> String {
|
||||||
|
var result = ""
|
||||||
|
var index = text.startIndex
|
||||||
|
while index < text.endIndex {
|
||||||
|
guard text[index] == "<",
|
||||||
|
let hexEnd = text.index(
|
||||||
|
index, offsetBy: 3, limitedBy: text.endIndex),
|
||||||
|
hexEnd < text.endIndex, text[hexEnd] == ">",
|
||||||
|
let byte = UInt8(
|
||||||
|
text[text.index(after: index)..<hexEnd], radix: 16)
|
||||||
|
else {
|
||||||
|
result.append(text[index])
|
||||||
|
index = text.index(after: index)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.append(Character(UnicodeScalar(byte)))
|
||||||
|
index = text.index(after: hexEnd)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Detection (docs/11)
|
// MARK: - Detection (docs/11)
|
||||||
|
|
||||||
/// Media-type option key in preference order — used both to read a
|
/// Media-type option key in preference order — used both to read a
|
||||||
@@ -223,13 +266,61 @@ public enum CupsParsers {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Media type from a captured `key=value key=value` options string.
|
/// Media type from a captured `key=value key=value` options string.
|
||||||
/// Prefers `MediaType`, then `EPIJ_Medi` (docs/11 §tests).
|
/// Prefers `MediaType` (docs/11 §tests), then the remaining roster
|
||||||
|
/// keys in detection order — `CNIJMediaType`, `EPIJ_Medi`,
|
||||||
|
/// `StpMediaType` (#186 capture-return).
|
||||||
public static func extractMediaType(fromOptionsString options: String) -> String? {
|
public static func extractMediaType(fromOptionsString options: String) -> String? {
|
||||||
let pairs = lpoptions(options)
|
let pairs = lpoptions(options)
|
||||||
if let v = pairs.first(where: { $0.key == "MediaType" })?.value {
|
if let v = pairs.first(where: { $0.key == "MediaType" })?.value {
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
return pairs.first(where: { $0.key == "EPIJ_Medi" })?.value
|
for key in mediaTypeKeys where key != "MediaType" {
|
||||||
|
if let v = pairs.first(where: { $0.key == key })?.value {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Print-quality option key in preference order — vendor-first,
|
||||||
|
/// weakest last (#183). `OutputMode`/`Resolution` sit last: on some
|
||||||
|
/// drivers they are colour-mode keys, not quality (#180).
|
||||||
|
/// `CNIJPrintMode2`/`CNIJPQualitySlider` are deferred (#16).
|
||||||
|
public static let qualityKeys = [
|
||||||
|
"EPIJ_Qual", "CNIJPrintQuality", "CNIJQuality",
|
||||||
|
"cupsPrintQuality", "PrintQuality", "Quality", "StpQuality",
|
||||||
|
"EPIJ_Quality", "OutputMode", "Resolution",
|
||||||
|
]
|
||||||
|
|
||||||
|
public static func detectQualityKey(optionKeys: Set<String>) -> String? {
|
||||||
|
qualityKeys.first { optionKeys.contains($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single value from a captured `key=value key=value` options
|
||||||
|
/// string — case-insensitive key match (#183 capture-return).
|
||||||
|
public static func extractOption(
|
||||||
|
named key: String,
|
||||||
|
fromOptionsString options: String
|
||||||
|
) -> String? {
|
||||||
|
lpoptions(options).first {
|
||||||
|
$0.key.caseInsensitiveCompare(key) == .orderedSame
|
||||||
|
}?.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Print-quality token from a captured options string — the queue's
|
||||||
|
/// quality key is detected from the roster before extracting (#183).
|
||||||
|
public static func extractQuality(fromOptionsString options: String) -> String? {
|
||||||
|
let pairs = lpoptions(options)
|
||||||
|
guard let key = detectQualityKey(
|
||||||
|
optionKeys: Set(pairs.map(\.key)))
|
||||||
|
else { return nil }
|
||||||
|
return pairs.first(where: { $0.key == key })?.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `orientation-requested=3|4` → `"portrait"`/`"landscape"` (#183).
|
||||||
|
public static func extractOrientation(fromOptionsString options: String) -> String? {
|
||||||
|
extractOption(named: "orientation-requested", fromOptionsString: options)
|
||||||
|
.map { $0 == "4" ? "landscape" : "portrait" }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Driver "no colour adjustment" key=value for `lpoptions -l` keys
|
/// Driver "no colour adjustment" key=value for `lpoptions -l` keys
|
||||||
|
|||||||
@@ -132,8 +132,28 @@ public struct CupsService: Sendable {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Print quality — the detected roster key's listing maps to
|
||||||
|
// `PrinterQuality` with PPD labels and the `*` default (#183).
|
||||||
|
var qualities: [PrinterQuality] = []
|
||||||
|
var qualityDefault: String?
|
||||||
|
let qualityKey = CupsParsers.detectQualityKey(
|
||||||
|
optionKeys: Set(listings.map(\.key)))
|
||||||
|
if let qualityKey,
|
||||||
|
let listing = listings.first(where: { $0.key == qualityKey }) {
|
||||||
|
let labels = ppd.map {
|
||||||
|
CupsParsers.ppdChoiceLabels($0, key: qualityKey)
|
||||||
|
} ?? [:]
|
||||||
|
qualities = listing.choices.map {
|
||||||
|
PrinterQuality(id: $0, name: labels[$0] ?? $0)
|
||||||
|
}
|
||||||
|
qualityDefault = listing.defaultChoice
|
||||||
|
}
|
||||||
|
|
||||||
return PrinterCapabilities(
|
return PrinterCapabilities(
|
||||||
trays: trays, paperSizes: sizes, mediaTypes: media)
|
trays: trays, paperSizes: sizes, mediaTypes: media,
|
||||||
|
qualityKey: qualityKey, qualities: qualities,
|
||||||
|
qualityDefault: qualityDefault)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The set of option keys a queue advertises — input to
|
/// The set of option keys a queue advertises — input to
|
||||||
@@ -167,7 +187,11 @@ public struct CupsService: Sendable {
|
|||||||
|
|
||||||
private func loadPPD(for queue: String) -> String? {
|
private func loadPPD(for queue: String) -> String? {
|
||||||
let url = ppdDir.appendingPathComponent("\(queue).ppd")
|
let url = ppdDir.appendingPathComponent("\(queue).ppd")
|
||||||
return try? String(contentsOf: url, encoding: .utf8)
|
// UTF-8 first — the Canon Thai labels are UTF-8 and a blanket
|
||||||
|
// Latin-1 read would mojibake them (#181, R10). Latin-1 only
|
||||||
|
// when UTF-8 decoding fails outright.
|
||||||
|
return (try? String(contentsOf: url, encoding: .utf8))
|
||||||
|
?? (try? String(contentsOf: url, encoding: .isoLatin1))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Spawn
|
// MARK: - Spawn
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ public enum LpArgsError: LocalizedError, Equatable {
|
|||||||
/// -o AP.ColorMatchingMode=AP_ApplicationColorMatching
|
/// -o AP.ColorMatchingMode=AP_ApplicationColorMatching
|
||||||
/// <captured cups_options>
|
/// <captured cups_options>
|
||||||
/// <media_type, if no media key already captured>
|
/// <media_type, if no media key already captured>
|
||||||
|
/// <quality, if no quality key already captured> (#183)
|
||||||
/// <driver bypass, if no bypass key captured>
|
/// <driver bypass, if no bypass key captured>
|
||||||
/// <orientation-requested=3|4, unless captured>
|
/// <orientation-requested=3|4, unless captured>
|
||||||
/// <PageSize, unless captured>
|
/// <PageSize, unless captured>
|
||||||
@@ -82,6 +83,15 @@ public enum LpArgs {
|
|||||||
argv += ["-o", "\(mediaKey)=\(mediaType)"]
|
argv += ["-o", "\(mediaKey)=\(mediaType)"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Print quality — after media, before the driver bypass; the
|
||||||
|
// detected queue key is skipped when already captured (#183).
|
||||||
|
if let quality = options.quality,
|
||||||
|
let qualityKey = CupsParsers.detectQualityKey(optionKeys: optionKeys),
|
||||||
|
!addedKeys.contains(qualityKey.lowercased()) {
|
||||||
|
addedKeys.insert(qualityKey.lowercased())
|
||||||
|
argv += ["-o", "\(qualityKey)=\(quality)"]
|
||||||
|
}
|
||||||
|
|
||||||
// Driver colour bypass — when no bypass key was captured. NOT
|
// Driver colour bypass — when no bypass key was captured. NOT
|
||||||
// gated on ppdUncorrectedPassthrough (macOS always bypasses).
|
// gated on ppdUncorrectedPassthrough (macOS always bypasses).
|
||||||
let capturedKeys = Set(
|
let capturedKeys = Set(
|
||||||
|
|||||||
@@ -66,6 +66,18 @@ public struct PrinterMediaType: Codable, Equatable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Print quality: `id` is the option token (e.g. `"303"`), `name` the
|
||||||
|
/// human label after PPD enrichment (mirrors `PrinterMediaType`, #183).
|
||||||
|
public struct PrinterQuality: 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 struct PrinterCapabilities: Codable, Equatable, Sendable {
|
||||||
public var trays: [PrinterTray]
|
public var trays: [PrinterTray]
|
||||||
public var paperSizes: [PrinterPaperSize]
|
public var paperSizes: [PrinterPaperSize]
|
||||||
@@ -73,17 +85,29 @@ public struct PrinterCapabilities: Codable, Equatable, Sendable {
|
|||||||
/// Always `true` on macOS (spec parity — CUPS honours
|
/// Always `true` on macOS (spec parity — CUPS honours
|
||||||
/// `orientation-requested`).
|
/// `orientation-requested`).
|
||||||
public var supportsOrientation: Bool
|
public var supportsOrientation: Bool
|
||||||
|
/// The queue's detected quality enumeration key
|
||||||
|
/// (`CupsParsers.detectQualityKey`), e.g. `EPIJ_Qual` (#183).
|
||||||
|
public var qualityKey: String?
|
||||||
|
public var qualities: [PrinterQuality]
|
||||||
|
/// The `*`-marked default choice from `lpoptions -l`, if any.
|
||||||
|
public var qualityDefault: String?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
trays: [PrinterTray] = [],
|
trays: [PrinterTray] = [],
|
||||||
paperSizes: [PrinterPaperSize] = [],
|
paperSizes: [PrinterPaperSize] = [],
|
||||||
mediaTypes: [PrinterMediaType] = [],
|
mediaTypes: [PrinterMediaType] = [],
|
||||||
supportsOrientation: Bool = true
|
supportsOrientation: Bool = true,
|
||||||
|
qualityKey: String? = nil,
|
||||||
|
qualities: [PrinterQuality] = [],
|
||||||
|
qualityDefault: String? = nil
|
||||||
) {
|
) {
|
||||||
self.trays = trays
|
self.trays = trays
|
||||||
self.paperSizes = paperSizes
|
self.paperSizes = paperSizes
|
||||||
self.mediaTypes = mediaTypes
|
self.mediaTypes = mediaTypes
|
||||||
self.supportsOrientation = supportsOrientation
|
self.supportsOrientation = supportsOrientation
|
||||||
|
self.qualityKey = qualityKey
|
||||||
|
self.qualities = qualities
|
||||||
|
self.qualityDefault = qualityDefault
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,9 +120,12 @@ public struct PrintOptions: Codable, Equatable, Sendable {
|
|||||||
public var paperSource: Int?
|
public var paperSource: Int?
|
||||||
/// `"portrait"` / `"landscape"` → `orientation-requested=3|4`.
|
/// `"portrait"` / `"landscape"` → `orientation-requested=3|4`.
|
||||||
public var orientation: String?
|
public var orientation: String?
|
||||||
/// printtarg layout page size → `PageSize=` (skipped if captured).
|
/// Stage 2 paper token → `PageSize=` (skipped if captured, #183).
|
||||||
public var paperSize: String?
|
public var paperSize: String?
|
||||||
public var mediaType: String?
|
public var mediaType: String?
|
||||||
|
/// Print-quality token → `-o <detectedQualityKey>=` (skipped if
|
||||||
|
/// captured, #183).
|
||||||
|
public var quality: String?
|
||||||
public var ppdUncorrectedPassthrough: Bool?
|
public var ppdUncorrectedPassthrough: Bool?
|
||||||
/// Space-separated `key=value` captured from
|
/// Space-separated `key=value` captured from
|
||||||
/// `PMPrintSettingsToOptions` and filtered (docs/11 layer ⑥).
|
/// `PMPrintSettingsToOptions` and filtered (docs/11 layer ⑥).
|
||||||
@@ -109,6 +136,7 @@ public struct PrintOptions: Codable, Equatable, Sendable {
|
|||||||
orientation: String? = nil,
|
orientation: String? = nil,
|
||||||
paperSize: String? = nil,
|
paperSize: String? = nil,
|
||||||
mediaType: String? = nil,
|
mediaType: String? = nil,
|
||||||
|
quality: String? = nil,
|
||||||
ppdUncorrectedPassthrough: Bool? = nil,
|
ppdUncorrectedPassthrough: Bool? = nil,
|
||||||
cupsOptions: String? = nil
|
cupsOptions: String? = nil
|
||||||
) {
|
) {
|
||||||
@@ -116,6 +144,7 @@ public struct PrintOptions: Codable, Equatable, Sendable {
|
|||||||
self.orientation = orientation
|
self.orientation = orientation
|
||||||
self.paperSize = paperSize
|
self.paperSize = paperSize
|
||||||
self.mediaType = mediaType
|
self.mediaType = mediaType
|
||||||
|
self.quality = quality
|
||||||
self.ppdUncorrectedPassthrough = ppdUncorrectedPassthrough
|
self.ppdUncorrectedPassthrough = ppdUncorrectedPassthrough
|
||||||
self.cupsOptions = cupsOptions
|
self.cupsOptions = cupsOptions
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import Foundation
|
|||||||
/// filter events on `id` — historical bug #56 was an id mismatch.
|
/// filter events on `id` — historical bug #56 was an id mismatch.
|
||||||
public enum ProcessID {
|
public enum ProcessID {
|
||||||
public static let instlist = "instlist"
|
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 targen(_ basename: String) -> String { "targen_\(basename)" }
|
||||||
public static func printtarg(_ basename: String) -> String { "printtarg_\(basename)" }
|
public static func printtarg(_ basename: String) -> String { "printtarg_\(basename)" }
|
||||||
|
|||||||
@@ -145,9 +145,7 @@ public actor ProcessManager {
|
|||||||
)
|
)
|
||||||
let process = prepared.process
|
let process = prepared.process
|
||||||
|
|
||||||
AppLogger(category: "process").debug(
|
logSpawn(id: id, binary: binary, arguments: arguments, captured: false)
|
||||||
"spawn \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))"
|
|
||||||
)
|
|
||||||
|
|
||||||
children[id] = RunningChild(
|
children[id] = RunningChild(
|
||||||
process: process,
|
process: process,
|
||||||
@@ -169,7 +167,7 @@ public actor ProcessManager {
|
|||||||
Task { await self.ingestOutput(data, id: id, isStderr: true, handle: handle) }
|
Task { await self.ingestOutput(data, id: id, isStderr: true, handle: handle) }
|
||||||
}
|
}
|
||||||
|
|
||||||
attachExitWatchdog(process) { [weak self] code in
|
attachTerminationHandler(process) { [weak self] code in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
Task { await self.didTerminate(id: id, code: code) }
|
Task { await self.didTerminate(id: id, code: code) }
|
||||||
}
|
}
|
||||||
@@ -182,6 +180,10 @@ public actor ProcessManager {
|
|||||||
emit(.error(id: id, message: error.localizedDescription))
|
emit(.error(id: id, message: error.localizedDescription))
|
||||||
throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)")
|
throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
|
startWaitUntilExitWatchdog(process) { [weak self] code in
|
||||||
|
guard let self else { return }
|
||||||
|
Task { await self.didTerminate(id: id, code: code) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Spawn (captured)
|
// MARK: - Spawn (captured)
|
||||||
@@ -210,9 +212,7 @@ public actor ProcessManager {
|
|||||||
let stdoutPipe = prepared.stdoutPipe
|
let stdoutPipe = prepared.stdoutPipe
|
||||||
let stderrPipe = prepared.stderrPipe
|
let stderrPipe = prepared.stderrPipe
|
||||||
|
|
||||||
AppLogger(category: "process").debug(
|
logSpawn(id: id, binary: binary, arguments: arguments, captured: true)
|
||||||
"spawn(captured) \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Register and set up the termination hand-off before run() so
|
// Register and set up the termination hand-off before run() so
|
||||||
// a very fast exit is never missed (#50, #52).
|
// a very fast exit is never missed (#50, #52).
|
||||||
@@ -265,7 +265,7 @@ public actor ProcessManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let box = Box()
|
let box = Box()
|
||||||
attachExitWatchdog(capturedProcess) { status in
|
attachTerminationHandler(capturedProcess) { status in
|
||||||
_ = box.resume(with: status)
|
_ = box.resume(with: status)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,6 +278,9 @@ public actor ProcessManager {
|
|||||||
emit(.error(id: id, message: error.localizedDescription))
|
emit(.error(id: id, message: error.localizedDescription))
|
||||||
throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)")
|
throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
|
startWaitUntilExitWatchdog(capturedProcess) { status in
|
||||||
|
_ = box.resume(with: status)
|
||||||
|
}
|
||||||
|
|
||||||
// Close the parent write ends so readDataToEndOfFile() gets EOF
|
// Close the parent write ends so readDataToEndOfFile() gets EOF
|
||||||
// as soon as the child exits; the child still has its own copies.
|
// as soon as the child exits; the child still has its own copies.
|
||||||
@@ -459,15 +462,36 @@ public actor ProcessManager {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// terminationHandler can lose a fast-exit race on a loaded host;
|
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).
|
/// `waitUntilExit` on a detached thread is the fallback (#50, #52).
|
||||||
private func attachExitWatchdog(
|
/// 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,
|
_ process: Process,
|
||||||
onExit: @escaping @Sendable (Int32) -> Void
|
onExit: @escaping @Sendable (Int32) -> Void
|
||||||
) {
|
) {
|
||||||
process.terminationHandler = { proc in
|
process.terminationHandler = { proc in
|
||||||
onExit(proc.terminationStatus)
|
onExit(proc.terminationStatus)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startWaitUntilExitWatchdog(
|
||||||
|
_ process: Process,
|
||||||
|
onExit: @escaping @Sendable (Int32) -> Void
|
||||||
|
) {
|
||||||
Task.detached { [process] in
|
Task.detached { [process] in
|
||||||
process.waitUntilExit()
|
process.waitUntilExit()
|
||||||
onExit(process.terminationStatus)
|
onExit(process.terminationStatus)
|
||||||
@@ -535,7 +559,7 @@ public actor ProcessManager {
|
|||||||
// Start a watchdog in case the `readabilityHandler` EOFs never
|
// Start a watchdog in case the `readabilityHandler` EOFs never
|
||||||
// arrive after the process exits (e.g. a hung pipe).
|
// arrive after the process exits (e.g. a hung pipe).
|
||||||
child.finalizeTask = Task { [weak self] in
|
child.finalizeTask = Task { [weak self] in
|
||||||
try? await Task.sleep(for: .seconds(2))
|
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
await self.forceKill(id: id)
|
await self.forceKill(id: id)
|
||||||
await self.forceFinalize(id: id)
|
await self.forceFinalize(id: id)
|
||||||
|
|||||||
@@ -33,16 +33,16 @@ public struct CalibrationIdentity: Equatable, Sendable {
|
|||||||
|
|
||||||
/// Derive identity from the live wizard basename and the persisted
|
/// Derive identity from the live wizard basename and the persisted
|
||||||
/// original. A non-empty persisted original wins over a `CAL_` live
|
/// original. A non-empty persisted original wins over a `CAL_` live
|
||||||
/// name (Force Quit mid-calibration).
|
/// 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 {
|
public static func parse(liveBasename: String, persistedOriginal: String) -> CalibrationIdentity {
|
||||||
if liveBasename.isEmpty && persistedOriginal.isEmpty {
|
guard !liveBasename.isEmpty else {
|
||||||
return CalibrationIdentity(originalBasename: "", calibrationBasename: "")
|
return CalibrationIdentity(originalBasename: "", calibrationBasename: "")
|
||||||
}
|
}
|
||||||
let original: String
|
let original: String
|
||||||
if liveBasename.hasPrefix("CAL_") {
|
if liveBasename.hasPrefix("CAL_") {
|
||||||
original = persistedOriginal.isEmpty ? strip(liveBasename) : persistedOriginal
|
original = persistedOriginal.isEmpty ? strip(liveBasename) : persistedOriginal
|
||||||
} else if liveBasename.isEmpty {
|
|
||||||
original = persistedOriginal
|
|
||||||
} else {
|
} else {
|
||||||
original = liveBasename
|
original = liveBasename
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,16 +79,9 @@ public enum PrintcalArgs {
|
|||||||
|
|
||||||
var args: [String] = ["-v", "-e"]
|
var args: [String] = ["-v", "-e"]
|
||||||
|
|
||||||
if config.noInkLimit {
|
args.append(contentsOf: ArgsBuilder.flag("-I", when: config.noInkLimit))
|
||||||
args.append("-I")
|
args.append(contentsOf: ArgsBuilder.flag("-z", when: config.verify))
|
||||||
}
|
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-a", config.previousCalPath))
|
||||||
if config.verify {
|
|
||||||
args.append("-z")
|
|
||||||
}
|
|
||||||
if let previous = config.previousCalPath?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
||||||
!previous.isEmpty {
|
|
||||||
args.append(contentsOf: ["-a", previous])
|
|
||||||
}
|
|
||||||
if let tac = config.totalInkLimit, tac > 0 {
|
if let tac = config.totalInkLimit, tac > 0 {
|
||||||
args.append(contentsOf: ["-m", String(format: "%.1f", tac)])
|
args.append(contentsOf: ["-m", String(format: "%.1f", tac)])
|
||||||
} else if let tac = config.totalInkLimit {
|
} else if let tac = config.totalInkLimit {
|
||||||
|
|||||||
@@ -73,7 +73,11 @@ public actor VerificationHistoryStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Removes all history and updates disk.
|
/// Removes all history and updates disk.
|
||||||
|
///
|
||||||
|
/// Loads the existing history first and propagates any load error so an
|
||||||
|
/// unparseable file is never overwritten.
|
||||||
public func clear() throws {
|
public func clear() throws {
|
||||||
|
try load()
|
||||||
try write([])
|
try write([])
|
||||||
records = []
|
records = []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ extension TargenConfig {
|
|||||||
neutralSteps: preset.neutralSteps,
|
neutralSteps: preset.neutralSteps,
|
||||||
neutralConcentration: preset.neutralConcentration,
|
neutralConcentration: preset.neutralConcentration,
|
||||||
preconditioningProfile: preset.preconditioningProfile,
|
preconditioningProfile: preset.preconditioningProfile,
|
||||||
ofpsHighQuality: preset.ofpsHighQuality == true ? true : nil,
|
// An explicit `false` is preserved — distinguishable from a
|
||||||
|
// missing key; `-G` is only emitted for `true` (#82).
|
||||||
|
ofpsHighQuality: preset.ofpsHighQuality,
|
||||||
ofpsAdaptation: preset.ofpsAdaptation,
|
ofpsAdaptation: preset.ofpsAdaptation,
|
||||||
fullSpreadAlgorithm: preset.fullSpreadAlgorithm.flatMap { FullSpreadAlgorithm(presetValue: $0) }.flatMap { $0 == .ofps ? nil : $0 },
|
fullSpreadAlgorithm: preset.fullSpreadAlgorithm.flatMap { FullSpreadAlgorithm(presetValue: $0) }.flatMap { $0 == .ofps ? nil : $0 },
|
||||||
totalInkLimit: preset.totalInkLimit,
|
totalInkLimit: preset.totalInkLimit,
|
||||||
@@ -109,6 +111,51 @@ extension ColprofConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
extension PageSize {
|
||||||
/// `"210x297"` custom page parse used by presets (issue #82).
|
/// `"210x297"` custom page parse used by presets (issue #82).
|
||||||
public static func parseCustom(_ raw: String) -> (Double, Double)? {
|
public static func parseCustom(_ raw: String) -> (Double, Double)? {
|
||||||
|
|||||||
@@ -1,42 +1,76 @@
|
|||||||
# ICCery
|
# 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.
|
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.
|
||||||
|
|
||||||
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.
|
**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 |
|
| Product | ICCery v2 for macOS |
|
||||||
| Bundle | `com.gronod.iccery2` |
|
| Bundle | `com.gronod.iccery2` |
|
||||||
| Floor | macOS 14 Sonoma, universal `arm64` + `x86_64` |
|
| 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` |
|
| Default branch | `develop` |
|
||||||
| M6 | Stage 0 calibration, CGATS import, SceneKit gamut viewer, packaging — shipped on `develop` |
|
| M6 | Stage 0 calibration, CGATS import, SceneKit gamut viewer, packaging — shipped |
|
||||||
| M7 | UAT-ready hardening of the v2.0 wizard paths |
|
| 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: media library (#146), gamut compare (#147), Spot Read (#148), project files (#149) — shipped on `develop` |
|
||||||
|
| M11 | Printer settings completeness & dialog binding (#183, #180, #181, #186) — in flight on `milestone/m11-print-settings` |
|
||||||
| Licence | Proprietary source in [`LICENCE.md`](LICENCE.md); bundled Argyll sidecars remain AGPLv3 |
|
| Licence | Proprietary source in [`LICENCE.md`](LICENCE.md); bundled Argyll sidecars remain AGPLv3 |
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
The wizard is artefact-gated:
|
The wizard is artefact-gated:
|
||||||
|
|
||||||
1. **Stage 0** — printer calibration: `printcal` / `applycal` session, `CAL_` basename restore
|
1. **Stage 1** — `targen` → `.ti1`
|
||||||
2. **Stage 1** — `targen` → `.ti1`
|
2. **Stage 2** — `printtarg` → `.ti2` + TIFF, unmanaged `lp` spool, bound `NSPrintPanel`
|
||||||
3. **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 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 4** — `colprof` → `.icc` / `.icm`; optional `applycal`; `iccgamut` next to the profile
|
5. **Stage 5** — `profcheck`, verification history, ColorSync user/system install
|
||||||
6. **Stage 5** — `profcheck`, verification history, ColorSync user/system install
|
|
||||||
|
|
||||||
Plus CGATS dataset import (`.ti3` / `.txt` / `.cgats` / `.csv`), SceneKit gamut preview with sRGB overlay, and signed `.dmg` packaging.
|
Plus:
|
||||||
|
|
||||||
**Not this product:** display calibration (`dispwin` / `dispread`), i18n, Windows/Linux print trees, in-process Argyll, App Sandbox.
|
- **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
|
## Requirements
|
||||||
|
|
||||||
- macOS 14+
|
To **run** a packaged build:
|
||||||
- Xcode 15.4+ with the macOS 14 SDK and Swift 6.0
|
|
||||||
- [XcodeGen](https://github.com/yonaskolb/XcodeGen)
|
|
||||||
- Network once, to fetch Argyll sidecars
|
|
||||||
|
|
||||||
App Sandbox is **off**. Hardened Runtime is **on**. Entitlements live in `ICCery.entitlements`.
|
- 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
|
## Build
|
||||||
|
|
||||||
@@ -46,7 +80,7 @@ cd iccery-v2-mac
|
|||||||
git checkout develop
|
git checkout develop
|
||||||
|
|
||||||
make fetch-argyll # Vendor/Argyll/macos-universal/, ad-hoc signed
|
make fetch-argyll # Vendor/Argyll/macos-universal/, ad-hoc signed
|
||||||
make test # xcodegen + xcodebuild build test
|
make test # xcodegen + xcodebuild build test (host arch)
|
||||||
make universal # ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
|
make universal # ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -56,10 +90,17 @@ Equivalent without Make:
|
|||||||
xcodegen generate
|
xcodegen generate
|
||||||
xcodebuild test -scheme ICCery \
|
xcodebuild test -scheme ICCery \
|
||||||
-destination 'platform=macOS' \
|
-destination 'platform=macOS' \
|
||||||
ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
|
ARCHS="$(uname -m)"
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
`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
|
```bash
|
||||||
# optional
|
# optional
|
||||||
@@ -69,9 +110,11 @@ export ARGYLL_RELEASE_TAG=… # default: latest
|
|||||||
export GITEA_TOKEN=… # private releases
|
export GITEA_TOKEN=… # private releases
|
||||||
```
|
```
|
||||||
|
|
||||||
`make clean` drops `ICCery.xcodeproj`, `DerivedData`, and `Packages/ICCeryCore/.build`.
|
`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.
|
Do not open the generated xcodeproj as the source of truth. Edit `project.yml`
|
||||||
|
and regenerate.
|
||||||
|
|
||||||
## Release packaging
|
## Release packaging
|
||||||
|
|
||||||
@@ -79,11 +122,48 @@ Do not open the generated xcodeproj as the source of truth. Edit `project.yml` a
|
|||||||
scripts/package-release.sh # fetch → sign → universal build → verify → DMG
|
scripts/package-release.sh # fetch → sign → universal build → verify → DMG
|
||||||
```
|
```
|
||||||
|
|
||||||
The script builds with a fixed derived data path (`build/DerivedData`),
|
The script builds with a fixed derived data path (`build/DerivedData`), locates
|
||||||
locates `Release/ICCery.app` from it, signs the bundle, recursively verifies
|
`Release/ICCery.app` from it, signs the bundle, recursively verifies every
|
||||||
every bundled Mach-O sidecar (`scripts/verify-sidecar-signatures.sh`), and
|
bundled Mach-O sidecar (`scripts/verify-sidecar-signatures.sh`), builds a
|
||||||
writes `ICCery-${VERSION}-${BUILD_NUM}.dmg` via `dmgbuild`. Sidecars stay
|
HiDPI TIFF from `Resources/dmg-background.png` (+ `@2x`) via `tiffutil`, and
|
||||||
ad-hoc signed inside the bundle — the app is never `codesign --deep`ed.
|
writes the DMG with `dmgbuild==1.6.7`.
|
||||||
|
|
||||||
|
### Versioning
|
||||||
|
|
||||||
|
`scripts/version.sh` resolves the version triple and is the single source for
|
||||||
|
packaging and CI:
|
||||||
|
|
||||||
|
- **`ICCERY_RELEASE_TAG`** — `RELEASE_TAG` env when it matches `v[0-9]*` (CI
|
||||||
|
tag builds), else `git describe --tags --always --dirty --match 'v[0-9]*'`.
|
||||||
|
Stamped into the bundle's `ICCeryReleaseTag` resource (a generated
|
||||||
|
Info.plist cannot carry custom keys); the About dialog shows
|
||||||
|
`tag (marketing)` — e.g. `v2.0.0-pre2-grok (2.0.0)` — falling back to the
|
||||||
|
plain version when untagged.
|
||||||
|
- **`MARKETING_VERSION`** (`CFBundleShortVersionString`) — first three numeric
|
||||||
|
components of the tag core. Must equal `project.yml`'s
|
||||||
|
`MARKETING_VERSION` on tag builds — packaging hard-fails on mismatch, so
|
||||||
|
bump `project.yml` on `develop` *before* tagging.
|
||||||
|
- **`CURRENT_PROJECT_VERSION`** (`CFBundleVersion`) — `BUILD_NUMBER` env
|
||||||
|
override, else `git rev-list --count HEAD`. A single monotonically
|
||||||
|
increasing integer per Apple's macOS convention (Mac build numbers never
|
||||||
|
reset per version, unlike iOS).
|
||||||
|
|
||||||
|
Tagged/described DMGs carry the tag: `ICCery-2.0.0-pre2-grok-<build>.dmg`;
|
||||||
|
plain releases keep `ICCery-<ver>-<build>.dmg`.
|
||||||
|
|
||||||
|
Release procedure: bump `MARKETING_VERSION` in `project.yml` on `develop` →
|
||||||
|
merge `develop` → `main` → tag the release commit `vX.Y.Z[-suffix]` → push
|
||||||
|
the tag. CI builds, signs, notarizes (when secrets exist) and attaches the
|
||||||
|
DMG to the release.
|
||||||
|
|
||||||
|
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:
|
Environment variables read by the pipeline:
|
||||||
|
|
||||||
@@ -94,6 +174,8 @@ Environment variables read by the pipeline:
|
|||||||
| `CODESIGN_IDENTITY` | Developer ID identity for the outer `.app`; unset or `-` = ad-hoc |
|
| `CODESIGN_IDENTITY` | Developer ID identity for the outer `.app`; unset or `-` = ad-hoc |
|
||||||
| `DEVELOPMENT_TEAM` | team ID passed to `xcodebuild` when signing |
|
| `DEVELOPMENT_TEAM` | team ID passed to `xcodebuild` when signing |
|
||||||
| `NOTARIZE_APPLE_ID` / `NOTARIZE_PASSWORD` / `APPLE_TEAM_ID` | `notarytool` + staple when all three are set |
|
| `NOTARIZE_APPLE_ID` / `NOTARIZE_PASSWORD` / `APPLE_TEAM_ID` | `notarytool` + staple when all three are set |
|
||||||
|
| `RELEASE_TAG` | release tag string (CI sets `github.ref_name`); stamped into the bundle + DMG name |
|
||||||
|
| `BUILD_NUMBER` | `CFBundleVersion` override; default `git rev-list --count HEAD` |
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
@@ -105,28 +187,48 @@ Resources/ assets; Argyll reference files (not the tools)
|
|||||||
Vendor/Argyll/ fetched sidecars (gitignored)
|
Vendor/Argyll/ fetched sidecars (gitignored)
|
||||||
Tests/ICCeryCoreTests/ argv goldens, parsers, stores
|
Tests/ICCeryCoreTests/ argv goldens, parsers, stores
|
||||||
Tests/ICCeryUITests/ fixture / mock-binary UI tests
|
Tests/ICCeryUITests/ fixture / mock-binary UI tests
|
||||||
|
scripts/ensure-host-tools.sh
|
||||||
scripts/fetch-argyll.sh
|
scripts/fetch-argyll.sh
|
||||||
|
scripts/package-release.sh
|
||||||
docs/ functional spec + v2 ticket plan
|
docs/ functional spec + v2 ticket plan
|
||||||
```
|
```
|
||||||
|
|
||||||
`ICCeryPrintKit` (issue #16, Quartz / AirPrint / TargetPrint) is v2.1 and is not in this tree.
|
`ICCeryPrintKit` (issue #16, Quartz / AirPrint / TargetPrint) is v2.1 and is
|
||||||
|
not in this tree.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
- **Spawn, never link.** Tools resolve through `BinaryResolver` inside the bundle / `Vendor` tree. `$PATH` is not searched. `ARGYLL_NOT_INTERACTIVE=1` is always set.
|
- **Spawn, never link.** Tools resolve through `BinaryResolver` inside the
|
||||||
- **`ProcessManager` actor** owns child lifetime. Streaming tools (`chartread`, `printcal`, etc.) use the event bus; one-shot tools use `runCaptured`. Exclusive `ProcessID` leases. Quit path: `q\n`, ~500 ms, kill; `killAll` on terminate.
|
bundle / `Vendor` tree. `$PATH` is not searched. `ARGYLL_NOT_INTERACTIVE=1`
|
||||||
- **Argv builders** in ICCeryCore (`TargenArgs`, `PrinttargArgs`, `ChartreadArgs`, `ColprofArgs`, `ApplycalArgs`, `IccgamutArgs`, `ProfcheckArgs`, `LpArgs`, …). UI must not concatenate flags.
|
is always set.
|
||||||
- **Atomic artefacts.** Writes go to `*.tmp` then `replaceItemAt`. `applycal` must not replace the input profile on cancel or non-zero exit.
|
- **`ProcessManager` actor** owns child lifetime. Streaming tools
|
||||||
- **Concurrency.** View models are `@MainActor`. No blocking I/O on the main actor. SwiftUI `@Observable` for new state.
|
(`chartread`, `printtarg`, `colprof`, …) use the event bus; one-shot tools
|
||||||
- **Print.** Unmanaged `lp` with ColorSync suppression (`AP_ColorMatchingMode` / `AP.ColorMatchingMode`). Captured `NSPrintPanel` options win over derived CUPS keys. Never `lp -o raw`.
|
(`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
|
## Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# full suite (universal)
|
# full suite (host arch) — same as CI
|
||||||
xcodebuild test -scheme ICCery \
|
xcodebuild test -scheme ICCery \
|
||||||
-destination 'platform=macOS' \
|
-destination 'platform=macOS' \
|
||||||
ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
|
ARCHS="$(uname -m)"
|
||||||
|
|
||||||
|
# fat compile-check (not the default test path):
|
||||||
|
# ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
|
||||||
|
|
||||||
# examples
|
# examples
|
||||||
xcodebuild test -scheme ICCery -destination 'platform=macOS' \
|
xcodebuild test -scheme ICCery -destination 'platform=macOS' \
|
||||||
@@ -135,9 +237,24 @@ xcodebuild test -scheme ICCery -destination 'platform=macOS' \
|
|||||||
-only-testing:ICCeryUITests/Milestone5UITests
|
-only-testing:ICCeryUITests/Milestone5UITests
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
Hardware gates (real instrument, real printer, Gatekeeper-open `.dmg`) are manual and block release, not compile.
|
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
|
## Instruments
|
||||||
|
|
||||||
@@ -148,11 +265,18 @@ Detected via bundled `instlist`:
|
|||||||
- SpyderPrint (`p3`)
|
- SpyderPrint (`p3`)
|
||||||
- SpectroScan (`SS`)
|
- SpectroScan (`SS`)
|
||||||
- DTP20 / 22 / 41 / 51
|
- DTP20 / 22 / 41 / 51
|
||||||
- XY tables (SpectroScan, i1iO) when the `instlist` name matches `/spectro\s?scan|i1io/i`
|
- XY tables (SpectroScan, i1iO) when the `instlist` name matches
|
||||||
|
`/spectro\s?scan|i1io/i`
|
||||||
|
|
||||||
## Docs
|
## Docs
|
||||||
|
|
||||||
Normative spec is [`docs/`](docs/README.md). Implementation order:
|
| 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 |
|
| Doc | Topic |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -160,29 +284,40 @@ Normative spec is [`docs/`](docs/README.md). Implementation order:
|
|||||||
| [`docs/03-ipc-and-process-manager.md`](docs/03-ipc-and-process-manager.md) | Spawn / stdin / kill |
|
| [`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/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/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/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/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 |
|
| [`docs/PREUAT.md`](docs/PREUAT.md) | Pre-UAT tester kit |
|
||||||
|
|
||||||
Agent / branch rules: [`AGENTS.md`](AGENTS.md), [`BUILD-PLAN.md`](BUILD-PLAN.md).
|
|
||||||
|
|
||||||
## Git
|
## Git
|
||||||
|
|
||||||
```
|
```
|
||||||
develop
|
develop # integration; PRs land here unless a milestone branch is announced
|
||||||
└── milestone/mN-<slug> # integration only
|
main # protected release line (PR from develop)
|
||||||
└── feat/<issue>-<slug> # one issue per branch
|
feat/<issue>-<slug>
|
||||||
|
fix/<issue>-<slug>
|
||||||
```
|
```
|
||||||
|
|
||||||
Feature PRs target the current milestone branch, not `develop`. The milestone branch merges to `develop` when its issues are green. M7 is small; its PRs target `develop` directly. Do not open umbrella "bugfix" branches that mix tickets.
|
Open feature/fix PRs against **`develop`**. A `milestone/m…` integration
|
||||||
|
branch is used only while that milestone is assembling — currently
|
||||||
|
`milestone/m11-print-settings` (`milestone/m10-studio` was 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
|
## Licence
|
||||||
|
|
||||||
GUI source: © 2026 Gordon Bolton — see [`LICENCE.md`](LICENCE.md). Viewing and personal evaluation only unless a separate grant says otherwise.
|
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.
|
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
|
## 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/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)
|
- [gronod/ICCery](https://git.i3omb.com/gronod/ICCery) — v1 Tauri application (spec source, not this tree)
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<!-- App Sandbox intentionally absent: ICCery must spawn Argyll tools,
|
||||||
|
read/write user-chosen working directories, and talk to lp/CUPS. -->
|
||||||
|
<key>com.apple.security.device.usb</key>
|
||||||
|
<true/>
|
||||||
|
<!-- Debug only: the shared ICCeryCore package framework embedded in the
|
||||||
|
test products is ad-hoc signed with no Team ID, so hardened-runtime
|
||||||
|
library validation kills the test host at launch (run 31992, #119).
|
||||||
|
Release uses ICCery.entitlements and links the package statically. -->
|
||||||
|
<key>com.apple.security.cs.disable-library-validation</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -6,6 +6,8 @@ import ICCeryCore
|
|||||||
struct AboutView: View {
|
struct AboutView: View {
|
||||||
let onClose: () -> Void
|
let onClose: () -> Void
|
||||||
|
|
||||||
|
@State private var showingLicenses = false
|
||||||
|
|
||||||
private let info = ArtefactFiles.appInfo()
|
private let info = ArtefactFiles.appInfo()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -54,6 +56,12 @@ struct AboutView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
Button("View Licenses") {
|
||||||
|
showingLicenses = true
|
||||||
|
}
|
||||||
|
.controlSize(.large)
|
||||||
|
.accessibilityIdentifier("viewLicensesBtn")
|
||||||
|
|
||||||
Button("Close") {
|
Button("Close") {
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
@@ -66,5 +74,8 @@ struct AboutView: View {
|
|||||||
.background(Theme.panel)
|
.background(Theme.panel)
|
||||||
.accessibilityElement(children: .contain)
|
.accessibilityElement(children: .contain)
|
||||||
.accessibilityIdentifier("aboutDialog")
|
.accessibilityIdentifier("aboutDialog")
|
||||||
|
.sheet(isPresented: $showingLicenses) {
|
||||||
|
LicenseWindowView()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ struct AppEnvironment: Sendable {
|
|||||||
let runner: ArgyllRunner
|
let runner: ArgyllRunner
|
||||||
let cupsService: CupsService
|
let cupsService: CupsService
|
||||||
let historyStore: VerificationHistoryStore
|
let historyStore: VerificationHistoryStore
|
||||||
|
let mediaStore: MediaLibraryStore
|
||||||
|
let recentProjectsStore: RecentProjectsStore
|
||||||
|
|
||||||
static func live(
|
static func live(
|
||||||
environment: [String: String] = ProcessInfo.processInfo.environment
|
environment: [String: String] = ProcessInfo.processInfo.environment
|
||||||
@@ -20,11 +22,15 @@ struct AppEnvironment: Sendable {
|
|||||||
let settingsStore = SettingsStore()
|
let settingsStore = SettingsStore()
|
||||||
var overrideDir = settingsStore.load().argyllBinaryDir
|
var overrideDir = settingsStore.load().argyllBinaryDir
|
||||||
.map { URL(fileURLWithPath: $0) }
|
.map { URL(fileURLWithPath: $0) }
|
||||||
|
var bundledRoot = AppPaths.bundledArgyllDir
|
||||||
var cupsDir = URL(fileURLWithPath: "/usr/bin")
|
var cupsDir = URL(fileURLWithPath: "/usr/bin")
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
if let dir = environment["ICCERY_ARGYLL_BINARY_DIR"], !dir.isEmpty {
|
if let dir = environment["ICCERY_ARGYLL_BINARY_DIR"], !dir.isEmpty {
|
||||||
overrideDir = URL(fileURLWithPath: dir)
|
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 {
|
if let dir = environment["ICCERY_CUPS_BIN_DIR"], !dir.isEmpty {
|
||||||
cupsDir = URL(fileURLWithPath: dir)
|
cupsDir = URL(fileURLWithPath: dir)
|
||||||
}
|
}
|
||||||
@@ -35,12 +41,15 @@ struct AppEnvironment: Sendable {
|
|||||||
presetStore: PresetStore(settingsStore: settingsStore),
|
presetStore: PresetStore(settingsStore: settingsStore),
|
||||||
runner: ArgyllRunner(
|
runner: ArgyllRunner(
|
||||||
processManager: .shared,
|
processManager: .shared,
|
||||||
binaryResolver: BinaryResolver(overrideDir: overrideDir)
|
binaryResolver: BinaryResolver(
|
||||||
|
bundledRoot: bundledRoot, overrideDir: overrideDir)
|
||||||
),
|
),
|
||||||
cupsService: CupsService(
|
cupsService: CupsService(
|
||||||
processManager: .shared,
|
processManager: .shared,
|
||||||
binaryDir: cupsDir),
|
binaryDir: cupsDir),
|
||||||
historyStore: VerificationHistoryStore()
|
historyStore: VerificationHistoryStore(),
|
||||||
|
mediaStore: MediaLibraryStore(),
|
||||||
|
recentProjectsStore: RecentProjectsStore()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,6 +83,26 @@ enum UITestHooks {
|
|||||||
static var presetImportURL: URL? { url("ICCERY_TEST_PRESET_IMPORT") }
|
static var presetImportURL: URL? { url("ICCERY_TEST_PRESET_IMPORT") }
|
||||||
/// Preset export destination.
|
/// Preset export destination.
|
||||||
static var presetExportURL: URL? { url("ICCERY_TEST_PRESET_EXPORT") }
|
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)
|
// MARK: - Print panel / CUPS stubs (issue 13/17)
|
||||||
|
|
||||||
@@ -107,9 +136,19 @@ enum UITestHooks {
|
|||||||
$0.isEmpty ? nil : $0
|
$0.isEmpty ? nil : $0
|
||||||
} ?? queue,
|
} ?? queue,
|
||||||
options: PrintOptions(
|
options: PrintOptions(
|
||||||
|
orientation: options.flatMap {
|
||||||
|
CupsParsers.extractOrientation(fromOptionsString: $0)
|
||||||
|
},
|
||||||
|
paperSize: options.flatMap {
|
||||||
|
CupsParsers.extractOption(
|
||||||
|
named: "PageSize", fromOptionsString: $0)
|
||||||
|
},
|
||||||
mediaType: options.flatMap {
|
mediaType: options.flatMap {
|
||||||
CupsParsers.extractMediaType(fromOptionsString: $0)
|
CupsParsers.extractMediaType(fromOptionsString: $0)
|
||||||
},
|
},
|
||||||
|
quality: options.flatMap {
|
||||||
|
CupsParsers.extractQuality(fromOptionsString: $0)
|
||||||
|
},
|
||||||
ppdUncorrectedPassthrough: true,
|
ppdUncorrectedPassthrough: true,
|
||||||
cupsOptions: options))
|
cupsOptions: options))
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -3,52 +3,94 @@ import ICCeryCore
|
|||||||
|
|
||||||
/// Stage 0 calibration dashboard (issue #29, docs/07).
|
/// Stage 0 calibration dashboard (issue #29, docs/07).
|
||||||
struct CalibrationView: View {
|
struct CalibrationView: View {
|
||||||
@Bindable var model: CalibrationViewModel
|
@ObservedObject var model: CalibrationViewModel
|
||||||
@Bindable var wizard: WizardViewModel
|
@ObservedObject var wizard: WizardViewModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
Text("Calibrate Printer")
|
Text("Calibrate Printer")
|
||||||
.font(.title2.bold())
|
.font(.title2.bold())
|
||||||
.padding(.horizontal, 16)
|
.foregroundStyle(Theme.text)
|
||||||
.padding(.top, 16)
|
wedgeSection
|
||||||
|
workflowSection
|
||||||
|
if !model.calibrationLog.isEmpty { logSection }
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.background(Theme.background)
|
||||||
|
|
||||||
Form {
|
Divider().overlay(Theme.border)
|
||||||
Section("Wedge Settings") {
|
|
||||||
|
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) {
|
Picker("Colour Space", selection: $model.colourSpace) {
|
||||||
Text("RGB").tag(ColourSpace.rgb)
|
Text("RGB").tag(ColourSpace.rgb)
|
||||||
Text("CMYK").tag(ColourSpace.cmyk)
|
Text("CMYK").tag(ColourSpace.cmyk)
|
||||||
}
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.frame(maxWidth: 220)
|
||||||
|
|
||||||
HStack {
|
HStack(spacing: 12) {
|
||||||
Text("Steps per channel")
|
Text("Steps per channel")
|
||||||
Spacer()
|
.foregroundStyle(Theme.text)
|
||||||
|
.frame(width: 140, alignment: .leading)
|
||||||
TextField("", value: $model.steps, format: .number)
|
TextField("", value: $model.steps, format: .number)
|
||||||
.frame(width: 60)
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: 70)
|
||||||
.accessibilityIdentifier("calSteps")
|
.accessibilityIdentifier("calSteps")
|
||||||
}
|
}
|
||||||
|
|
||||||
HStack {
|
HStack(spacing: 12) {
|
||||||
Text("White patches")
|
Text("White patches")
|
||||||
Spacer()
|
.foregroundStyle(Theme.text)
|
||||||
|
.frame(width: 140, alignment: .leading)
|
||||||
TextField("", value: $model.whitePatches, format: .number)
|
TextField("", value: $model.whitePatches, format: .number)
|
||||||
.frame(width: 60)
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: 70)
|
||||||
}
|
}
|
||||||
|
|
||||||
if model.colourSpace == .cmyk {
|
if model.colourSpace == .cmyk {
|
||||||
HStack {
|
HStack(spacing: 12) {
|
||||||
Text("Ink-limit exploration")
|
Text("Ink-limit exploration")
|
||||||
Spacer()
|
.foregroundStyle(Theme.text)
|
||||||
|
.frame(width: 140, alignment: .leading)
|
||||||
TextField("", text: $model.inkLimit)
|
TextField("", text: $model.inkLimit)
|
||||||
.frame(width: 60)
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: 70)
|
||||||
.accessibilityIdentifier("calInkExplore")
|
.accessibilityIdentifier("calInkExplore")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Toggle("Neutral emphasis", isOn: $model.includeNeutralEmphasis)
|
Toggle("Neutral emphasis", isOn: $model.includeNeutralEmphasis)
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.accessibilityIdentifier("calNeutralEmphasis")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Workflow") {
|
// MARK: - Workflow
|
||||||
|
|
||||||
|
private var workflowSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Workflow").font(.headline).foregroundStyle(Theme.text)
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Button("Generate Target") { model.generateTarget() }
|
Button("Generate Target") { model.generateTarget() }
|
||||||
.accessibilityIdentifier("btnCalGenerate")
|
.accessibilityIdentifier("btnCalGenerate")
|
||||||
@@ -73,7 +115,9 @@ struct CalibrationView: View {
|
|||||||
|
|
||||||
if let url = model.computedCalURL {
|
if let url = model.computedCalURL {
|
||||||
Toggle("Apply calibration to next profile", isOn: $model.applyToProfile)
|
Toggle("Apply calibration to next profile", isOn: $model.applyToProfile)
|
||||||
.onChange(of: model.applyToProfile) { model.updateApplyToProfile() }
|
.toggleStyle(.checkbox)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.onChange(of: model.applyToProfile) { _ in model.updateApplyToProfile() }
|
||||||
.accessibilityIdentifier("calApplyToggle")
|
.accessibilityIdentifier("calApplyToggle")
|
||||||
|
|
||||||
Text("Loaded: \(url.lastPathComponent)")
|
Text("Loaded: \(url.lastPathComponent)")
|
||||||
@@ -81,36 +125,17 @@ struct CalibrationView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !model.calibrationLog.isEmpty {
|
|
||||||
Section("Log") {
|
|
||||||
ScrollView {
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
ForEach(model.calibrationLog, id: \.self) { line in
|
|
||||||
Text(line)
|
|
||||||
.font(.system(.caption, design: .monospaced))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(minHeight: 80, maxHeight: 120)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let error = model.lastError {
|
// MARK: - Log
|
||||||
Section {
|
|
||||||
Text(error)
|
|
||||||
.foregroundStyle(.red)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.formStyle(.grouped)
|
|
||||||
|
|
||||||
HStack {
|
private var logSection: some View {
|
||||||
Spacer()
|
ProcessLogView(
|
||||||
Button("Return to Profiling") { model.returnToProfiling() }
|
lines: model.calibrationLog,
|
||||||
.accessibilityIdentifier("btnCalReturn")
|
minHeight: 80,
|
||||||
}
|
maxHeight: 120,
|
||||||
.padding(16)
|
containerId: "calLogContainer",
|
||||||
}
|
logId: "calLog"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import ICCeryCore
|
import ICCeryCore
|
||||||
|
|
||||||
/// Stage 0 calibration workflow: generate wedge, print, measure, and
|
/// Stage 0 calibration workflow: generate wedge, print, measure, and
|
||||||
/// compute `.cal` curves.
|
/// compute `.cal` curves.
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
final class CalibrationViewModel: ObservableObject {
|
||||||
final class CalibrationViewModel {
|
|
||||||
|
|
||||||
let workflow: TargetWorkflowViewModel
|
let workflow: TargetWorkflowViewModel
|
||||||
let profile: ProfileWorkflowViewModel
|
let profile: ProfileWorkflowViewModel
|
||||||
@@ -15,17 +14,16 @@ final class CalibrationViewModel {
|
|||||||
|
|
||||||
// MARK: - Form state
|
// MARK: - Form state
|
||||||
|
|
||||||
var colourSpace: ColourSpace = .cmyk
|
@Published var colourSpace: ColourSpace = .cmyk
|
||||||
var steps: Int = 21
|
@Published var steps: Int = 21
|
||||||
var whitePatches: Int = 4
|
@Published var whitePatches: Int = 4
|
||||||
var includeNeutralEmphasis: Bool = false
|
@Published var includeNeutralEmphasis: Bool = false
|
||||||
var inkLimit: String = "320"
|
@Published var inkLimit: String = "320"
|
||||||
var applyToProfile: Bool = false
|
@Published var applyToProfile: Bool = false
|
||||||
var computedCalURL: URL?
|
@Published var computedCalURL: URL?
|
||||||
var calibrationLog: [String] = []
|
@Published var calibrationLog: [String] = []
|
||||||
var isGenerating = false
|
@Published var isGenerating = false
|
||||||
var isComputing = false
|
@Published var isComputing = false
|
||||||
var lastError: String?
|
|
||||||
|
|
||||||
init(workflow: TargetWorkflowViewModel, profile: ProfileWorkflowViewModel, environment: AppEnvironment) {
|
init(workflow: TargetWorkflowViewModel, profile: ProfileWorkflowViewModel, environment: AppEnvironment) {
|
||||||
self.workflow = workflow
|
self.workflow = workflow
|
||||||
@@ -77,10 +75,6 @@ final class CalibrationViewModel {
|
|||||||
wizard.basename = identity.calibrationBasename
|
wizard.basename = identity.calibrationBasename
|
||||||
wizard.sessionMode = .calibration
|
wizard.sessionMode = .calibration
|
||||||
|
|
||||||
isGenerating = true
|
|
||||||
calibrationLog = []
|
|
||||||
lastError = nil
|
|
||||||
|
|
||||||
let config = CalibrationTargenConfig(
|
let config = CalibrationTargenConfig(
|
||||||
colourSpace: colourSpace,
|
colourSpace: colourSpace,
|
||||||
steps: steps,
|
steps: steps,
|
||||||
@@ -92,17 +86,19 @@ final class CalibrationViewModel {
|
|||||||
)
|
)
|
||||||
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
defer { self.isGenerating = false }
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
_ = try await self.environment.runner.runCalibrationTargen(config: config, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
|
_ = try await ProcessRunSupport.runLogged(
|
||||||
self?.calibrationLog.append(contentsOf: batch)
|
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.refreshGating()
|
||||||
self.wizard.showNotice("Calibration target generated.")
|
self.wizard.showNotice("Calibration target generated.")
|
||||||
self.wizard.go(to: .layOutPrint)
|
self.wizard.go(to: .layOutPrint)
|
||||||
} catch {
|
} catch {
|
||||||
self.lastError = error.localizedDescription
|
|
||||||
self.wizard.showNotice(
|
self.wizard.showNotice(
|
||||||
"Calibration target failed: \(error.localizedDescription)",
|
"Calibration target failed: \(error.localizedDescription)",
|
||||||
kind: .error
|
kind: .error
|
||||||
@@ -137,15 +133,13 @@ final class CalibrationViewModel {
|
|||||||
// "already exists" when the user declines overwrite. We do not
|
// "already exists" when the user declines overwrite. We do not
|
||||||
// silently clobber.
|
// silently clobber.
|
||||||
if FileManager.default.fileExists(atPath: outputURL.path) {
|
if FileManager.default.fileExists(atPath: outputURL.path) {
|
||||||
lastError = "\(outputURL.lastPathComponent) already exists. Rename or overwrite it first."
|
wizard.showNotice(
|
||||||
wizard.showNotice(lastError!, kind: .error)
|
"\(outputURL.lastPathComponent) already exists. Rename or overwrite it first.",
|
||||||
|
kind: .error
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
isComputing = true
|
|
||||||
calibrationLog = []
|
|
||||||
lastError = nil
|
|
||||||
|
|
||||||
let config = PrintcalConfig(
|
let config = PrintcalConfig(
|
||||||
ti3Basename: calBasename,
|
ti3Basename: calBasename,
|
||||||
workingDirectory: cwd,
|
workingDirectory: cwd,
|
||||||
@@ -158,19 +152,21 @@ final class CalibrationViewModel {
|
|||||||
)
|
)
|
||||||
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
defer { self.isComputing = false }
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let url = try await self.environment.runner.runPrintcal(config: config, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
|
let url = try await ProcessRunSupport.runLogged(
|
||||||
self?.calibrationLog.append(contentsOf: batch)
|
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.computedCalURL = url
|
||||||
self.profile.calibrationFile = url.path
|
self.profile.calibrationFile = url.path
|
||||||
self.profile.applyCalibration = self.applyToProfile
|
self.profile.applyCalibration = self.applyToProfile
|
||||||
self.wizard.showNotice("Calibration curves computed.")
|
self.wizard.showNotice("Calibration curves computed.")
|
||||||
self.wizard.restoreCalibration()
|
self.wizard.restoreCalibration()
|
||||||
} catch {
|
} catch {
|
||||||
self.lastError = error.localizedDescription
|
|
||||||
self.wizard.showNotice(
|
self.wizard.showNotice(
|
||||||
"Calibration curve computation failed: \(error.localizedDescription)",
|
"Calibration curve computation failed: \(error.localizedDescription)",
|
||||||
kind: .error
|
kind: .error
|
||||||
|
|||||||
@@ -70,6 +70,19 @@ final class FileDialogService {
|
|||||||
message: "Choose a calibration file (.cal)")
|
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.
|
/// `btnImportPreset` — open a `.json` preset file.
|
||||||
func selectPresetFile(startingAt start: URL? = nil) -> URL? {
|
func selectPresetFile(startingAt start: URL? = nil) -> URL? {
|
||||||
open(extensions: ["json"], startingAt: start,
|
open(extensions: ["json"], startingAt: start,
|
||||||
@@ -82,6 +95,23 @@ final class FileDialogService {
|
|||||||
message: "Export this preset as JSON")
|
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)
|
// MARK: - Internals (private — not a shared public picker API)
|
||||||
|
|
||||||
private func save(
|
private func save(
|
||||||
@@ -102,16 +132,19 @@ final class FileDialogService {
|
|||||||
private func open(
|
private func open(
|
||||||
extensions: [String],
|
extensions: [String],
|
||||||
startingAt start: URL?,
|
startingAt start: URL?,
|
||||||
message: String?
|
message: String?,
|
||||||
|
title: String? = nil,
|
||||||
|
allowsOtherFileTypes: Bool = true
|
||||||
) -> URL? {
|
) -> URL? {
|
||||||
let panel = NSOpenPanel()
|
let panel = NSOpenPanel()
|
||||||
panel.canChooseDirectories = false
|
panel.canChooseDirectories = false
|
||||||
panel.canChooseFiles = true
|
panel.canChooseFiles = true
|
||||||
panel.allowsMultipleSelection = false
|
panel.allowsMultipleSelection = false
|
||||||
panel.allowedContentTypes = utTypes(extensions)
|
panel.allowedContentTypes = utTypes(extensions)
|
||||||
panel.allowsOtherFileTypes = true
|
panel.allowsOtherFileTypes = allowsOtherFileTypes
|
||||||
panel.directoryURL = start
|
panel.directoryURL = start
|
||||||
if let message { panel.message = message }
|
if let message { panel.message = message }
|
||||||
|
if let title { panel.title = title }
|
||||||
return run(panel)
|
return run(panel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+511
-62
@@ -1,5 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SceneKit
|
import SceneKit
|
||||||
|
import Metal
|
||||||
import ICCeryCore
|
import ICCeryCore
|
||||||
import simd
|
import simd
|
||||||
|
|
||||||
@@ -62,77 +63,322 @@ internal struct GamutSceneGeometryBuilder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Native SceneKit 3D gamut viewer.
|
/// Native SceneKit 3D gamut viewer (issues #28, #147).
|
||||||
///
|
///
|
||||||
/// Displays a profile gamut mesh and the bundled `sRGB.gam` reference. Uses
|
/// Displays the bundled `sRGB.gam` reference plus up to two profile
|
||||||
/// the CIELAB coordinate convention `x = a*`, `y = L*`, `z = b*` so that the
|
/// meshes with independent visibility toggles, a status line, and an
|
||||||
/// a* (green-red) axis is horizontal, L* (lightness) is vertical, and b*
|
/// inspect panel (click a mesh, type a Lab value, or sample a TIFF
|
||||||
/// (blue-yellow) is depth.
|
/// pixel). Uses the CIELAB coordinate convention `x = a*`, `y = L*`,
|
||||||
|
/// `z = b*`.
|
||||||
struct GamutView: View {
|
struct GamutView: View {
|
||||||
@State private var viewModel: GamutViewModel
|
@StateObject private var viewModel: GamutViewModel
|
||||||
@State private var pause: () -> Void = {}
|
@State private var pause: () -> Void = {}
|
||||||
@FocusState private var isFocused: Bool
|
@FocusState private var isFocused: Bool
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Binding var showingAllHelp: Bool
|
||||||
|
|
||||||
init(profileGamURL: URL? = nil) {
|
init(
|
||||||
_viewModel = State(wrappedValue: GamutViewModel(profileGamURL: profileGamURL))
|
environment: AppEnvironment,
|
||||||
|
profileGamURL: URL? = nil,
|
||||||
|
showingAllHelp: Binding<Bool>
|
||||||
|
) {
|
||||||
|
_viewModel = StateObject(wrappedValue: GamutViewModel(
|
||||||
|
environment: environment, profileGamURL: profileGamURL))
|
||||||
|
_showingAllHelp = showingAllHelp
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
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(
|
GamutSceneView(
|
||||||
profileMesh: viewModel.profileMesh,
|
layers: viewModel.layers,
|
||||||
referenceMesh: viewModel.sRGBMesh,
|
visibleIDs: viewModel.visibleIDs,
|
||||||
onReset: $viewModel.resetCamera,
|
onReset: $viewModel.resetCamera,
|
||||||
onPause: $pause
|
onPause: $pause,
|
||||||
|
onUnavailable: { viewModel.viewerUnavailable = true },
|
||||||
|
onInspect: { point, layerID in
|
||||||
|
if let layerID {
|
||||||
|
viewModel.inspectSceneHit(world: point, layerID: layerID)
|
||||||
|
} else {
|
||||||
|
viewModel.clearInspect()
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
.focusable()
|
.focusable()
|
||||||
.focused($isFocused)
|
.focused($isFocused)
|
||||||
.focusEffectDisabled()
|
|
||||||
.onKeyPress(.init("R"), action: {
|
|
||||||
viewModel.resetCamera()
|
|
||||||
return .handled
|
|
||||||
})
|
|
||||||
.onAppear { isFocused = true }
|
.onAppear { isFocused = true }
|
||||||
|
}
|
||||||
VStack {
|
|
||||||
HStack {
|
|
||||||
Spacer()
|
|
||||||
Button(action: { viewModel.resetCamera() }) {
|
Button(action: { viewModel.resetCamera() }) {
|
||||||
Text("Reset view")
|
Text("Reset view")
|
||||||
}
|
}
|
||||||
.accessibilityIdentifier("btnResetGamutCamera")
|
.accessibilityIdentifier("btnResetGamutCamera")
|
||||||
.padding(8)
|
.padding(8)
|
||||||
}
|
}
|
||||||
Spacer()
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
HStack {
|
}
|
||||||
|
|
||||||
|
// MARK: - Status
|
||||||
|
|
||||||
|
private var statusLine: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
Text(viewModel.status)
|
Text(viewModel.status)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.padding(8)
|
.foregroundStyle(Theme.text)
|
||||||
.background(.thinMaterial)
|
|
||||||
.cornerRadius(6)
|
|
||||||
.accessibilityIdentifier("gamutStatusText")
|
.accessibilityIdentifier("gamutStatusText")
|
||||||
|
if let notice = viewModel.noticeText {
|
||||||
|
Text(notice)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.accessibilityIdentifier("gamutNoticeText")
|
||||||
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding(8)
|
.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")
|
||||||
}
|
}
|
||||||
.frame(minWidth: 500, minHeight: 400)
|
} else {
|
||||||
.onDisappear { pause() }
|
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)
|
.accessibilityElement(children: .contain)
|
||||||
.accessibilityIdentifier("gamutView")
|
.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` that builds the scene from
|
/// `NSViewRepresentable` wrapper around an `SCNView` rendering one node
|
||||||
/// one or two ``GamutMesh`` values.
|
/// per ``NamedGamut`` layer.
|
||||||
///
|
///
|
||||||
/// Scene construction and camera reset are coordinated through a typed callback
|
/// Layer toggles hide/show `SCNNode`s — the scene is built once and the
|
||||||
/// binding owned by the view model.
|
/// camera is only reset through the explicit reset path, never on a
|
||||||
|
/// mesh or visibility update.
|
||||||
private struct GamutSceneView: NSViewRepresentable {
|
private struct GamutSceneView: NSViewRepresentable {
|
||||||
var profileMesh: GamutMesh?
|
var layers: [NamedGamut]
|
||||||
var referenceMesh: GamutMesh?
|
var visibleIDs: Set<String>
|
||||||
var onReset: Binding<() -> Void>
|
var onReset: Binding<() -> Void>
|
||||||
var onPause: Binding<() -> Void>
|
var onPause: Binding<() -> Void>
|
||||||
|
var onUnavailable: () -> Void
|
||||||
|
var onInspect: (SIMD3<Float>, String?) -> Void
|
||||||
|
|
||||||
func makeNSView(context: Context) -> SCNView {
|
func makeNSView(context: Context) -> SCNView {
|
||||||
let scnView = SCNView()
|
let scnView = SCNView()
|
||||||
@@ -147,13 +393,25 @@ private struct GamutSceneView: NSViewRepresentable {
|
|||||||
|
|
||||||
context.coordinator.scnView = scnView
|
context.coordinator.scnView = scnView
|
||||||
context.coordinator.scene = scene
|
context.coordinator.scene = scene
|
||||||
context.coordinator.buildScene(profile: profileMesh, reference: referenceMesh)
|
context.coordinator.onInspect = onInspect
|
||||||
|
context.coordinator.buildSceneOnce()
|
||||||
|
context.coordinator.syncLayers(layers, visibleIDs: visibleIDs)
|
||||||
|
context.coordinator.installKeyMonitor()
|
||||||
|
context.coordinator.installClickGesture()
|
||||||
|
|
||||||
|
// 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
|
return scnView
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateNSView(_ nsView: SCNView, context: Context) {
|
func updateNSView(_ nsView: SCNView, context: Context) {
|
||||||
context.coordinator.buildScene(profile: profileMesh, reference: referenceMesh)
|
context.coordinator.onInspect = onInspect
|
||||||
|
context.coordinator.syncLayers(layers, visibleIDs: visibleIDs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator {
|
func makeCoordinator() -> Coordinator {
|
||||||
@@ -168,6 +426,10 @@ private struct GamutSceneView: NSViewRepresentable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func dismantleNSView(_ nsView: SCNView, coordinator: Coordinator) {
|
static func dismantleNSView(_ nsView: SCNView, coordinator: Coordinator) {
|
||||||
|
coordinator.removeKeyMonitor()
|
||||||
|
if let click = coordinator.clickGesture {
|
||||||
|
nsView.removeGestureRecognizer(click)
|
||||||
|
}
|
||||||
nsView.isPlaying = false
|
nsView.isPlaying = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,10 +437,14 @@ private struct GamutSceneView: NSViewRepresentable {
|
|||||||
final class Coordinator: NSObject {
|
final class Coordinator: NSObject {
|
||||||
weak var scnView: SCNView?
|
weak var scnView: SCNView?
|
||||||
weak var scene: SCNScene?
|
weak var scene: SCNScene?
|
||||||
|
var onInspect: (SIMD3<Float>, String?) -> Void = { _, _ in }
|
||||||
|
private(set) var clickGesture: NSClickGestureRecognizer?
|
||||||
|
private var keyMonitor: Any?
|
||||||
|
|
||||||
private let profileNode = SCNNode()
|
/// One `SCNNode` per loaded layer, keyed by `NamedGamut.id`.
|
||||||
private let referenceGroup = SCNNode()
|
private var layerNodes: [String: SCNNode] = [:]
|
||||||
private let axisNode = SCNNode()
|
private let axisNode = SCNNode()
|
||||||
|
private let layerGroup = SCNNode()
|
||||||
private let cameraNode: SCNNode = {
|
private let cameraNode: SCNNode = {
|
||||||
let node = SCNNode()
|
let node = SCNNode()
|
||||||
node.camera = SCNCamera()
|
node.camera = SCNCamera()
|
||||||
@@ -186,33 +452,97 @@ private struct GamutSceneView: NSViewRepresentable {
|
|||||||
return node
|
return node
|
||||||
}()
|
}()
|
||||||
|
|
||||||
func buildScene(profile: GamutMesh?, reference: GamutMesh?) {
|
/// Builds the static scene furniture exactly once — axis
|
||||||
guard let scene else { return }
|
/// scaffold, lights, camera home. Layer content lives under
|
||||||
|
/// `layerGroup` and is managed by `syncLayers`.
|
||||||
|
func buildSceneOnce() {
|
||||||
|
guard let scene, scene.rootNode.childNodes.isEmpty else { return }
|
||||||
|
|
||||||
// Rebuild from scratch on every mesh change to avoid stale geometry.
|
|
||||||
scene.rootNode.childNodes.forEach { $0.removeFromParentNode() }
|
|
||||||
scene.rootNode.addChildNode(axisNode)
|
scene.rootNode.addChildNode(axisNode)
|
||||||
scene.rootNode.addChildNode(profileNode)
|
scene.rootNode.addChildNode(layerGroup)
|
||||||
scene.rootNode.addChildNode(referenceGroup)
|
|
||||||
scene.rootNode.addChildNode(cameraNode)
|
scene.rootNode.addChildNode(cameraNode)
|
||||||
|
|
||||||
buildAxisScaffold()
|
buildAxisScaffold()
|
||||||
|
|
||||||
if let profile {
|
|
||||||
profileNode.addChildNode(profileMeshNode(profile, name: "profile"))
|
|
||||||
} else {
|
|
||||||
profileNode.childNodes.forEach { $0.removeFromParentNode() }
|
|
||||||
}
|
|
||||||
|
|
||||||
if let reference {
|
|
||||||
referenceGroup.childNodes.forEach { $0.removeFromParentNode() }
|
|
||||||
referenceGroup.addChildNode(referenceMeshNode(reference))
|
|
||||||
}
|
|
||||||
|
|
||||||
addLights(to: scene)
|
addLights(to: scene)
|
||||||
resetCamera()
|
resetCamera()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reconciles the node set with `layers` and `visibleIDs`.
|
||||||
|
///
|
||||||
|
/// New layers get a node; removed layers lose theirs; hidden
|
||||||
|
/// layers keep their mesh (`isHidden` only). Never rebuilds the
|
||||||
|
/// scene, so the camera is untouched by a checkbox toggle.
|
||||||
|
func syncLayers(_ layers: [NamedGamut], visibleIDs: Set<String>) {
|
||||||
|
guard scene != nil else { return }
|
||||||
|
let wanted = Set(layers.map { $0.id })
|
||||||
|
for (id, node) in layerNodes where !wanted.contains(id) {
|
||||||
|
node.removeFromParentNode()
|
||||||
|
layerNodes.removeValue(forKey: id)
|
||||||
|
}
|
||||||
|
for layer in layers {
|
||||||
|
if layerNodes[layer.id] == nil {
|
||||||
|
let node = makeLayerNode(for: layer)
|
||||||
|
layerNodes[layer.id] = node
|
||||||
|
layerGroup.addChildNode(node)
|
||||||
|
}
|
||||||
|
layerNodes[layer.id]?.isHidden = !visibleIDs.contains(layer.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeLayerNode(for layer: NamedGamut) -> SCNNode {
|
||||||
|
let node: SCNNode
|
||||||
|
switch layer.role {
|
||||||
|
case .reference:
|
||||||
|
node = referenceMeshNode(layer.mesh)
|
||||||
|
case .profileA:
|
||||||
|
node = profileMeshNode(layer.mesh)
|
||||||
|
case .profileB:
|
||||||
|
node = compareMeshNode(layer.mesh)
|
||||||
|
}
|
||||||
|
node.name = layer.id
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Click inspect (#147)
|
||||||
|
|
||||||
|
/// Click (not drag) hit-tests the scene. `NSClickGestureRecognizer`
|
||||||
|
/// only fires on a press+release in place, so orbit drags are
|
||||||
|
/// untouched.
|
||||||
|
func installClickGesture() {
|
||||||
|
guard let scnView, clickGesture == nil else { return }
|
||||||
|
let gesture = NSClickGestureRecognizer(target: self, action: #selector(handleClick(_:)))
|
||||||
|
scnView.addGestureRecognizer(gesture)
|
||||||
|
clickGesture = gesture
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func handleClick(_ gesture: NSClickGestureRecognizer) {
|
||||||
|
guard let scnView else { return }
|
||||||
|
let point = gesture.location(in: scnView)
|
||||||
|
for hit in scnView.hitTest(point, options: nil) {
|
||||||
|
if let layerID = layerID(for: hit.node) {
|
||||||
|
let world = hit.worldCoordinates
|
||||||
|
onInspect(
|
||||||
|
SIMD3<Float>(Float(world.x), Float(world.y), Float(world.z)),
|
||||||
|
layerID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Axis scaffold / empty background → back to idle.
|
||||||
|
onInspect(.zero, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walks the hit node's ancestor chain looking for a layer node.
|
||||||
|
private func layerID(for node: SCNNode) -> String? {
|
||||||
|
var current: SCNNode? = node
|
||||||
|
while let node = current {
|
||||||
|
if let name = node.name, layerNodes[name] != nil { return name }
|
||||||
|
current = node.parent
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Scene furniture (unchanged from #28)
|
||||||
|
|
||||||
private func addLights(to scene: SCNScene) {
|
private func addLights(to scene: SCNScene) {
|
||||||
let ambient = SCNNode()
|
let ambient = SCNNode()
|
||||||
ambient.light = SCNLight()
|
ambient.light = SCNLight()
|
||||||
@@ -353,7 +683,8 @@ private struct GamutSceneView: NSViewRepresentable {
|
|||||||
return SCNNode(geometry: geometry)
|
return SCNNode(geometry: geometry)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func profileMeshNode(_ mesh: GamutMesh, name: String) -> SCNNode {
|
/// Profile A: solid vertex-coloured surface.
|
||||||
|
private func profileMeshNode(_ mesh: GamutMesh) -> SCNNode {
|
||||||
let (geometry, _) = scnGeometry(for: mesh)
|
let (geometry, _) = scnGeometry(for: mesh)
|
||||||
|
|
||||||
let material = SCNMaterial()
|
let material = SCNMaterial()
|
||||||
@@ -363,11 +694,26 @@ private struct GamutSceneView: NSViewRepresentable {
|
|||||||
material.isDoubleSided = true
|
material.isDoubleSided = true
|
||||||
geometry.materials = [material]
|
geometry.materials = [material]
|
||||||
|
|
||||||
let node = SCNNode(geometry: geometry)
|
return SCNNode(geometry: geometry)
|
||||||
node.name = name
|
|
||||||
return node
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
private func referenceMeshNode(_ mesh: GamutMesh) -> SCNNode {
|
||||||
let (geometry, _) = scnGeometry(for: mesh)
|
let (geometry, _) = scnGeometry(for: mesh)
|
||||||
|
|
||||||
@@ -431,6 +777,31 @@ private struct GamutSceneView: NSViewRepresentable {
|
|||||||
scnView?.isPlaying = false
|
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() {
|
func resetCamera() {
|
||||||
guard let scnView else { return }
|
guard let scnView else { return }
|
||||||
|
|
||||||
@@ -465,3 +836,81 @@ private struct GamutSceneView: NSViewRepresentable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Click-to-sample image view for the TIFF preview sheet (#147).
|
||||||
|
///
|
||||||
|
/// The TIFF is already decoded to PNG on the host side (#58); the view
|
||||||
|
/// reports 8-bit sRGB pixel values at the clicked point — the Lab
|
||||||
|
/// conversion is the documented approximate matrix helper, not a CMM.
|
||||||
|
private struct TiffSampleImageView: NSViewRepresentable {
|
||||||
|
let pngData: Data
|
||||||
|
var onSample: (Int, Int, Int) -> Void
|
||||||
|
|
||||||
|
func makeNSView(context: Context) -> TiffSampleNSView {
|
||||||
|
let view = TiffSampleNSView()
|
||||||
|
view.image = NSImage(data: pngData)
|
||||||
|
view.onSample = onSample
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateNSView(_ nsView: TiffSampleNSView, context: Context) {
|
||||||
|
nsView.onSample = onSample
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class TiffSampleNSView: NSView {
|
||||||
|
var image: NSImage? {
|
||||||
|
didSet {
|
||||||
|
bitmapRep = image?.cgImage(forProposedRect: nil, context: nil, hints: nil)
|
||||||
|
.flatMap { NSBitmapImageRep(cgImage: $0) }
|
||||||
|
invalidateIntrinsicContentSize()
|
||||||
|
needsDisplay = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var onSample: ((Int, Int, Int) -> Void)?
|
||||||
|
private var bitmapRep: NSBitmapImageRep?
|
||||||
|
|
||||||
|
override var intrinsicContentSize: NSSize {
|
||||||
|
image?.size ?? NSSize(width: 320, height: 240)
|
||||||
|
}
|
||||||
|
|
||||||
|
override var acceptsFirstResponder: Bool { true }
|
||||||
|
|
||||||
|
override func draw(_ dirtyRect: NSRect) {
|
||||||
|
NSColor(red: 0.055, green: 0.055, blue: 0.078, alpha: 1).setFill()
|
||||||
|
dirtyRect.fill()
|
||||||
|
guard let image else { return }
|
||||||
|
image.draw(in: imageRect())
|
||||||
|
}
|
||||||
|
|
||||||
|
override func mouseUp(with event: NSEvent) {
|
||||||
|
guard let rep = bitmapRep else { return }
|
||||||
|
let rect = imageRect()
|
||||||
|
let location = convert(event.locationInWindow, from: nil)
|
||||||
|
guard rect.contains(location), rect.width > 0, rect.height > 0 else { return }
|
||||||
|
|
||||||
|
let x = Int((location.x - rect.minX) / rect.width * CGFloat(rep.pixelsWide))
|
||||||
|
// This view is not flipped: y grows up, bitmap rows grow down.
|
||||||
|
let y = rep.pixelsHigh - 1
|
||||||
|
- Int((location.y - rect.minY) / rect.height * CGFloat(rep.pixelsHigh))
|
||||||
|
guard x >= 0, x < rep.pixelsWide, y >= 0, y < rep.pixelsHigh else { return }
|
||||||
|
|
||||||
|
guard let color = rep.colorAt(x: x, y: y)?.usingColorSpace(.sRGB) else { return }
|
||||||
|
onSample?(
|
||||||
|
Int((color.redComponent * 255).rounded()),
|
||||||
|
Int((color.greenComponent * 255).rounded()),
|
||||||
|
Int((color.blueComponent * 255).rounded()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aspect-fit rect of the image inside `bounds`.
|
||||||
|
private func imageRect() -> NSRect {
|
||||||
|
guard let image, image.size.width > 0, image.size.height > 0 else { return .zero }
|
||||||
|
let scale = min(bounds.width / image.size.width, bounds.height / image.size.height)
|
||||||
|
let size = NSSize(width: image.size.width * scale, height: image.size.height * scale)
|
||||||
|
return NSRect(
|
||||||
|
x: (bounds.width - size.width) / 2,
|
||||||
|
y: (bounds.height - size.height) / 2,
|
||||||
|
width: size.width,
|
||||||
|
height: size.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,54 +1,318 @@
|
|||||||
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import ICCeryCore
|
import ICCeryCore
|
||||||
import Observation
|
import Metal
|
||||||
|
import simd
|
||||||
|
|
||||||
/// View model for the native SceneKit gamut viewer.
|
/// Whether the SceneKit gamut scene can render on this host (#147).
|
||||||
///
|
///
|
||||||
/// Loads the bundled `sRGB.gam` reference immediately and, optionally, a
|
/// Checked **before** `GamutSceneView` is mounted — constructing an
|
||||||
/// printer/profile `.gam` from the current working directory.
|
/// `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
|
@MainActor
|
||||||
@Observable
|
final class GamutViewModel: ObservableObject {
|
||||||
final class GamutViewModel {
|
|
||||||
|
|
||||||
/// Parsed reference sRGB gamut mesh.
|
/// Stable layer ids — also the `gamutLayer-<id>` a11y suffixes.
|
||||||
var sRGBMesh: GamutMesh?
|
static let srgbLayerID = "sRGB"
|
||||||
|
static let profileLayerID = "profile"
|
||||||
|
static let compareLayerID = "compare"
|
||||||
|
|
||||||
/// Parsed printer/profile gamut mesh.
|
/// Loaded meshes: bundled sRGB plus up to two profiles.
|
||||||
var profileMesh: GamutMesh?
|
@Published var layers: [NamedGamut] = []
|
||||||
|
|
||||||
/// User-facing status line.
|
/// Layer ids currently shown in the scene. Toggling never unloads
|
||||||
var status = "Loading gamut…"
|
/// the mesh — the `SCNNode` is hidden only.
|
||||||
|
@Published var visibleIDs: Set<String> = [srgbLayerID]
|
||||||
|
|
||||||
|
/// User-facing status line (`gamutStatusText`). Always non-empty
|
||||||
|
/// once set — `Milestone6GamutUITests` asserts it.
|
||||||
|
@Published var status = "Loading gamut…"
|
||||||
|
|
||||||
|
/// In-sheet info line (`gamutNoticeText`). The main `NoticeBanner`
|
||||||
|
/// sits behind the sheet, so notices surface here instead.
|
||||||
|
@Published var noticeText: String?
|
||||||
|
|
||||||
|
/// Set when `SCNView` cannot create a render context; the scene is
|
||||||
|
/// replaced by the docs/18 fallback text (`gamutViewerUnavailable`).
|
||||||
|
@Published var viewerUnavailable = false
|
||||||
|
|
||||||
/// Closure injected into the SceneKit view to request a camera reset.
|
/// Closure injected into the SceneKit view to request a camera reset.
|
||||||
var resetCamera: () -> Void = {}
|
@Published var resetCamera: () -> Void = {}
|
||||||
|
|
||||||
|
// MARK: - Inspect panel
|
||||||
|
|
||||||
|
/// Lab point currently inspected, or `nil` for the idle state.
|
||||||
|
@Published var inspectLab: LabColor?
|
||||||
|
|
||||||
|
/// Swatch colour: the hit vertex's `rgb`, or the approximate sRGB of
|
||||||
|
/// the inspected Lab.
|
||||||
|
@Published var inspectSwatch: DisplayRGB?
|
||||||
|
|
||||||
|
/// `true` when the swatch/Lab came from the approximate helper or a
|
||||||
|
/// typed value — drives the "approx. Lab, not ColorSync" caption.
|
||||||
|
@Published var inspectIsApproximate = false
|
||||||
|
|
||||||
|
/// Per-layer containment for `inspectLab`, in layer order.
|
||||||
|
@Published var inspectResults: [(id: String, name: String, containment: GamutContainment)] = []
|
||||||
|
|
||||||
|
/// Manual Lab entry fields (`gamutLabEntry*`).
|
||||||
|
@Published var labEntryL = ""
|
||||||
|
@Published var labEntryA = ""
|
||||||
|
@Published var labEntryB = ""
|
||||||
|
|
||||||
|
// MARK: - TIFF sampling
|
||||||
|
|
||||||
|
/// PNG bytes for the preview sheet (`gamutTiffPreview`).
|
||||||
|
@Published var tiffPreviewPNG: Data?
|
||||||
|
@Published var showingTiffPreview = false
|
||||||
|
|
||||||
|
private let environment: AppEnvironment
|
||||||
private let profileGamURL: URL?
|
private let profileGamURL: URL?
|
||||||
|
private let fileDialogs = FileDialogService.shared
|
||||||
|
|
||||||
init(profileGamURL: URL? = nil) {
|
init(environment: AppEnvironment, profileGamURL: URL? = nil) {
|
||||||
|
self.environment = environment
|
||||||
self.profileGamURL = profileGamURL
|
self.profileGamURL = profileGamURL
|
||||||
Task { await load() }
|
// Never let the view mount an SCNView without Metal (#147).
|
||||||
|
viewerUnavailable = !GamutSceneAvailability.isAvailable
|
||||||
|
loadTask = Task { await load() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var loadTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
/// Awaits the initial sRGB/profile load — used by tests.
|
||||||
|
func awaitInitialLoad() async {
|
||||||
|
await loadTask?.value
|
||||||
|
}
|
||||||
|
|
||||||
|
func layer(id: String) -> NamedGamut? {
|
||||||
|
layers.first { $0.id == id }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Initial load
|
||||||
|
|
||||||
private func load() async {
|
private func load() async {
|
||||||
do {
|
do {
|
||||||
let referenceURL = BinaryResolver().referenceGamut("sRGB")
|
let referenceURL = environment.runner.binaryResolver.referenceGamut("sRGB")
|
||||||
let reference = try await parse(url: referenceURL)
|
let reference = try await parse(url: referenceURL)
|
||||||
sRGBMesh = reference
|
layers.append(NamedGamut(
|
||||||
|
id: Self.srgbLayerID,
|
||||||
if let profileGamURL {
|
displayName: "sRGB",
|
||||||
let profile = try await parse(url: profileGamURL)
|
role: .reference,
|
||||||
profileMesh = profile
|
mesh: reference,
|
||||||
status = "Profile gamut (\(profile.faces.count) faces) vs sRGB reference"
|
sourceURL: referenceURL))
|
||||||
} else {
|
visibleIDs.insert(Self.srgbLayerID)
|
||||||
status = "sRGB reference gamut (\(reference.faces.count) faces)"
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
status = "Could not load gamut: \(error.localizedDescription)"
|
status = "Could not load gamut: \(error.localizedDescription)"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let profileGamURL {
|
||||||
|
do {
|
||||||
|
let profile = try await parse(url: profileGamURL)
|
||||||
|
layers.append(NamedGamut(
|
||||||
|
id: Self.profileLayerID,
|
||||||
|
displayName: profileGamURL.deletingPathExtension().lastPathComponent,
|
||||||
|
role: .profileA,
|
||||||
|
mesh: profile,
|
||||||
|
sourceURL: profileGamURL))
|
||||||
|
visibleIDs.insert(Self.profileLayerID)
|
||||||
|
} catch {
|
||||||
|
// #24 — a missing/unparseable profile mesh is info, not fatal.
|
||||||
|
noticeText = "Profile gamut could not be loaded: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refreshStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Compare slot (profile B)
|
||||||
|
|
||||||
|
/// `btnGamutOpenGam` — pick an existing `.gam` for the compare slot.
|
||||||
|
func openCompareGam() {
|
||||||
|
let url = UITestHooks.isEnabled
|
||||||
|
? UITestHooks.gamutFileURL
|
||||||
|
: fileDialogs.selectGamutFile()
|
||||||
|
guard let url else { return }
|
||||||
|
Task { await loadCompareGam(url: url) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `btnGamutOpenProfile` — pick `.icc/.icm`; uses a sibling `.gam`
|
||||||
|
/// when present, otherwise runs bundled `iccgamut -v -d 10` (#24).
|
||||||
|
func openCompareProfile() {
|
||||||
|
let url = UITestHooks.isEnabled
|
||||||
|
? UITestHooks.gamutProfileURL
|
||||||
|
: fileDialogs.selectProfileFile()
|
||||||
|
guard let url else { return }
|
||||||
|
Task { await loadCompareProfile(url: url) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `btnGamutRemoveCompare` — drops layer B, leaves sRGB + A.
|
||||||
|
func removeCompare() {
|
||||||
|
layers.removeAll { $0.id == Self.compareLayerID }
|
||||||
|
visibleIDs.remove(Self.compareLayerID)
|
||||||
|
refreshStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses `url` into the compare slot. Internal for tests — the UI
|
||||||
|
/// reaches it through `openCompareGam` / `openCompareProfile`.
|
||||||
|
func loadCompareGam(url: URL) async {
|
||||||
|
do {
|
||||||
|
let mesh = try await parse(url: url)
|
||||||
|
installCompare(NamedGamut(
|
||||||
|
id: Self.compareLayerID,
|
||||||
|
displayName: url.deletingPathExtension().lastPathComponent,
|
||||||
|
role: .profileB,
|
||||||
|
mesh: mesh,
|
||||||
|
sourceURL: url))
|
||||||
|
} catch {
|
||||||
|
noticeText = "Could not load compare gamut: \(error.localizedDescription)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parses a `.gam` file off the main actor so large meshes do not stall
|
/// `.icc/.icm` → sibling `.gam` or `iccgamut` → compare slot.
|
||||||
/// the UI.
|
/// 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<Float>, layerID: String) {
|
||||||
|
let lab = LabColor(l: Double(world.y), a: Double(world.x), b: Double(world.z))
|
||||||
|
var swatch: DisplayRGB?
|
||||||
|
var approximate = true
|
||||||
|
if let mesh = layer(id: layerID)?.mesh,
|
||||||
|
let nearest = mesh.vertices.min(by: {
|
||||||
|
simd_distance($0.position, world) < simd_distance($1.position, world)
|
||||||
|
}) {
|
||||||
|
swatch = nearest.rgb
|
||||||
|
approximate = false
|
||||||
|
}
|
||||||
|
inspect(lab: lab, swatch: swatch, isApproximate: approximate)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `btnGamutInspectLab` — typed L*a*b* path. No clamping; out-of-axis
|
||||||
|
/// values still run containment and report `?` outside every hull.
|
||||||
|
func inspectEnteredLab() {
|
||||||
|
guard let l = Double(labEntryL),
|
||||||
|
let a = Double(labEntryA),
|
||||||
|
let b = Double(labEntryB) else { return }
|
||||||
|
inspect(lab: LabColor(l: l, a: a, b: b), swatch: nil, isApproximate: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - TIFF sampling
|
||||||
|
|
||||||
|
/// `btnGamutSampleTiff` — pick a target TIFF, decode a host-side PNG
|
||||||
|
/// preview (#58), and open the click-to-sample sheet.
|
||||||
|
func openTiffSample() {
|
||||||
|
let url = UITestHooks.isEnabled
|
||||||
|
? UITestHooks.gamutTiffURL
|
||||||
|
: fileDialogs.selectTiffFile()
|
||||||
|
guard let url else { return }
|
||||||
|
guard let png = TiffPreview.previewPNG(tiff: url) else {
|
||||||
|
noticeText = "Could not decode TIFF preview."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tiffPreviewPNG = png
|
||||||
|
showingTiffPreview = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pixel tap inside the preview sheet: sRGB8 → approximate Lab D50.
|
||||||
|
func sampleTiffPixel(r: Int, g: Int, b: Int) {
|
||||||
|
inspect(
|
||||||
|
lab: ApproximateLab.srgb8ToLab(r: r, g: g, b: b),
|
||||||
|
swatch: DisplayRGB(
|
||||||
|
r: Double(r) / 255.0,
|
||||||
|
g: Double(g) / 255.0,
|
||||||
|
b: Double(b) / 255.0),
|
||||||
|
isApproximate: true)
|
||||||
|
showingTiffPreview = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a `.gam` file off the main actor so large meshes do not
|
||||||
|
/// stall the UI.
|
||||||
private func parse(url: URL) async throws -> GamutMesh {
|
private func parse(url: URL) async throws -> GamutMesh {
|
||||||
try await Task.detached {
|
try await Task.detached {
|
||||||
try GamutMeshParser.parse(url: url)
|
try GamutMeshParser.parse(url: url)
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import SwiftUI
|
|||||||
@main
|
@main
|
||||||
struct ICCeryApp: App {
|
struct ICCeryApp: App {
|
||||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||||
@State private var workflow: TargetWorkflowViewModel
|
@StateObject private var workflow: TargetWorkflowViewModel
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
let environment = AppEnvironment.live()
|
let environment = AppEnvironment.live()
|
||||||
_workflow = State(initialValue: TargetWorkflowViewModel(environment: environment))
|
_workflow = StateObject(wrappedValue: TargetWorkflowViewModel(environment: environment))
|
||||||
try? AppPaths.ensureDirectories()
|
try? AppPaths.ensureDirectories()
|
||||||
// Log level is runtime state — apply persisted settings at
|
// Log level is runtime state — apply persisted settings at
|
||||||
// startup (#158); the Settings sheet re-applies on save.
|
// startup (#158); the Settings sheet re-applies on save.
|
||||||
@@ -17,15 +17,22 @@ struct ICCeryApp: App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
// Single fixed window (docs/21 §Shell: 1280×800, min 1100×700).
|
// Single fixed window (docs/21 §Shell: 1280×800, min 1100×700);
|
||||||
Window("ICCery", id: "main") {
|
// metrics are applied by AppDelegate once the window exists.
|
||||||
|
WindowGroup("ICCery") {
|
||||||
RootView(workflow: workflow)
|
RootView(workflow: workflow)
|
||||||
.frame(minWidth: 1100, minHeight: 700)
|
.frame(minWidth: 1100, minHeight: 700)
|
||||||
.preferredColorScheme(.dark)
|
.preferredColorScheme(.dark)
|
||||||
}
|
}
|
||||||
.defaultSize(width: 1280, height: 800)
|
.commands {
|
||||||
.windowResizability(.contentMinSize)
|
// Single-window app: the File menu carries the project
|
||||||
.defaultPosition(.center)
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +43,62 @@ struct ICCeryApp: App {
|
|||||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
private var terminationRequested = false
|
private var terminationRequested = false
|
||||||
|
|
||||||
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
|
// SwiftUI 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 {
|
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import ICCeryCore
|
import ICCeryCore
|
||||||
|
|
||||||
@@ -32,8 +32,7 @@ enum XYStep: Equatable, Sendable {
|
|||||||
|
|
||||||
/// Stage 3 workflow state and interaction (issues #18–#22).
|
/// Stage 3 workflow state and interaction (issues #18–#22).
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
final class MeasurementWorkflowViewModel: ObservableObject {
|
||||||
final class MeasurementWorkflowViewModel {
|
|
||||||
|
|
||||||
// MARK: - Authorities
|
// MARK: - Authorities
|
||||||
|
|
||||||
@@ -42,36 +41,37 @@ final class MeasurementWorkflowViewModel {
|
|||||||
|
|
||||||
// MARK: - Settings-driven thresholds
|
// MARK: - Settings-driven thresholds
|
||||||
|
|
||||||
private(set) var goodMax: Double = 2.0
|
@Published private(set) var goodMax: Double = 2.0
|
||||||
private(set) var warningMax: Double = 5.0
|
@Published private(set) var warningMax: Double = 5.0
|
||||||
private(set) var enableLEDs: Bool = false
|
@Published private(set) var enableLEDs: Bool = false
|
||||||
|
|
||||||
// MARK: - Instrument detection
|
// MARK: - Instrument detection
|
||||||
|
|
||||||
var instruments: [InstrumentDevice] = []
|
@Published var instruments: [InstrumentDevice] = []
|
||||||
var selectedInstrument: InstrumentSelection = .auto
|
@Published var selectedInstrument: InstrumentSelection = .auto
|
||||||
var isDetecting = false
|
@Published var isDetecting = false
|
||||||
var detectionError: String?
|
@Published var detectionError: String?
|
||||||
|
|
||||||
// MARK: - Chartread session
|
// MARK: - Chartread session
|
||||||
|
|
||||||
var isChartreadRunning = false
|
@Published var isChartreadRunning = false
|
||||||
var chartreadState: ChartreadState = .idle
|
@Published var chartreadState: ChartreadState = .idle
|
||||||
var currentPrompt: String?
|
@Published var currentPrompt: String?
|
||||||
var requestedWarningKey: String?
|
@Published var requestedWarningKey: String?
|
||||||
var chartreadLog: [String] = []
|
@Published var chartreadLog: [String] = []
|
||||||
var rows: [ChartreadRow] = []
|
@Published var rows: [ChartreadRow] = []
|
||||||
var swatchRows: [SwatchRow] = []
|
@Published var swatchRows: [SwatchRow] = []
|
||||||
var showRemoveSheetNotice = false
|
@Published var showRemoveSheetNotice = false
|
||||||
var lastError: String?
|
/// Stage-local chartread error notice (`#chartreadLastError`, #80).
|
||||||
|
@Published var chartreadNotice: Notice?
|
||||||
private var chartreadTask: Task<Void, Never>?
|
private var chartreadTask: Task<Void, Never>?
|
||||||
|
|
||||||
// MARK: - Averaging
|
// MARK: - Averaging
|
||||||
|
|
||||||
var passSnapshots: [URL] = []
|
@Published var passSnapshots: [URL] = []
|
||||||
var isFinishing = false
|
@Published var isFinishing = false
|
||||||
var finishNotice: Notice?
|
@Published var finishNotice: Notice?
|
||||||
var resumedFromTi2 = false
|
@Published var resumedFromTi2 = false
|
||||||
|
|
||||||
init(wizard: WizardViewModel, environment: AppEnvironment) {
|
init(wizard: WizardViewModel, environment: AppEnvironment) {
|
||||||
self.wizard = wizard
|
self.wizard = wizard
|
||||||
@@ -185,7 +185,7 @@ final class MeasurementWorkflowViewModel {
|
|||||||
isChartreadRunning = true
|
isChartreadRunning = true
|
||||||
chartreadState = .idle
|
chartreadState = .idle
|
||||||
currentPrompt = nil
|
currentPrompt = nil
|
||||||
lastError = nil
|
chartreadNotice = nil
|
||||||
chartreadLog.removeAll()
|
chartreadLog.removeAll()
|
||||||
|
|
||||||
// Optional: reset rows when starting a fresh first pass.
|
// Optional: reset rows when starting a fresh first pass.
|
||||||
@@ -227,7 +227,10 @@ final class MeasurementWorkflowViewModel {
|
|||||||
|
|
||||||
case .exit(let code):
|
case .exit(let code):
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
lastError = "chartread exited with code \(code)"
|
chartreadNotice = Notice(
|
||||||
|
kind: .error,
|
||||||
|
text: "chartread exited with code \(code)"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
case .completed(let canonicalURL):
|
case .completed(let canonicalURL):
|
||||||
@@ -235,7 +238,7 @@ final class MeasurementWorkflowViewModel {
|
|||||||
completePass(canonicalURL: canonicalURL)
|
completePass(canonicalURL: canonicalURL)
|
||||||
|
|
||||||
case .failed(let error):
|
case .failed(let error):
|
||||||
lastError = error.localizedDescription
|
chartreadNotice = Notice(kind: .error, text: error.localizedDescription)
|
||||||
chartreadState = .error
|
chartreadState = .error
|
||||||
isChartreadRunning = false
|
isChartreadRunning = false
|
||||||
}
|
}
|
||||||
@@ -381,7 +384,10 @@ final class MeasurementWorkflowViewModel {
|
|||||||
discoverPassSnapshots()
|
discoverPassSnapshots()
|
||||||
wizard.refreshGating()
|
wizard.refreshGating()
|
||||||
} catch {
|
} catch {
|
||||||
lastError = "Could not snapshot pass: \(error.localizedDescription)"
|
chartreadNotice = Notice(
|
||||||
|
kind: .error,
|
||||||
|
text: "Could not snapshot pass: \(error.localizedDescription)"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,30 +403,32 @@ final class MeasurementWorkflowViewModel {
|
|||||||
|
|
||||||
func finishAndAverage() {
|
func finishAndAverage() {
|
||||||
guard !isFinishing, let cwd = workingDirectory, !passSnapshots.isEmpty else { return }
|
guard !isFinishing, let cwd = workingDirectory, !passSnapshots.isEmpty else { return }
|
||||||
isFinishing = true
|
|
||||||
finishNotice = nil
|
finishNotice = nil
|
||||||
|
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
do {
|
do {
|
||||||
let canonical: URL
|
// No log reset: prior chartread output must be preserved.
|
||||||
|
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 {
|
if self.passSnapshots.count == 1, let pass = self.passSnapshots.first {
|
||||||
canonical = try MeasurementArtefacts.promotePass(
|
return try MeasurementArtefacts.promotePass(
|
||||||
pass: pass,
|
pass: pass,
|
||||||
basename: self.basename,
|
basename: self.basename,
|
||||||
cwd: cwd
|
cwd: cwd
|
||||||
)
|
)
|
||||||
} else {
|
}
|
||||||
let config = AverageConfig(
|
let config = AverageConfig(
|
||||||
workingDirectory: cwd,
|
workingDirectory: cwd,
|
||||||
basename: self.basename,
|
basename: self.basename,
|
||||||
passFiles: self.passSnapshots
|
passFiles: self.passSnapshots
|
||||||
)
|
)
|
||||||
canonical = try await self.environment.runner.runAverage(
|
return try await self.environment.runner.runAverage(
|
||||||
config: config,
|
config: config,
|
||||||
onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
|
onLogBatch: onLog
|
||||||
self?.chartreadLog.append(contentsOf: batch)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
self.discoverPassSnapshots()
|
self.discoverPassSnapshots()
|
||||||
@@ -465,7 +473,6 @@ final class MeasurementWorkflowViewModel {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.isFinishing = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<AnyCancellable>()
|
||||||
|
|
||||||
|
@Published var recipes: [MediaRecipe] = []
|
||||||
|
/// Sidebar picker selection; `"none"` = no media recipe. Written
|
||||||
|
/// only on a successful apply so a failed apply snaps back.
|
||||||
|
@Published var selectedRecipeID = "none"
|
||||||
|
/// `recipe.id` → stale reasons for the sidebar badge / manage sheet.
|
||||||
|
@Published var staleReasons: [String: StaleReason] = [:]
|
||||||
|
/// `recipe.id` → whole days since the bound `.cal` was created.
|
||||||
|
@Published var calAgeDays: [String: Int] = [:]
|
||||||
|
|
||||||
|
// Save-sheet state (mirrors savePresetName/savePresetDesc).
|
||||||
|
@Published var saveMediaName = ""
|
||||||
|
@Published var saveMediaNotes = ""
|
||||||
|
@Published var saveMediaPaper = ""
|
||||||
|
@Published var saveMediaInk = ""
|
||||||
|
@Published var saveMediaApplyCal = false
|
||||||
|
/// Inline caption inside the capture sheet (no a11y id — roster complete).
|
||||||
|
@Published var saveMediaError: String?
|
||||||
|
/// 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,14 @@ struct Notice: Identifiable, Equatable {
|
|||||||
case .error: return .red
|
case .error: return .red
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var accessibilityValue: String {
|
||||||
|
switch self {
|
||||||
|
case .info: return "info"
|
||||||
|
case .warning: return "warning"
|
||||||
|
case .error: return "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let id = UUID()
|
let id = UUID()
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import ICCeryCore
|
|||||||
/// `#savePresetDialog` — save the live Stage 1/2 form as a custom
|
/// `#savePresetDialog` — save the live Stage 1/2 form as a custom
|
||||||
/// preset (issue #11). Names/descriptions render via `Text` only (#114).
|
/// preset (issue #11). Names/descriptions render via `Text` only (#114).
|
||||||
struct SavePresetDialog: View {
|
struct SavePresetDialog: View {
|
||||||
@Bindable var workflow: TargetWorkflowViewModel
|
@ObservedObject var workflow: TargetWorkflowViewModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 14) {
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
@@ -35,7 +35,7 @@ struct SavePresetDialog: View {
|
|||||||
|
|
||||||
/// `#managePresetsDialog` — list, delete (custom only), import, export.
|
/// `#managePresetsDialog` — list, delete (custom only), import, export.
|
||||||
struct ManagePresetsDialog: View {
|
struct ManagePresetsDialog: View {
|
||||||
@Bindable var workflow: TargetWorkflowViewModel
|
@ObservedObject var workflow: TargetWorkflowViewModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
|||||||
@@ -17,6 +17,24 @@ enum PrintPanelError: LocalizedError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stage 2 selections pre-applied to the bound print panel before it
|
||||||
|
/// opens (#183/#186). Every write is warn-only — the panel still
|
||||||
|
/// opens when a driver ignores a key.
|
||||||
|
struct PrintPanelInitialSelections {
|
||||||
|
/// CUPS `PageSize` token, e.g. `"A4"` / `"Custom.595x842"`. Written
|
||||||
|
/// to `PMPrintSettings` **and** `PMPageFormat` (#186 E1).
|
||||||
|
var paperSize: String?
|
||||||
|
/// The queue's detected quality enumeration key, e.g. `EPIJ_Qual`.
|
||||||
|
var qualityKey: String?
|
||||||
|
/// The selected quality token.
|
||||||
|
var quality: String?
|
||||||
|
/// The selected media token — written to the queue's detected
|
||||||
|
/// vendor key (`CNIJMediaType`/`EPIJ_Medi`/…) (#186).
|
||||||
|
var mediaType: String?
|
||||||
|
/// `"portrait"`/`"landscape"` → `orientation-requested` 3|4 (#186).
|
||||||
|
var orientation: String?
|
||||||
|
}
|
||||||
|
|
||||||
/// Preferences → native `NSPrintPanel` bound to the selected CUPS
|
/// Preferences → native `NSPrintPanel` bound to the selected CUPS
|
||||||
/// queue (issue 13, docs/11).
|
/// queue (issue 13, docs/11).
|
||||||
///
|
///
|
||||||
@@ -41,7 +59,9 @@ struct PrintPanelService {
|
|||||||
func showProperties(
|
func showProperties(
|
||||||
queue: String,
|
queue: String,
|
||||||
displayName: String?,
|
displayName: String?,
|
||||||
cupsService: CupsService
|
cupsService: CupsService,
|
||||||
|
initialSelections: PrintPanelInitialSelections =
|
||||||
|
PrintPanelInitialSelections()
|
||||||
) async throws -> PrintPropertiesResult? {
|
) async throws -> PrintPropertiesResult? {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
if UITestHooks.printPanelStubbed {
|
if UITestHooks.printPanelStubbed {
|
||||||
@@ -56,7 +76,8 @@ struct PrintPanelService {
|
|||||||
let optionKeys = (try? await cupsService.optionKeys(for: queue))
|
let optionKeys = (try? await cupsService.optionKeys(for: queue))
|
||||||
?? []
|
?? []
|
||||||
return try runNativePanel(
|
return try runNativePanel(
|
||||||
queue: queue, displayName: display, optionKeys: optionKeys)
|
queue: queue, displayName: display, optionKeys: optionKeys,
|
||||||
|
initialSelections: initialSelections)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Panel
|
// MARK: - Panel
|
||||||
@@ -64,7 +85,8 @@ struct PrintPanelService {
|
|||||||
private func runNativePanel(
|
private func runNativePanel(
|
||||||
queue: String,
|
queue: String,
|
||||||
displayName: String?,
|
displayName: String?,
|
||||||
optionKeys: Set<String>
|
optionKeys: Set<String>,
|
||||||
|
initialSelections: PrintPanelInitialSelections
|
||||||
) throws -> PrintPropertiesResult? {
|
) throws -> PrintPropertiesResult? {
|
||||||
let printInfo = NSPrintInfo()
|
let printInfo = NSPrintInfo()
|
||||||
var pmPrinter: PMPrinter?
|
var pmPrinter: PMPrinter?
|
||||||
@@ -89,9 +111,26 @@ struct PrintPanelService {
|
|||||||
// queue but are not fatal when they fail.
|
// queue but are not fatal when they fail.
|
||||||
_ = PMSessionDefaultPrintSettings(session, settings)
|
_ = PMSessionDefaultPrintSettings(session, settings)
|
||||||
_ = PMSessionDefaultPageFormat(session, pageFormat)
|
_ = PMSessionDefaultPageFormat(session, pageFormat)
|
||||||
|
// Initial selections — after `PMSessionDefault*`, before
|
||||||
|
// ColorSync suppression ②–⑤ (locked write order,
|
||||||
|
// #183/#186). Paper is TWO writes (E1): the `PageSize`
|
||||||
|
// print-settings value drivers/capture read AND the
|
||||||
|
// `PMPageFormat` paper the panel's dropdown reflects.
|
||||||
|
applyInitialSelections(
|
||||||
|
initialSelections, to: settings, optionKeys: optionKeys)
|
||||||
|
if let paperToken = initialSelections.paperSize {
|
||||||
|
applyPaperPageFormat(
|
||||||
|
paperToken, printer: printer, session: session,
|
||||||
|
printInfo: printInfo)
|
||||||
|
}
|
||||||
boundViaPM = true
|
boundViaPM = true
|
||||||
} else {
|
} else {
|
||||||
// Fallback: NSPrinter by display name (docs/11 §binding).
|
// Fallback: NSPrinter by display name (docs/11 §binding).
|
||||||
|
// Warn — the display name can resolve a *different* queue
|
||||||
|
// (#186 E2: diagnosable, not a proven defect).
|
||||||
|
AppLogger.shared.warn(
|
||||||
|
"Print panel: PM binding unavailable for '\(queue)' — "
|
||||||
|
+ "falling back to NSPrinter(displayName)")
|
||||||
guard let displayName,
|
guard let displayName,
|
||||||
let nsPrinter = NSPrinter(name: displayName)
|
let nsPrinter = NSPrinter(name: displayName)
|
||||||
else {
|
else {
|
||||||
@@ -138,7 +177,9 @@ struct PrintPanelService {
|
|||||||
|
|
||||||
// ⑥ Capture the user's choices — filtered replay options plus
|
// ⑥ Capture the user's choices — filtered replay options plus
|
||||||
// the media type they picked. Re-fetch the settings handle so
|
// the media type they picked. Re-fetch the settings handle so
|
||||||
// we read back what the modal wrote.
|
// we read back what the modal wrote. Paper size, quality, and
|
||||||
|
// orientation ride back parsed from the captured `k=v` string
|
||||||
|
// (#183); the PDE may rewrite or drop them (R12).
|
||||||
var cupsOptions: String?
|
var cupsOptions: String?
|
||||||
var mediaType: String?
|
var mediaType: String?
|
||||||
if boundViaPM {
|
if boundViaPM {
|
||||||
@@ -148,6 +189,7 @@ struct PrintPanelService {
|
|||||||
cupsOptions = captured.cupsOptions
|
cupsOptions = captured.cupsOptions
|
||||||
mediaType = captured.mediaType
|
mediaType = captured.mediaType
|
||||||
}
|
}
|
||||||
|
let capturedOptions = cupsOptions ?? ""
|
||||||
return PrintPropertiesResult(
|
return PrintPropertiesResult(
|
||||||
selectedPrinter: boundViaPM
|
selectedPrinter: boundViaPM
|
||||||
? Self.currentPrinterID(
|
? Self.currentPrinterID(
|
||||||
@@ -156,11 +198,144 @@ struct PrintPanelService {
|
|||||||
fallback: queue)
|
fallback: queue)
|
||||||
: nil,
|
: nil,
|
||||||
options: PrintOptions(
|
options: PrintOptions(
|
||||||
|
orientation: CupsParsers.extractOrientation(
|
||||||
|
fromOptionsString: capturedOptions),
|
||||||
|
paperSize: CupsParsers.extractOption(
|
||||||
|
named: "PageSize", fromOptionsString: capturedOptions),
|
||||||
mediaType: mediaType,
|
mediaType: mediaType,
|
||||||
|
quality: CupsParsers.extractQuality(
|
||||||
|
fromOptionsString: capturedOptions),
|
||||||
ppdUncorrectedPassthrough: true,
|
ppdUncorrectedPassthrough: true,
|
||||||
cupsOptions: cupsOptions))
|
cupsOptions: cupsOptions))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Initial-selection `PMPrintSettings` writes — paper, quality,
|
||||||
|
/// media type, orientation. All warn-only: a driver that ignores
|
||||||
|
/// a key must not keep the panel from opening (R12 surfaces via
|
||||||
|
/// the capture echo instead).
|
||||||
|
private func applyInitialSelections(
|
||||||
|
_ selections: PrintPanelInitialSelections,
|
||||||
|
to settings: PMPrintSettings,
|
||||||
|
optionKeys: Set<String>
|
||||||
|
) {
|
||||||
|
if let paperSize = selections.paperSize {
|
||||||
|
warnOnFailure(PMPrintSettingsSetValue(
|
||||||
|
settings, "PageSize" as CFString,
|
||||||
|
paperSize as CFString, false), key: "PageSize")
|
||||||
|
}
|
||||||
|
if let key = selections.qualityKey, let value = selections.quality {
|
||||||
|
warnOnFailure(PMPrintSettingsSetValue(
|
||||||
|
settings, key as CFString,
|
||||||
|
value as CFString, false), key: key)
|
||||||
|
}
|
||||||
|
// Media type via the queue's detected vendor key (#186).
|
||||||
|
if let mediaType = selections.mediaType,
|
||||||
|
let mediaKey = CupsParsers.detectMediaTypeKey(
|
||||||
|
optionKeys: optionKeys) {
|
||||||
|
warnOnFailure(PMPrintSettingsSetValue(
|
||||||
|
settings, mediaKey as CFString,
|
||||||
|
mediaType as CFString, false), key: mediaKey)
|
||||||
|
}
|
||||||
|
// Orientation — portrait=3, landscape=4 (CUPS IPP codes).
|
||||||
|
if let orientation = selections.orientation {
|
||||||
|
let code = orientation == "landscape" ? "4" : "3"
|
||||||
|
warnOnFailure(PMPrintSettingsSetValue(
|
||||||
|
settings, "orientation-requested" as CFString,
|
||||||
|
code as CFString, false), key: "orientation-requested")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `PMPageFormat` half of paper preselect (#186 E1): the
|
||||||
|
/// panel's paper dropdown reflects the page format's `PMPaper`,
|
||||||
|
/// not `PMPrintSettings`. Match the Stage 2 `PageSize` token to a
|
||||||
|
/// paper from `PMPrinterGetPaperList`, rebuild the page format
|
||||||
|
/// around it, and copy it into the printInfo's format (TN2248:
|
||||||
|
/// `PMCreatePageFormatWithPMPaper` → `PMSessionValidatePageFormat`
|
||||||
|
/// → `PMCopyPageFormat` → `updateFromPMPageFormat`).
|
||||||
|
/// `Custom.<w>x<h>` tokens (already points) have no `PMPaper` —
|
||||||
|
/// set the Cocoa `paperSize` directly. Warn-only throughout: a
|
||||||
|
/// missed match must not keep the panel from opening.
|
||||||
|
private func applyPaperPageFormat(
|
||||||
|
_ token: String,
|
||||||
|
printer: PMPrinter,
|
||||||
|
session: PMPrintSession,
|
||||||
|
printInfo: NSPrintInfo
|
||||||
|
) {
|
||||||
|
if let custom = Self.customPaperDimensions(from: token) {
|
||||||
|
printInfo.paperSize = NSSize(
|
||||||
|
width: custom.width, height: custom.height)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var paperList: Unmanaged<CFArray>?
|
||||||
|
guard PMPrinterGetPaperList(printer, &paperList) == 0,
|
||||||
|
let papers = paperList?.takeUnretainedValue()
|
||||||
|
else {
|
||||||
|
AppLogger.shared.warn(
|
||||||
|
"Print panel: PMPrinterGetPaperList failed — "
|
||||||
|
+ "paper preselect skipped")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The list (and its elements) is owned by the printer —
|
||||||
|
// borrowed, never released.
|
||||||
|
var match: PMPaper?
|
||||||
|
for index in 0..<CFArrayGetCount(papers) {
|
||||||
|
let paper = unsafeBitCast(
|
||||||
|
CFArrayGetValueAtIndex(papers, index), to: PMPaper.self)
|
||||||
|
var idRef: Unmanaged<CFString>?
|
||||||
|
guard PMPaperGetID(paper, &idRef) == 0,
|
||||||
|
let paperID = idRef?.takeUnretainedValue() as String?
|
||||||
|
else { continue }
|
||||||
|
if paperID == token {
|
||||||
|
match = paper
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard let paper = match else {
|
||||||
|
AppLogger.shared.warn(
|
||||||
|
"Print panel: no PMPaper id matches '\(token)'")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var created: PMPageFormat?
|
||||||
|
guard PMCreatePageFormatWithPMPaper(&created, paper) == 0,
|
||||||
|
let newFormat = created
|
||||||
|
else {
|
||||||
|
AppLogger.shared.warn(
|
||||||
|
"Print panel: PMCreatePageFormatWithPMPaper failed "
|
||||||
|
+ "for '\(token)'")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer { PMRelease(unsafeBitCast(newFormat, to: PMObject.self)) }
|
||||||
|
_ = PMSessionValidatePageFormat(session, newFormat, nil)
|
||||||
|
let destination = unsafeBitCast(
|
||||||
|
printInfo.pmPageFormat(), to: PMPageFormat.self)
|
||||||
|
_ = PMCopyPageFormat(newFormat, destination)
|
||||||
|
printInfo.updateFromPMPageFormat()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Custom.<w>x<h>` → dimensions in points (the token builder
|
||||||
|
/// emits integer points, mm × 72/25.4). `nil` for non-custom or
|
||||||
|
/// malformed tokens — a malformed `Custom.*` then misses the
|
||||||
|
/// `PMPaper` match and logs instead of guessing a size.
|
||||||
|
static func customPaperDimensions(
|
||||||
|
from token: String
|
||||||
|
) -> (width: Double, height: Double)? {
|
||||||
|
guard token.hasPrefix("Custom.") else { return nil }
|
||||||
|
let dims = token.dropFirst("Custom.".count).split(separator: "x")
|
||||||
|
guard dims.count == 2,
|
||||||
|
let width = Double(dims[0]), let height = Double(dims[1]),
|
||||||
|
width > 0, height > 0
|
||||||
|
else { return nil }
|
||||||
|
return (width, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func warnOnFailure(_ status: OSStatus, key: String) {
|
||||||
|
if status != 0 {
|
||||||
|
AppLogger.shared.warn(
|
||||||
|
"Print panel: PMPrintSettingsSetValue(\(key)) "
|
||||||
|
+ "rejected (\(status))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - PM helpers
|
// MARK: - PM helpers
|
||||||
|
|
||||||
/// `PMPrinter` → `PMObject` for `PMRelease` — the Carbon API wants
|
/// `PMPrinter` → `PMObject` for `PMRelease` — the Carbon API wants
|
||||||
|
|||||||
@@ -1,23 +1,30 @@
|
|||||||
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
|
||||||
import ICCeryCore
|
import ICCeryCore
|
||||||
|
|
||||||
/// CUPS queue selection, bound print panel, and `lp` spool (issues 12–15, 17 / #85).
|
/// CUPS queue selection, bound print panel, and `lp` spool (issues 12–15, 17 / #85).
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
final class PrintSessionViewModel: ObservableObject {
|
||||||
final class PrintSessionViewModel {
|
|
||||||
let wizard: WizardViewModel
|
let wizard: WizardViewModel
|
||||||
let environment: AppEnvironment
|
let environment: AppEnvironment
|
||||||
|
/// Stage 1/2 form state — read for paper seeding/mirroring only;
|
||||||
|
/// `workflow.pageSize` is the printtarg layout and is never written
|
||||||
|
/// back from the print side (#183).
|
||||||
|
weak var workflow: TargetWorkflowViewModel?
|
||||||
|
|
||||||
var printers: [Printer] = []
|
@Published var printers: [Printer] = []
|
||||||
var selectedPrinter = ""
|
@Published var selectedPrinter = ""
|
||||||
var printerCaps = PrinterCapabilities()
|
@Published var printerCaps = PrinterCapabilities()
|
||||||
var selectedTray: Int?
|
@Published var selectedTray: Int?
|
||||||
var selectedMediaType: String?
|
@Published var selectedMediaType: String?
|
||||||
var printOrientation = "portrait"
|
/// `PrinterPaperSize.id` — `0` is the synthetic custom entry (#183).
|
||||||
var capturedCupsOptions: [String: String] = [:]
|
@Published var selectedPaperSize: Int?
|
||||||
var printNotice: Notice?
|
/// Print-quality option token, e.g. `"303"` (#183).
|
||||||
var isPrinting = false
|
@Published var selectedQuality: String?
|
||||||
|
@Published var printOrientation = "portrait"
|
||||||
|
@Published var capturedCupsOptions: [String: String] = [:]
|
||||||
|
@Published var printNotice: Notice?
|
||||||
|
@Published var isPrinting = false
|
||||||
private var printTask: Task<Void, Never>?
|
private var printTask: Task<Void, Never>?
|
||||||
|
|
||||||
init(wizard: WizardViewModel, environment: AppEnvironment) {
|
init(wizard: WizardViewModel, environment: AppEnvironment) {
|
||||||
@@ -25,24 +32,41 @@ final class PrintSessionViewModel {
|
|||||||
self.environment = environment
|
self.environment = environment
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var printerEnumTask: Task<[Printer]?, Never>?
|
||||||
|
|
||||||
func refreshPrinters() {
|
func refreshPrinters() {
|
||||||
let cups = environment.cupsService
|
Task { @MainActor in _ = await enumeratePrinters() }
|
||||||
Task { @MainActor in
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
do {
|
||||||
let list = try await cups.listPrinters()
|
let list = try await self.environment.cupsService.listPrinters()
|
||||||
printers = list
|
self.printers = list
|
||||||
if !list.contains(where: { $0.name == selectedPrinter }) {
|
if !list.contains(where: { $0.name == self.selectedPrinter }) {
|
||||||
selectedPrinter = list.first { $0.isDefault }?.name
|
self.selectedPrinter = list.first { $0.isDefault }?.name
|
||||||
?? list.first?.name ?? ""
|
?? list.first?.name ?? ""
|
||||||
}
|
}
|
||||||
await reloadSelectedCapabilities()
|
await self.reloadSelectedCapabilities()
|
||||||
|
return list
|
||||||
} catch {
|
} catch {
|
||||||
printNotice = Notice(
|
self.printNotice = Notice(
|
||||||
kind: .error,
|
kind: .error,
|
||||||
text: "Could not list printers: \(error.localizedDescription)"
|
text: "Could not list printers: \(error.localizedDescription)"
|
||||||
)
|
)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
printerEnumTask = task
|
||||||
|
let result = await task.value
|
||||||
|
printerEnumTask = nil
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func reloadSelectedCapabilities() async {
|
func reloadSelectedCapabilities() async {
|
||||||
@@ -59,22 +83,79 @@ final class PrintSessionViewModel {
|
|||||||
if selectedTray == nil {
|
if selectedTray == nil {
|
||||||
selectedTray = printerCaps.trays.first?.id
|
selectedTray = printerCaps.trays.first?.id
|
||||||
}
|
}
|
||||||
|
if selectedQuality == nil {
|
||||||
|
selectedQuality = printerCaps.qualityDefault
|
||||||
|
?? printerCaps.qualities.first?.id
|
||||||
|
}
|
||||||
|
// Caps reload is a re-mirror trigger for the paper picker
|
||||||
|
// (#183 E4) — pageSize + printer changes route here too.
|
||||||
|
seedPaperSelection()
|
||||||
} catch {
|
} catch {
|
||||||
printerCaps = PrinterCapabilities()
|
printerCaps = PrinterCapabilities()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Paper / quality selection (#183)
|
||||||
|
|
||||||
|
/// Seed `selectedPaperSize` from Stage 1's `workflow.pageSize`:
|
||||||
|
/// a capability whose name matches `pageSize.rawValue` → its id;
|
||||||
|
/// `.custom` → the synthetic `Custom.<pt>x<pt>` entry (`id: 0`);
|
||||||
|
/// no match → nil (never guess). Called only on pageSize / printer /
|
||||||
|
/// caps triggers — never on unrelated publishes (R14).
|
||||||
|
func seedPaperSelection() {
|
||||||
|
guard let pageSize = workflow?.pageSize else { return }
|
||||||
|
if pageSize == .custom {
|
||||||
|
let token = customPaperToken()
|
||||||
|
if let index = printerCaps.paperSizes.firstIndex(where: { $0.id == 0 }) {
|
||||||
|
printerCaps.paperSizes[index].name = token
|
||||||
|
} else {
|
||||||
|
printerCaps.paperSizes.append(
|
||||||
|
PrinterPaperSize(id: 0, name: token))
|
||||||
|
}
|
||||||
|
selectedPaperSize = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
selectedPaperSize = printerCaps.paperSizes
|
||||||
|
.first { $0.name == pageSize.rawValue }?.id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Custom.<w>x<h>` in **points** — mm × 72/25.4 (#183 E5/R8). The
|
||||||
|
/// PPD template token `Custom.WIDTHxHEIGHT` is never emitted verbatim.
|
||||||
|
func customPaperToken() -> String {
|
||||||
|
let w = workflow?.customPageW ?? 0
|
||||||
|
let h = workflow?.customPageH ?? 0
|
||||||
|
let wPt = (w * 72.0 / 25.4).rounded()
|
||||||
|
let hPt = (h * 72.0 / 25.4).rounded()
|
||||||
|
return "Custom.\(Int(wPt))x\(Int(hPt))"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The CUPS `PageSize` token for the current Stage 2 pick — live
|
||||||
|
/// `Custom.<pt>x<pt>` for the synthetic entry, else the capability
|
||||||
|
/// name. This is what `lp -o PageSize=` sees.
|
||||||
|
var selectedPaperSizeToken: String? {
|
||||||
|
guard let id = selectedPaperSize else { return nil }
|
||||||
|
if id == 0 { return customPaperToken() }
|
||||||
|
return printerCaps.paperSizes.first { $0.id == id }?.name
|
||||||
|
}
|
||||||
|
|
||||||
func openPrinterPreferences() {
|
func openPrinterPreferences() {
|
||||||
guard !selectedPrinter.isEmpty else { return }
|
guard !selectedPrinter.isEmpty else { return }
|
||||||
let queue = selectedPrinter
|
let queue = selectedPrinter
|
||||||
let displayName = printers.first { $0.name == queue }?.displayName
|
let displayName = printers.first { $0.name == queue }?.displayName
|
||||||
let cups = environment.cupsService
|
let cups = environment.cupsService
|
||||||
|
let selections = PrintPanelInitialSelections(
|
||||||
|
paperSize: selectedPaperSizeToken,
|
||||||
|
qualityKey: printerCaps.qualityKey,
|
||||||
|
quality: selectedQuality,
|
||||||
|
mediaType: selectedMediaType,
|
||||||
|
orientation: printOrientation)
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
do {
|
do {
|
||||||
guard let result = try await PrintPanelService()
|
guard let result = try await PrintPanelService()
|
||||||
.showProperties(
|
.showProperties(
|
||||||
queue: queue, displayName: displayName,
|
queue: queue, displayName: displayName,
|
||||||
cupsService: cups)
|
cupsService: cups,
|
||||||
|
initialSelections: selections)
|
||||||
else {
|
else {
|
||||||
printNotice = Notice(
|
printNotice = Notice(
|
||||||
kind: .info,
|
kind: .info,
|
||||||
@@ -95,6 +176,21 @@ final class PrintSessionViewModel {
|
|||||||
if let media = result.options.mediaType {
|
if let media = result.options.mediaType {
|
||||||
selectedMediaType = media
|
selectedMediaType = media
|
||||||
}
|
}
|
||||||
|
// Capture-return (#183/#186): a dialog paper/quality/
|
||||||
|
// orientation change updates the Stage 2 selections —
|
||||||
|
// never `workflow.pageSize` (printtarg layout is
|
||||||
|
// sacred).
|
||||||
|
if let paper = result.options.paperSize,
|
||||||
|
let match = printerCaps.paperSizes
|
||||||
|
.first(where: { $0.name == paper }) {
|
||||||
|
selectedPaperSize = match.id
|
||||||
|
}
|
||||||
|
if let quality = result.options.quality {
|
||||||
|
selectedQuality = quality
|
||||||
|
}
|
||||||
|
if let orientation = result.options.orientation {
|
||||||
|
printOrientation = orientation
|
||||||
|
}
|
||||||
printNotice = Notice(
|
printNotice = Notice(
|
||||||
kind: .info,
|
kind: .info,
|
||||||
text: "Settings captured for \(selectedPrinter).",
|
text: "Settings captured for \(selectedPrinter).",
|
||||||
@@ -106,16 +202,17 @@ final class PrintSessionViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func printAllPages(from result: PrinttargResult, pageSize: PageSize) {
|
func printAllPages(from result: PrinttargResult) {
|
||||||
guard !isPrinting else { return }
|
guard !isPrinting else { return }
|
||||||
isPrinting = true
|
isPrinting = true
|
||||||
let task = Task { @MainActor [weak self] in
|
let task = Task { @MainActor [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
defer { self.printTask = nil }
|
// `defer` cannot mutate isolated state under Swift 5.7
|
||||||
|
// (Xcode 14.2 / macOS 12 runner), so clear explicitly (#113).
|
||||||
var printed = 0
|
var printed = 0
|
||||||
for page in result.pages {
|
for page in result.pages {
|
||||||
do {
|
do {
|
||||||
try await spool(page, index: page.index, pageSize: pageSize)
|
try await spool(page, index: page.index)
|
||||||
printed += 1
|
printed += 1
|
||||||
} catch {
|
} catch {
|
||||||
printNotice = Notice(
|
printNotice = Notice(
|
||||||
@@ -124,6 +221,7 @@ final class PrintSessionViewModel {
|
|||||||
+ error.localizedDescription
|
+ error.localizedDescription
|
||||||
)
|
)
|
||||||
isPrinting = false
|
isPrinting = false
|
||||||
|
self.printTask = nil
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,18 +231,18 @@ final class PrintSessionViewModel {
|
|||||||
autoHideAfter: nil
|
autoHideAfter: nil
|
||||||
)
|
)
|
||||||
isPrinting = false
|
isPrinting = false
|
||||||
|
self.printTask = nil
|
||||||
}
|
}
|
||||||
printTask = task
|
printTask = task
|
||||||
}
|
}
|
||||||
|
|
||||||
func printPage(_ page: GalleryPage, pageSize: PageSize) {
|
func printPage(_ page: GalleryPage) {
|
||||||
guard !isPrinting else { return }
|
guard !isPrinting else { return }
|
||||||
isPrinting = true
|
isPrinting = true
|
||||||
let task = Task { @MainActor [weak self] in
|
let task = Task { @MainActor [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
defer { self.printTask = nil }
|
|
||||||
do {
|
do {
|
||||||
try await spool(page, index: page.index, pageSize: pageSize)
|
try await spool(page, index: page.index)
|
||||||
printNotice = Notice(
|
printNotice = Notice(
|
||||||
kind: .info,
|
kind: .info,
|
||||||
text: "Sent \(page.page.filename) to \(selectedPrinter).",
|
text: "Sent \(page.page.filename) to \(selectedPrinter).",
|
||||||
@@ -157,18 +255,23 @@ final class PrintSessionViewModel {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
isPrinting = false
|
isPrinting = false
|
||||||
|
self.printTask = nil
|
||||||
}
|
}
|
||||||
printTask = task
|
printTask = task
|
||||||
}
|
}
|
||||||
|
|
||||||
private func spool(_ page: GalleryPage, index: Int, pageSize: PageSize) async throws {
|
private func spool(_ page: GalleryPage, index: Int) async throws {
|
||||||
guard !selectedPrinter.isEmpty else {
|
guard !selectedPrinter.isEmpty else {
|
||||||
throw CupsError.noPrinterSelected
|
throw CupsError.noPrinterSelected
|
||||||
}
|
}
|
||||||
|
// The Stage 2 paper token is what `lp -o PageSize=` sees;
|
||||||
|
// `workflow.pageSize` remains the printtarg layout input only
|
||||||
|
// (#183).
|
||||||
let options = PrintOptions(
|
let options = PrintOptions(
|
||||||
orientation: printOrientation,
|
orientation: printOrientation,
|
||||||
paperSize: pageSize == .custom ? nil : pageSize.rawValue,
|
paperSize: selectedPaperSizeToken,
|
||||||
mediaType: selectedMediaType,
|
mediaType: selectedMediaType,
|
||||||
|
quality: selectedQuality,
|
||||||
ppdUncorrectedPassthrough: true,
|
ppdUncorrectedPassthrough: true,
|
||||||
cupsOptions: capturedCupsOptions[selectedPrinter])
|
cupsOptions: capturedCupsOptions[selectedPrinter])
|
||||||
try await environment.cupsService.printTarget(
|
try await environment.cupsService.printTarget(
|
||||||
|
|||||||
@@ -11,12 +11,15 @@ enum ProcessRunSupport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
@MainActor
|
||||||
static func runLogged<T>(
|
static func runLogged<T>(
|
||||||
setRunning: (Bool) -> Void,
|
setRunning: (Bool) -> Void,
|
||||||
resetLog: () -> Void,
|
resetLog: () -> Void,
|
||||||
onLog: @escaping @MainActor @Sendable ([String]) -> Void,
|
onLog: @escaping @MainActor @Sendable ([String]) -> Void,
|
||||||
work: (@escaping @Sendable ([String]) -> Void) async throws -> T
|
work: @MainActor @escaping (@escaping @Sendable ([String]) -> Void) async throws -> T
|
||||||
) async throws -> T {
|
) async throws -> T {
|
||||||
setRunning(true)
|
setRunning(true)
|
||||||
resetLog()
|
resetLog()
|
||||||
|
|||||||
@@ -1,31 +1,11 @@
|
|||||||
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import ICCeryCore
|
import ICCeryCore
|
||||||
|
|
||||||
/// User-facing FWA selection for the Stage 4 form.
|
|
||||||
enum ColprofFwaSelection: String, CaseIterable, Sendable, Equatable {
|
|
||||||
case none = "none"
|
|
||||||
case empty = ""
|
|
||||||
case D50 = "D50"
|
|
||||||
case D65 = "D65"
|
|
||||||
case custom = "custom"
|
|
||||||
|
|
||||||
var displayName: String {
|
|
||||||
switch self {
|
|
||||||
case .none: return "None"
|
|
||||||
case .empty: return "Bare (-f)"
|
|
||||||
case .D50: return "D50"
|
|
||||||
case .D65: return "D65"
|
|
||||||
case .custom: return "Custom .sp"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stage 4/5 workflow: build a profile, verify it, track drift, and install.
|
/// Stage 4/5 workflow: build a profile, verify it, track drift, and install.
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
final class ProfileWorkflowViewModel: ObservableObject {
|
||||||
final class ProfileWorkflowViewModel {
|
|
||||||
|
|
||||||
let wizard: WizardViewModel
|
let wizard: WizardViewModel
|
||||||
let environment: AppEnvironment
|
let environment: AppEnvironment
|
||||||
@@ -33,51 +13,50 @@ final class ProfileWorkflowViewModel {
|
|||||||
|
|
||||||
// MARK: - Stage 4 form
|
// MARK: - Stage 4 form
|
||||||
|
|
||||||
var algorithm: String = "l" // l | x | X | m
|
@Published var algorithm: String = "l" // l | x | X | m
|
||||||
var quality: String = "m" // l | m | h | u
|
@Published var quality: String = "m" // l | m | h | u
|
||||||
var intent: String = "" // usually empty at Stage 4
|
@Published var intent: String = "" // usually empty at Stage 4
|
||||||
var fwaSelection: ColprofFwaSelection = .none
|
@Published var fwaSelection: ColprofFwaSelection = .none
|
||||||
var fwaCustomPath: String = ""
|
@Published var fwaCustomPath: String = ""
|
||||||
var illuminant: String = ""
|
@Published var illuminant: String = ""
|
||||||
var observer: String = ""
|
@Published var observer: String = ""
|
||||||
var inputViewingCond: String = ""
|
@Published var inputViewingCond: String = ""
|
||||||
var outputViewingCond: String = ""
|
@Published var outputViewingCond: String = ""
|
||||||
var profileDescription: String = ""
|
@Published var profileDescription: String = ""
|
||||||
var copyright: String = ""
|
@Published var copyright: String = ""
|
||||||
|
|
||||||
// MARK: - Run state
|
// MARK: - Run state
|
||||||
|
|
||||||
var isColprofRunning = false
|
@Published var isColprofRunning = false
|
||||||
var colprofLog: [String] = []
|
@Published var colprofLog: [String] = []
|
||||||
var colprofProgress: String?
|
@Published var colprofProgress: String?
|
||||||
var lastError: String?
|
@Published var createdProfileURL: URL?
|
||||||
var createdProfileURL: URL?
|
|
||||||
/// Path to the `.gam` gamut mesh extracted post-`colprof` (issue #28).
|
/// Path to the `.gam` gamut mesh extracted post-`colprof` (issue #28).
|
||||||
var createdGamutURL: URL?
|
@Published var createdGamutURL: URL?
|
||||||
|
|
||||||
// MARK: - Stage 4/5 calibration (issue #24)
|
// MARK: - Stage 4/5 calibration (issue #24)
|
||||||
|
|
||||||
var applyCalibration = false
|
@Published var applyCalibration = false
|
||||||
var calibrationFile: String = ""
|
@Published var calibrationFile: String = ""
|
||||||
|
|
||||||
// MARK: - Stage 5 verification (issue #25)
|
// MARK: - Stage 5 verification (issue #25)
|
||||||
|
|
||||||
var profcheckReport: ProfcheckReport?
|
@Published var profcheckReport: ProfcheckReport?
|
||||||
var profcheckWarning: String?
|
@Published var profcheckWarning: String?
|
||||||
var isProfcheckRunning = false
|
@Published var isProfcheckRunning = false
|
||||||
|
|
||||||
// MARK: - History / drift (issue #26)
|
// MARK: - History / drift (issue #26)
|
||||||
|
|
||||||
var verificationHistory: [VerificationRecord] = []
|
@Published var verificationHistory: [VerificationRecord] = []
|
||||||
var driftPrinterFilter: String? = nil
|
@Published var driftPrinterFilter: String? = nil
|
||||||
var driftAlert: String?
|
@Published var driftAlert: String?
|
||||||
var isHistoryStoreError: String?
|
@Published var isHistoryStoreError: String?
|
||||||
|
|
||||||
// MARK: - Install (issue #27)
|
// MARK: - Install (issue #27)
|
||||||
|
|
||||||
var installResult: InstallProfileResult?
|
@Published var installResult: InstallProfileResult?
|
||||||
var showingInstallCollision = false
|
@Published var showingInstallCollision = false
|
||||||
var installCollisionMessage: String = ""
|
@Published var installCollisionMessage: String = ""
|
||||||
var pendingInstallOptions: InstallProfileOptions?
|
var pendingInstallOptions: InstallProfileOptions?
|
||||||
|
|
||||||
init(wizard: WizardViewModel, environment: AppEnvironment) {
|
init(wizard: WizardViewModel, environment: AppEnvironment) {
|
||||||
@@ -110,13 +89,7 @@ final class ProfileWorkflowViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var fwaValue: String? {
|
var fwaValue: String? {
|
||||||
switch fwaSelection {
|
fwaSelection.presetValue(customPath: fwaCustomPath)
|
||||||
case .none: return nil
|
|
||||||
case .empty: return ""
|
|
||||||
case .D50: return "D50"
|
|
||||||
case .D65: return "D65"
|
|
||||||
case .custom: return fwaCustomPath
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Preset application
|
// MARK: - Preset application
|
||||||
@@ -131,21 +104,16 @@ final class ProfileWorkflowViewModel {
|
|||||||
algorithm = config.algorithm
|
algorithm = config.algorithm
|
||||||
quality = config.quality
|
quality = config.quality
|
||||||
intent = config.intent ?? ""
|
intent = config.intent ?? ""
|
||||||
if let fwa = config.fwa {
|
fwaSelection = ColprofFwaSelection(presetValue: config.fwa)
|
||||||
switch fwa.lowercased() {
|
fwaCustomPath = fwaSelection == .custom ? (config.fwa ?? "") : ""
|
||||||
case "none": fwaSelection = .none
|
|
||||||
case "": fwaSelection = .empty
|
|
||||||
case "d50": fwaSelection = .D50
|
|
||||||
case "d65": fwaSelection = .D65
|
|
||||||
default:
|
|
||||||
fwaSelection = .custom
|
|
||||||
fwaCustomPath = fwa
|
|
||||||
}
|
|
||||||
}
|
|
||||||
illuminant = config.illuminant ?? ""
|
illuminant = config.illuminant ?? ""
|
||||||
observer = config.observer ?? ""
|
observer = config.observer ?? ""
|
||||||
inputViewingCond = config.inputViewingCond ?? ""
|
inputViewingCond = config.inputViewingCond ?? ""
|
||||||
outputViewingCond = config.outputViewingCond ?? ""
|
outputViewingCond = config.outputViewingCond ?? ""
|
||||||
|
profileDescription = ""
|
||||||
|
copyright = ""
|
||||||
|
applyCalibration = preset.applyCalibration == true
|
||||||
|
calibrationFile = preset.calibrationFile ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stage 4 form values for saving into a custom preset.
|
/// Stage 4 form values for saving into a custom preset.
|
||||||
@@ -195,26 +163,25 @@ final class ProfileWorkflowViewModel {
|
|||||||
guard canCreateProfile, let _ = wizard.effectiveWorkingDirectory else { return }
|
guard canCreateProfile, let _ = wizard.effectiveWorkingDirectory else { return }
|
||||||
let config = buildColprofConfig()
|
let config = buildColprofConfig()
|
||||||
|
|
||||||
isColprofRunning = true
|
|
||||||
colprofLog = []
|
|
||||||
colprofProgress = nil
|
colprofProgress = nil
|
||||||
lastError = nil
|
|
||||||
createdProfileURL = nil
|
createdProfileURL = nil
|
||||||
createdGamutURL = nil
|
createdGamutURL = nil
|
||||||
|
|
||||||
let runner = environment.runner
|
let runner = environment.runner
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
defer { self.isColprofRunning = false }
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let url = try await runner.runColprof(config: config, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
|
let outcome = try await ProcessRunSupport.runLogged(
|
||||||
guard let self else { return }
|
setRunning: { self.isColprofRunning = $0 },
|
||||||
|
resetLog: { self.colprofLog = [] },
|
||||||
|
onLog: { batch in
|
||||||
self.colprofLog.append(contentsOf: batch)
|
self.colprofLog.append(contentsOf: batch)
|
||||||
if let last = batch.last {
|
if let last = batch.last {
|
||||||
self.updateProgress(ColprofProgressClassifier.classify(line: last))
|
self.updateProgress(ColprofProgressClassifier.classify(line: last))
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
) { onLog in
|
||||||
|
let url = try await runner.runColprof(config: config, onLogBatch: onLog)
|
||||||
|
|
||||||
var finalProfileURL = url
|
var finalProfileURL = url
|
||||||
|
|
||||||
@@ -229,26 +196,26 @@ final class ProfileWorkflowViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Gamut extraction is best-effort for Stage 5 / M6 viewer.
|
// Gamut extraction is best-effort for Stage 5 / M6 viewer.
|
||||||
|
var gamutURL: URL?
|
||||||
do {
|
do {
|
||||||
let gamConfig = IccgamutConfig(profileURL: finalProfileURL)
|
let gamConfig = IccgamutConfig(profileURL: finalProfileURL)
|
||||||
let gamURL = try await runner.runIccgamut(config: gamConfig, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
|
let url = try await runner.runIccgamut(config: gamConfig, onLogBatch: onLog)
|
||||||
self?.colprofLog.append(contentsOf: batch)
|
gamutURL = url
|
||||||
})
|
self.colprofLog.append("Gamut mesh extracted: \(url.lastPathComponent)")
|
||||||
self.createdGamutURL = gamURL
|
|
||||||
self.colprofLog.append("Gamut mesh extracted: \(gamURL.lastPathComponent)")
|
|
||||||
} catch {
|
} catch {
|
||||||
self.wizard.showNotice(
|
self.wizard.showNotice(
|
||||||
"Gamut extraction skipped: \(error.localizedDescription)",
|
"Gamut extraction skipped: \(error.localizedDescription)",
|
||||||
kind: .info
|
kind: .info
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
return (profileURL: finalProfileURL, gamutURL: gamutURL)
|
||||||
self.createdProfileURL = finalProfileURL
|
}
|
||||||
|
self.createdProfileURL = outcome.profileURL
|
||||||
|
self.createdGamutURL = outcome.gamutURL
|
||||||
self.wizard.refreshGating()
|
self.wizard.refreshGating()
|
||||||
self.wizard.showNotice("Profile created: \(finalProfileURL.lastPathComponent)")
|
self.wizard.showNotice("Profile created: \(outcome.profileURL.lastPathComponent)")
|
||||||
self.wizard.go(to: .verifyInstall)
|
self.wizard.go(to: .verifyInstall)
|
||||||
} catch {
|
} catch {
|
||||||
self.lastError = error.localizedDescription
|
|
||||||
self.wizard.showNotice(
|
self.wizard.showNotice(
|
||||||
"Profile creation failed: \(error.localizedDescription)",
|
"Profile creation failed: \(error.localizedDescription)",
|
||||||
kind: .error
|
kind: .error
|
||||||
@@ -315,23 +282,28 @@ final class ProfileWorkflowViewModel {
|
|||||||
let ti3URL = ArtefactProbe.artefact(wizard.basename, "ti3", cwd)
|
let ti3URL = ArtefactProbe.artefact(wizard.basename, "ti3", cwd)
|
||||||
let config = ProfcheckConfig(ti3URL: ti3URL, iccURL: profileURL)
|
let config = ProfcheckConfig(ti3URL: ti3URL, iccURL: profileURL)
|
||||||
|
|
||||||
isProfcheckRunning = true
|
|
||||||
profcheckReport = nil
|
profcheckReport = nil
|
||||||
profcheckWarning = nil
|
profcheckWarning = nil
|
||||||
|
|
||||||
let runner = environment.runner
|
let runner = environment.runner
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
defer { self.isProfcheckRunning = false }
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let report = try await runner.runProfcheck(config: config, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
|
let outcome = try await ProcessRunSupport.runLogged(
|
||||||
self?.colprofLog.append(contentsOf: batch)
|
setRunning: { self.isProfcheckRunning = $0 },
|
||||||
})
|
resetLog: {},
|
||||||
self.profcheckReport = report
|
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) {
|
if let record = self.makeVerificationRecord(from: report) {
|
||||||
let updated = try await self.environment.historyStore.append(record)
|
history = try await self.environment.historyStore.append(record)
|
||||||
self.verificationHistory = updated
|
}
|
||||||
|
return (report: report, history: history)
|
||||||
|
}
|
||||||
|
self.profcheckReport = outcome.report
|
||||||
|
if let history = outcome.history {
|
||||||
|
self.verificationHistory = history
|
||||||
self.driftAlert = DriftAlert.compute(from: self.filteredHistory)
|
self.driftAlert = DriftAlert.compute(from: self.filteredHistory)
|
||||||
}
|
}
|
||||||
} catch let error as ArgyllRunnerError where error == .profcheckUnparseable {
|
} catch let error as ArgyllRunnerError where error == .profcheckUnparseable {
|
||||||
|
|||||||
@@ -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<AnyCancellable>()
|
||||||
|
|
||||||
|
/// 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])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,12 +5,20 @@ import ICCeryCore
|
|||||||
/// Root layout: 270 pt sidebar + main stage area with the notification
|
/// Root layout: 270 pt sidebar + main stage area with the notification
|
||||||
/// banner pinned to the top (docs/21 §Shell).
|
/// banner pinned to the top (docs/21 §Shell).
|
||||||
struct RootView: View {
|
struct RootView: View {
|
||||||
@Bindable var workflow: TargetWorkflowViewModel
|
@ObservedObject var workflow: TargetWorkflowViewModel
|
||||||
|
/// Observed directly: nested ObservableObjects are not tracked
|
||||||
|
/// through the parent's `objectWillChange`.
|
||||||
|
@ObservedObject private var model: WizardViewModel
|
||||||
|
@ObservedObject private var project: ProjectSession
|
||||||
@State private var showingSettings = false
|
@State private var showingSettings = false
|
||||||
@State private var showingAbout = false
|
@State private var showingAbout = false
|
||||||
@State private var showingAllHelp = false
|
@State private var showingAllHelp = false
|
||||||
|
|
||||||
private var model: WizardViewModel { workflow.wizard }
|
init(workflow: TargetWorkflowViewModel) {
|
||||||
|
self.workflow = workflow
|
||||||
|
self._model = ObservedObject(wrappedValue: workflow.wizard)
|
||||||
|
self._project = ObservedObject(wrappedValue: workflow.project)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
@@ -31,6 +39,7 @@ struct RootView: View {
|
|||||||
}
|
}
|
||||||
WizardStageContent(model: model, workflow: workflow)
|
WizardStageContent(model: model, workflow: workflow)
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
.frame(minWidth: 1100, minHeight: 700)
|
.frame(minWidth: 1100, minHeight: 700)
|
||||||
.background(Theme.background)
|
.background(Theme.background)
|
||||||
@@ -50,6 +59,25 @@ struct RootView: View {
|
|||||||
.sheet(isPresented: $workflow.showingManagePresets) {
|
.sheet(isPresented: $workflow.showingManagePresets) {
|
||||||
ManagePresetsDialog(workflow: workflow)
|
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) {
|
.sheet(isPresented: $showingAbout) {
|
||||||
AboutView { showingAbout = false }
|
AboutView { showingAbout = false }
|
||||||
}
|
}
|
||||||
@@ -57,18 +85,52 @@ struct RootView: View {
|
|||||||
get: { workflow.wizard.showingGamutViewer },
|
get: { workflow.wizard.showingGamutViewer },
|
||||||
set: { workflow.wizard.showingGamutViewer = $0 }
|
set: { workflow.wizard.showingGamutViewer = $0 }
|
||||||
)) {
|
)) {
|
||||||
GamutView(profileGamURL: workflow.wizard.gamutProfileURL)
|
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
|
/// Content for the active wizard stage. Isolated into its own view so that
|
||||||
/// `WizardViewModel` is tracked via `@Bindable` instead of the parent's
|
/// `WizardViewModel` is tracked via `@ObservedObject` instead of the parent's
|
||||||
/// `TargetWorkflowViewModel`, which does not observe nested `wizard` mutations.
|
/// `TargetWorkflowViewModel`, which does not observe nested `wizard` mutations.
|
||||||
private struct WizardStageContent: View {
|
private struct WizardStageContent: View {
|
||||||
@Bindable var model: WizardViewModel
|
@ObservedObject var model: WizardViewModel
|
||||||
var workflow: TargetWorkflowViewModel
|
@ObservedObject var workflow: TargetWorkflowViewModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
switch model.stage {
|
switch model.stage {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import ICCeryCore
|
|||||||
/// Settings sheet (issue #5, docs/21 §Settings). Dark-theme Form with
|
/// Settings sheet (issue #5, docs/21 §Settings). Dark-theme Form with
|
||||||
/// the full v1 field set; ΔE validation shows inline under the fields.
|
/// the full v1 field set; ΔE validation shows inline under the fields.
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@State var model = SettingsViewModel()
|
@StateObject var model = SettingsViewModel()
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
private static let instruments: [(code: String, label: String)] = [
|
private static let instruments: [(code: String, label: String)] = [
|
||||||
@@ -58,7 +58,7 @@ struct SettingsView: View {
|
|||||||
Text($0.label).tag($0.code)
|
Text($0.label).tag($0.code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text("Display-only — Stage 2's instrument select is used for actual runs.")
|
Text("Seeds Spot Read and Stage 3 when the instrument is plugged in. printtarg -i is still chosen on Stage 2.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
@@ -69,22 +69,24 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Section("Verification") {
|
Section("Verification") {
|
||||||
HStack {
|
|
||||||
Text("Good ΔE ≤")
|
|
||||||
TextField(
|
TextField(
|
||||||
"2.0",
|
"Good ΔE ≤",
|
||||||
value: $model.settings.deltaEGoodMax,
|
value: $model.settings.deltaEGoodMax,
|
||||||
format: .number
|
format: .number
|
||||||
)
|
)
|
||||||
.frame(width: 60)
|
.accessibilityIdentifier("settingsDeltaEGood")
|
||||||
Text("Warning ΔE ≤")
|
|
||||||
TextField(
|
TextField(
|
||||||
"5.0",
|
"Warning ΔE ≤",
|
||||||
value: $model.settings.deltaEWarningMax,
|
value: $model.settings.deltaEWarningMax,
|
||||||
format: .number
|
format: .number
|
||||||
)
|
)
|
||||||
.frame(width: 60)
|
.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
|
ForEach(model.validationErrors, id: \.self) { error in
|
||||||
Text(error)
|
Text(error)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
@@ -93,16 +95,12 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Section("Calibration") {
|
Section("Calibration") {
|
||||||
HStack {
|
|
||||||
Text("Stale after")
|
|
||||||
TextField(
|
TextField(
|
||||||
"30",
|
"Stale after (days)",
|
||||||
value: $model.settings.calibrationStaleDays,
|
value: $model.settings.calibrationStaleDays,
|
||||||
format: .number
|
format: .number
|
||||||
)
|
)
|
||||||
.frame(width: 60)
|
.accessibilityIdentifier("settingsCalStaleDays")
|
||||||
Text("days")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Profile install") {
|
Section("Profile install") {
|
||||||
@@ -143,7 +141,7 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.padding(.leading, 45)
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import ICCeryCore
|
import ICCeryCore
|
||||||
|
|
||||||
@@ -6,12 +7,11 @@ import ICCeryCore
|
|||||||
/// validation; the log level is applied live via `LogSink` (#158) and a
|
/// validation; the log level is applied live via `LogSink` (#158) and a
|
||||||
/// `settingsDidChange` notification fans out to #20.
|
/// `settingsDidChange` notification fans out to #20.
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
final class SettingsViewModel: ObservableObject {
|
||||||
final class SettingsViewModel {
|
|
||||||
|
|
||||||
var settings: AppSettings
|
@Published var settings: AppSettings
|
||||||
var validationErrors: [String] = []
|
@Published var validationErrors: [String] = []
|
||||||
var savedFlash = false
|
@Published var savedFlash = false
|
||||||
|
|
||||||
private let store: SettingsStore
|
private let store: SettingsStore
|
||||||
private let sink: LogSink
|
private let sink: LogSink
|
||||||
@@ -33,7 +33,7 @@ final class SettingsViewModel {
|
|||||||
sink.applySettings(settings)
|
sink.applySettings(settings)
|
||||||
savedFlash = true
|
savedFlash = true
|
||||||
Task {
|
Task {
|
||||||
try? await Task.sleep(for: .seconds(1.5))
|
try? await Task.sleep(nanoseconds: 1_500_000_000)
|
||||||
savedFlash = false
|
savedFlash = false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -4,15 +4,54 @@ import ICCeryCore
|
|||||||
/// 270 pt sidebar (docs/21 §Shell): logo, settings/about buttons, preset
|
/// 270 pt sidebar (docs/21 §Shell): logo, settings/about buttons, preset
|
||||||
/// select, Calibrate Printer + status chip, and the 1–5 stepper.
|
/// select, Calibrate Printer + status chip, and the 1–5 stepper.
|
||||||
struct SidebarView: View {
|
struct SidebarView: View {
|
||||||
@Bindable var workflow: TargetWorkflowViewModel
|
@ObservedObject var workflow: TargetWorkflowViewModel
|
||||||
|
/// Observed directly: nested ObservableObjects are not tracked
|
||||||
|
/// through the parent's `objectWillChange`.
|
||||||
|
@ObservedObject private var model: WizardViewModel
|
||||||
|
@ObservedObject private var profile: ProfileWorkflowViewModel
|
||||||
|
@ObservedObject private var media: MediaLibraryViewModel
|
||||||
|
@ObservedObject private var printSession: PrintSessionViewModel
|
||||||
|
@ObservedObject private var measurement: MeasurementWorkflowViewModel
|
||||||
|
@ObservedObject private var project: ProjectSession
|
||||||
var onOpenSettings: () -> Void
|
var onOpenSettings: () -> Void
|
||||||
var onOpenAbout: () -> Void
|
var onOpenAbout: () -> Void
|
||||||
@Binding var showingAllHelp: Bool
|
@Binding var showingAllHelp: Bool
|
||||||
|
|
||||||
private var model: WizardViewModel { workflow.wizard }
|
init(
|
||||||
|
workflow: TargetWorkflowViewModel,
|
||||||
|
onOpenSettings: @escaping () -> Void,
|
||||||
|
onOpenAbout: @escaping () -> Void,
|
||||||
|
showingAllHelp: Binding<Bool>
|
||||||
|
) {
|
||||||
|
self.workflow = workflow
|
||||||
|
self._model = ObservedObject(wrappedValue: workflow.wizard)
|
||||||
|
self._profile = ObservedObject(wrappedValue: workflow.profile)
|
||||||
|
self._media = ObservedObject(wrappedValue: workflow.media)
|
||||||
|
self._printSession = ObservedObject(wrappedValue: workflow.print)
|
||||||
|
self._measurement = ObservedObject(wrappedValue: workflow.measurement)
|
||||||
|
self._project = ObservedObject(wrappedValue: workflow.project)
|
||||||
|
self.onOpenSettings = onOpenSettings
|
||||||
|
self.onOpenAbout = onOpenAbout
|
||||||
|
self._showingAllHelp = showingAllHelp
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
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 {
|
HStack {
|
||||||
Image("ICCery-logo")
|
Image("ICCery-logo")
|
||||||
.resizable()
|
.resizable()
|
||||||
@@ -41,7 +80,11 @@ struct SidebarView: View {
|
|||||||
.padding(12)
|
.padding(12)
|
||||||
|
|
||||||
Divider().overlay(Theme.border)
|
Divider().overlay(Theme.border)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var presetBlock: some View {
|
||||||
|
Group {
|
||||||
// Preset select (`#presetSelect`) — issue #11. Selection
|
// Preset select (`#presetSelect`) — issue #11. Selection
|
||||||
// applies the preset immediately; names render via Text only.
|
// applies the preset immediately; names render via Text only.
|
||||||
Picker("Preset", selection: Binding(
|
Picker("Preset", selection: Binding(
|
||||||
@@ -73,7 +116,65 @@ struct SidebarView: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.bottom, 8)
|
.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`).
|
// Calibrate Printer (`#btnCalibratePrinter`).
|
||||||
Button(action: { model.enterCalibration() }) {
|
Button(action: { model.enterCalibration() }) {
|
||||||
Label("Calibrate Printer", systemImage: "slider.horizontal.3")
|
Label("Calibrate Printer", systemImage: "slider.horizontal.3")
|
||||||
@@ -88,9 +189,36 @@ struct SidebarView: View {
|
|||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
.controlSize(.large)
|
.controlSize(.large)
|
||||||
|
.helpOverlay(
|
||||||
|
"View the profile gamut in 3D against sRGB.",
|
||||||
|
showing: $showingAllHelp)
|
||||||
.accessibilityIdentifier("btnViewGamut")
|
.accessibilityIdentifier("btnViewGamut")
|
||||||
.padding(.horizontal, 12)
|
.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)
|
Divider().overlay(Theme.border)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
|
|
||||||
@@ -110,9 +238,13 @@ struct SidebarView: View {
|
|||||||
.padding(.horizontal, 6)
|
.padding(.horizontal, 6)
|
||||||
|
|
||||||
Spacer()
|
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)
|
||||||
}
|
}
|
||||||
.frame(width: Theme.Metrics.sidebarWidth)
|
|
||||||
.background(Theme.panel)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
import AppKit
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// Spot-read console state and interaction (issue #148).
|
||||||
|
///
|
||||||
|
/// Runs the bundled `spotread` sidecar under the single-lease process id
|
||||||
|
/// `spotread`; Stage 3 `chartread` is untouched. `defaultInstrument`
|
||||||
|
/// seeds the instrument picker on sheet open — it is never written into
|
||||||
|
/// `printtarg -i` or `targen` argv (R15).
|
||||||
|
@MainActor
|
||||||
|
final class SpotReadViewModel: ObservableObject {
|
||||||
|
|
||||||
|
let workflow: TargetWorkflowViewModel
|
||||||
|
let environment: AppEnvironment
|
||||||
|
private let fileDialogs = FileDialogService.shared
|
||||||
|
|
||||||
|
// MARK: - Instrument card
|
||||||
|
|
||||||
|
@Published var instruments: [InstrumentDevice] = []
|
||||||
|
@Published var selectedInstrument: InstrumentSelection = .auto
|
||||||
|
@Published var isDetecting = false
|
||||||
|
@Published var detectionError: String?
|
||||||
|
/// `spotDefaultMissing` — set when `defaultInstrument` is saved but
|
||||||
|
/// no detected device matches it.
|
||||||
|
@Published var defaultMissing = false
|
||||||
|
/// `spotSetDefault` toggle state.
|
||||||
|
@Published var setAsDefault = false
|
||||||
|
|
||||||
|
// MARK: - Session
|
||||||
|
|
||||||
|
@Published var isRunning = false
|
||||||
|
@Published var state: ChartreadState = .idle
|
||||||
|
@Published var prompt = "Press Start to open the instrument."
|
||||||
|
@Published var lastError: String?
|
||||||
|
@Published var log: [String] = []
|
||||||
|
|
||||||
|
// MARK: - Samples / history (in-memory, cap 50, newest first)
|
||||||
|
|
||||||
|
@Published private(set) var samples: [SpotReadSample] = []
|
||||||
|
@Published private(set) var displayedSample: SpotReadSample?
|
||||||
|
@Published private(set) var displayedDeltaE: Double?
|
||||||
|
|
||||||
|
private let historyLimit = 50
|
||||||
|
private var streamTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
init(workflow: TargetWorkflowViewModel, environment: AppEnvironment) {
|
||||||
|
self.workflow = workflow
|
||||||
|
self.environment = environment
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Derived state
|
||||||
|
|
||||||
|
/// Whether the bundled `spotread` sidecar resolves to an executable.
|
||||||
|
/// `BinaryResolver` only — never `$PATH`, never `chartread`.
|
||||||
|
var sidecarAvailable: Bool {
|
||||||
|
let url = environment.runner.binaryResolver.resolve("spotread")
|
||||||
|
return environment.runner.binaryResolver.exists(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
var isChartreadRunning: Bool { workflow.measurement.isChartreadRunning }
|
||||||
|
|
||||||
|
var canStart: Bool {
|
||||||
|
sidecarAvailable && !isDetecting && !isRunning && !isChartreadRunning
|
||||||
|
&& workflow.wizard.effectiveWorkingDirectory != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var canDetect: Bool { !isDetecting && !isRunning }
|
||||||
|
|
||||||
|
var deltaEClassification: SwatchClassification? {
|
||||||
|
guard let de = displayedDeltaE else { return nil }
|
||||||
|
let settings = environment.settingsStore.load()
|
||||||
|
return ColorDifference.classify(
|
||||||
|
deltaE: de,
|
||||||
|
goodMax: settings.deltaEGoodMax,
|
||||||
|
warningMax: settings.deltaEWarningMax)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `spotLabImplausible` — L* outside 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: "\"\""))\""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import ICCeryCore
|
|||||||
/// docs/08). All documented element ids are wired as accessibility
|
/// docs/08). All documented element ids are wired as accessibility
|
||||||
/// identifiers so the UI-test contract stays stable.
|
/// identifiers so the UI-test contract stays stable.
|
||||||
struct Stage1View: View {
|
struct Stage1View: View {
|
||||||
@Bindable var workflow: TargetWorkflowViewModel
|
@ObservedObject var workflow: TargetWorkflowViewModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
|
|||||||
@@ -5,7 +5,19 @@ import ICCeryCore
|
|||||||
/// issues #9/#10, docs/09). Print controls are visible but inert —
|
/// issues #9/#10, docs/09). Print controls are visible but inert —
|
||||||
/// real spooling lands in M3.
|
/// real spooling lands in M3.
|
||||||
struct Stage2View: View {
|
struct Stage2View: View {
|
||||||
@Bindable var workflow: TargetWorkflowViewModel
|
@ObservedObject var workflow: TargetWorkflowViewModel
|
||||||
|
/// Observed directly: nested ObservableObjects are not tracked
|
||||||
|
/// through the parent's `objectWillChange`.
|
||||||
|
@ObservedObject private var printSession: PrintSessionViewModel
|
||||||
|
@ObservedObject private var wizard: WizardViewModel
|
||||||
|
|
||||||
|
@State private var printGenerationTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
init(workflow: TargetWorkflowViewModel) {
|
||||||
|
self.workflow = workflow
|
||||||
|
self._printSession = ObservedObject(wrappedValue: workflow.print)
|
||||||
|
self._wizard = ObservedObject(wrappedValue: workflow.wizard)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
@@ -218,6 +230,7 @@ struct Stage2View: View {
|
|||||||
.foregroundStyle(notice.kind == .error
|
.foregroundStyle(notice.kind == .error
|
||||||
? .red : .blue)
|
? .red : .blue)
|
||||||
.accessibilityIdentifier("printNotificationIcon")
|
.accessibilityIdentifier("printNotificationIcon")
|
||||||
|
.accessibilityValue(notice.kind.accessibilityValue)
|
||||||
Text(notice.text)
|
Text(notice.text)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(notice.kind == .error
|
.foregroundStyle(notice.kind == .error
|
||||||
@@ -239,9 +252,10 @@ struct Stage2View: View {
|
|||||||
}
|
}
|
||||||
.frame(maxWidth: 320)
|
.frame(maxWidth: 320)
|
||||||
.accessibilityIdentifier("printerSelect")
|
.accessibilityIdentifier("printerSelect")
|
||||||
.onChange(of: workflow.print.selectedPrinter) { _, _ in
|
.onChange(of: workflow.print.selectedPrinter) { _ in
|
||||||
workflow.print.selectedTray = nil
|
workflow.print.selectedTray = nil
|
||||||
workflow.print.selectedMediaType = nil
|
workflow.print.selectedMediaType = nil
|
||||||
|
workflow.print.selectedQuality = nil
|
||||||
Task { @MainActor in await workflow.print.reloadSelectedCapabilities() }
|
Task { @MainActor in await workflow.print.reloadSelectedCapabilities() }
|
||||||
}
|
}
|
||||||
if let selected = workflow.print.printers
|
if let selected = workflow.print.printers
|
||||||
@@ -266,7 +280,9 @@ struct Stage2View: View {
|
|||||||
.accessibilityIdentifier("btnPrinterProperties")
|
.accessibilityIdentifier("btnPrinterProperties")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tray / media / orientation — from queue capabilities.
|
// Tray / media / paper / quality / orientation — from
|
||||||
|
// queue capabilities. Extracted subviews keep every
|
||||||
|
// ViewBuilder ≤10 children (R13).
|
||||||
HStack(spacing: 14) {
|
HStack(spacing: 14) {
|
||||||
if !workflow.print.printerCaps.trays.isEmpty {
|
if !workflow.print.printerCaps.trays.isEmpty {
|
||||||
Picker("Tray", selection: $workflow.print.selectedTray) {
|
Picker("Tray", selection: $workflow.print.selectedTray) {
|
||||||
@@ -288,6 +304,12 @@ struct Stage2View: View {
|
|||||||
.accessibilityIdentifier("mediaTypeGroup")
|
.accessibilityIdentifier("mediaTypeGroup")
|
||||||
.accessibilityIdentifier("printerMediaTypeSelect")
|
.accessibilityIdentifier("printerMediaTypeSelect")
|
||||||
}
|
}
|
||||||
|
if !workflow.print.printerCaps.paperSizes.isEmpty {
|
||||||
|
paperSizeGroup
|
||||||
|
}
|
||||||
|
if !workflow.print.printerCaps.qualities.isEmpty {
|
||||||
|
qualityGroup
|
||||||
|
}
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
Button("Portrait") { workflow.print.printOrientation = "portrait" }
|
Button("Portrait") { workflow.print.printOrientation = "portrait" }
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
@@ -300,11 +322,13 @@ struct Stage2View: View {
|
|||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
|
// Stage 1 owns the custom dimensions — the caption lives
|
||||||
|
// inside `paperSizeGroup` (#183).
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
if let result = workflow.printtargResult {
|
if let result = workflow.printtargResult {
|
||||||
workflow.print.printAllPages(from: result, pageSize: workflow.pageSize)
|
workflow.print.printAllPages(from: result)
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
Label(workflow.print.isPrinting ? "Printing…" : "Print All",
|
Label(workflow.print.isPrinting ? "Printing…" : "Print All",
|
||||||
@@ -327,9 +351,62 @@ struct Stage2View: View {
|
|||||||
.clipShape(RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium))
|
.clipShape(RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium))
|
||||||
.accessibilityElement(children: .contain)
|
.accessibilityElement(children: .contain)
|
||||||
.accessibilityIdentifier("rawPrintPanel")
|
.accessibilityIdentifier("rawPrintPanel")
|
||||||
.task(id: workflow.printtargResult?.pages.count) {
|
.onAppear { schedulePrinterRefresh() }
|
||||||
// Auto-enumerate once a manifest exists and whenever it
|
.onChange(of: workflow.printtargResult?.pages.count) { _ in
|
||||||
// changes (e.g. resume from .ti2).
|
schedulePrinterRefresh()
|
||||||
|
}
|
||||||
|
// Editable picker that re-mirrors Stage 1's pageSize (#183 E4).
|
||||||
|
.onChange(of: workflow.pageSize) { _ in
|
||||||
|
workflow.print.seedPaperSelection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paper picker + custom-size caption under the `paperSizeGroup`
|
||||||
|
/// container (#183). `caps.paperSizes` plus the synthetic custom
|
||||||
|
/// entry (`id: 0`, shown as `Custom (W×H mm)`).
|
||||||
|
private var paperSizeGroup: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Picker("Paper", selection: $workflow.print.selectedPaperSize) {
|
||||||
|
ForEach(workflow.print.printerCaps.paperSizes, id: \.id) { size in
|
||||||
|
Text(size.id == 0
|
||||||
|
? "Custom (\(Int(workflow.customPageW))×\(Int(workflow.customPageH)) mm)"
|
||||||
|
: size.name)
|
||||||
|
.tag(Optional(size.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: 200)
|
||||||
|
.accessibilityIdentifier("printerPaperSizeSelect")
|
||||||
|
if workflow.print.selectedPaperSize == 0 {
|
||||||
|
Text("Custom (\(Int(workflow.customPageW))×\(Int(workflow.customPageH)) mm)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityIdentifier("paperSizeGroup")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Quality picker — driver tokens with PPD-enriched labels (#183).
|
||||||
|
private var qualityGroup: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Picker("Quality", selection: $workflow.print.selectedQuality) {
|
||||||
|
ForEach(workflow.print.printerCaps.qualities, id: \.id) {
|
||||||
|
Text($0.name).tag(Optional($0.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: 200)
|
||||||
|
.accessibilityIdentifier("printerQualitySelect")
|
||||||
|
}
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityIdentifier("qualityGroup")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
if workflow.print.printers.isEmpty, workflow.printtargResult != nil {
|
||||||
workflow.print.refreshPrinters()
|
workflow.print.refreshPrinters()
|
||||||
}
|
}
|
||||||
@@ -340,7 +417,16 @@ struct Stage2View: View {
|
|||||||
/// One gallery cell: PNG preview + per-page Print button.
|
/// One gallery cell: PNG preview + per-page Print button.
|
||||||
private struct GalleryPageView: View {
|
private struct GalleryPageView: View {
|
||||||
let page: GalleryPage
|
let page: GalleryPage
|
||||||
let workflow: TargetWorkflowViewModel
|
@ObservedObject var workflow: TargetWorkflowViewModel
|
||||||
|
/// Observed directly: `print` is a nested ObservableObject and its
|
||||||
|
/// `isPrinting`/`selectedPrinter` changes drive this cell's button.
|
||||||
|
@ObservedObject private var printSession: PrintSessionViewModel
|
||||||
|
|
||||||
|
init(page: GalleryPage, workflow: TargetWorkflowViewModel) {
|
||||||
|
self.page = page
|
||||||
|
self.workflow = workflow
|
||||||
|
self._printSession = ObservedObject(wrappedValue: workflow.print)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 6) {
|
VStack(spacing: 6) {
|
||||||
@@ -361,7 +447,7 @@ private struct GalleryPageView: View {
|
|||||||
Text("\(page.page.patches) patches · " +
|
Text("\(page.page.patches) patches · " +
|
||||||
"\(Int(page.page.widthMm))×\(Int(page.page.heightMm)) mm")
|
"\(Int(page.page.widthMm))×\(Int(page.page.heightMm)) mm")
|
||||||
.font(.caption2).foregroundStyle(.secondary)
|
.font(.caption2).foregroundStyle(.secondary)
|
||||||
Button("Print") { workflow.print.printPage(page, pageSize: workflow.pageSize) }
|
Button("Print") { workflow.print.printPage(page) }
|
||||||
.disabled(workflow.print.isPrinting
|
.disabled(workflow.print.isPrinting
|
||||||
|| workflow.print.selectedPrinter.isEmpty)
|
|| workflow.print.selectedPrinter.isEmpty)
|
||||||
.accessibilityIdentifier("btnPrintPage-\(page.index)")
|
.accessibilityIdentifier("btnPrintPage-\(page.index)")
|
||||||
|
|||||||
@@ -4,7 +4,15 @@ import ICCeryCore
|
|||||||
|
|
||||||
/// Stage 3 — measurement, live swatches, and multi-pass averaging.
|
/// Stage 3 — measurement, live swatches, and multi-pass averaging.
|
||||||
struct Stage3View: View {
|
struct Stage3View: View {
|
||||||
@Bindable var model: MeasurementWorkflowViewModel
|
@ObservedObject var model: MeasurementWorkflowViewModel
|
||||||
|
/// `model.basename`/`model.workingDirectory` delegate to `wizard`;
|
||||||
|
/// observe it directly so header updates propagate.
|
||||||
|
@ObservedObject private var wizard: WizardViewModel
|
||||||
|
|
||||||
|
init(model: MeasurementWorkflowViewModel) {
|
||||||
|
self.model = model
|
||||||
|
self._wizard = ObservedObject(wrappedValue: model.wizard)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -164,28 +172,22 @@ struct Stage3View: View {
|
|||||||
.foregroundStyle(Theme.accent)
|
.foregroundStyle(Theme.accent)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let lastError = model.lastError {
|
if let notice = model.chartreadNotice {
|
||||||
Text(lastError)
|
Text(notice.text)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.red)
|
.foregroundStyle(notice.kind.tint)
|
||||||
.accessibilityIdentifier("chartreadLastError")
|
.accessibilityIdentifier("chartreadLastError")
|
||||||
.accessibilityValue(lastError)
|
.accessibilityValue(notice.text)
|
||||||
}
|
}
|
||||||
|
|
||||||
controlButtons
|
controlButtons
|
||||||
|
|
||||||
if !model.chartreadLog.isEmpty {
|
if !model.chartreadLog.isEmpty {
|
||||||
DisclosureGroup("Log") {
|
ProcessLogView(
|
||||||
VStack(alignment: .leading) {
|
lines: model.chartreadLog,
|
||||||
ForEach(model.chartreadLog, id: \.self) { line in
|
containerId: "chartreadLogContainer",
|
||||||
Text(line)
|
logId: "chartreadLog"
|
||||||
.font(.system(.caption, design: .monospaced))
|
)
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.foregroundStyle(Theme.text)
|
|
||||||
.accessibilityIdentifier("chartreadLogContainer")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(16)
|
||||||
@@ -374,6 +376,8 @@ struct Stage3View: View {
|
|||||||
Text(notice.text)
|
Text(notice.text)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(notice.kind == .error ? .red : .green)
|
.foregroundStyle(notice.kind == .error ? .red : .green)
|
||||||
|
.accessibilityIdentifier("chartreadFinishNotice")
|
||||||
|
.accessibilityValue(notice.kind.accessibilityValue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(16)
|
||||||
|
|||||||
@@ -3,7 +3,15 @@ import ICCeryCore
|
|||||||
|
|
||||||
/// Stage 4 — build an ICC/ICM profile from the canonical `.ti3`.
|
/// Stage 4 — build an ICC/ICM profile from the canonical `.ti3`.
|
||||||
struct Stage4View: View {
|
struct Stage4View: View {
|
||||||
@Bindable var model: ProfileWorkflowViewModel
|
@ObservedObject var model: ProfileWorkflowViewModel
|
||||||
|
/// Header reads `model.wizard.basename`; observe the nested
|
||||||
|
/// ObservableObject directly.
|
||||||
|
@ObservedObject private var wizard: WizardViewModel
|
||||||
|
|
||||||
|
init(model: ProfileWorkflowViewModel) {
|
||||||
|
self.model = model
|
||||||
|
self._wizard = ObservedObject(wrappedValue: model.wizard)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -130,6 +138,9 @@ struct Stage4View: View {
|
|||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
.accessibilityIdentifier("colprofCopyright")
|
.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)
|
Toggle("Apply calibration curve", isOn: $model.applyCalibration)
|
||||||
.accessibilityIdentifier("colprofApplyCalibration")
|
.accessibilityIdentifier("colprofApplyCalibration")
|
||||||
|
|
||||||
@@ -143,6 +154,7 @@ struct Stage4View: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.padding(16)
|
.padding(16)
|
||||||
.background(Theme.panel)
|
.background(Theme.panel)
|
||||||
}
|
}
|
||||||
@@ -166,27 +178,14 @@ struct Stage4View: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
if let lastError = model.lastError {
|
|
||||||
Text(lastError)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.red)
|
|
||||||
.accessibilityIdentifier("colprofLastError")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !model.colprofLog.isEmpty {
|
if !model.colprofLog.isEmpty {
|
||||||
DisclosureGroup("Log") {
|
ProcessLogView(
|
||||||
VStack(alignment: .leading) {
|
lines: model.colprofLog,
|
||||||
ForEach(model.colprofLog, id: \.self) { line in
|
containerId: "colprofLogContainer",
|
||||||
Text(line)
|
logId: "colprofLog"
|
||||||
.font(.system(.caption, design: .monospaced))
|
)
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.foregroundStyle(Theme.text)
|
|
||||||
.accessibilityIdentifier("colprofLogContainer")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(16)
|
||||||
|
|||||||
@@ -3,7 +3,15 @@ import ICCeryCore
|
|||||||
|
|
||||||
/// Stage 5 — verify the generated profile, track drift, and install.
|
/// Stage 5 — verify the generated profile, track drift, and install.
|
||||||
struct Stage5View: View {
|
struct Stage5View: View {
|
||||||
@Bindable var model: ProfileWorkflowViewModel
|
@ObservedObject var model: ProfileWorkflowViewModel
|
||||||
|
/// Header/buttons read `model.wizard.*`; observe the nested
|
||||||
|
/// ObservableObject directly.
|
||||||
|
@ObservedObject private var wizard: WizardViewModel
|
||||||
|
|
||||||
|
init(model: ProfileWorkflowViewModel) {
|
||||||
|
self.model = model
|
||||||
|
self._wizard = ObservedObject(wrappedValue: model.wizard)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
|
||||||
import ICCeryCore
|
import ICCeryCore
|
||||||
|
|
||||||
/// Stage 1/2 form state, runner orchestration, resume flow, and preset
|
/// Stage 1/2 form state, runner orchestration, resume flow, and preset
|
||||||
@@ -10,8 +10,7 @@ import ICCeryCore
|
|||||||
/// All process work runs through `ArgyllRunner` off `@MainActor`; only
|
/// All process work runs through `ArgyllRunner` off `@MainActor`; only
|
||||||
/// coalesced log batches and completion hop back.
|
/// coalesced log batches and completion hop back.
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
final class TargetWorkflowViewModel: ObservableObject {
|
||||||
final class TargetWorkflowViewModel {
|
|
||||||
|
|
||||||
let wizard: WizardViewModel
|
let wizard: WizardViewModel
|
||||||
let environment: AppEnvironment
|
let environment: AppEnvironment
|
||||||
@@ -19,95 +18,112 @@ final class TargetWorkflowViewModel {
|
|||||||
|
|
||||||
// MARK: - Stage 1 form (targen)
|
// MARK: - Stage 1 form (targen)
|
||||||
|
|
||||||
var colourSpace: ColourSpace = .rgb {
|
@Published var colourSpace: ColourSpace = .rgb {
|
||||||
didSet {
|
didSet {
|
||||||
guard colourSpace != oldValue else { return }
|
guard colourSpace != oldValue else { return }
|
||||||
// CMYK black patches default to 0, RGB to 4 (docs/08).
|
// CMYK black patches default to 0, RGB to 4 (docs/08).
|
||||||
blackPatches = colourSpace == .cmyk ? 0 : 4
|
blackPatches = colourSpace == .cmyk ? 0 : 4
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var patchPreset: PatchCountPreset = .standard800
|
@Published var patchPreset: PatchCountPreset = .standard800
|
||||||
/// `#patchCountCustom` — used when `patchPreset == .custom`.
|
/// `#patchCountCustom` — used when `patchPreset == .custom`.
|
||||||
var customPatchCount = 2500
|
@Published var customPatchCount = 2500
|
||||||
var whitePatches = 4
|
@Published var whitePatches = 4
|
||||||
var blackPatches = 4
|
@Published var blackPatches = 4
|
||||||
|
|
||||||
// Advanced — each optional flag is enabled + value, so an untouched
|
// Advanced — each optional flag is enabled + value, so an untouched
|
||||||
// control emits nothing (#advanced fields are opt-in).
|
// control emits nothing (#advanced fields are opt-in).
|
||||||
var greyStepsEnabled = false
|
@Published var greyStepsEnabled = false
|
||||||
var greySteps = 5
|
@Published var greySteps = 5
|
||||||
var singleChannelEnabled = false
|
@Published var singleChannelEnabled = false
|
||||||
var singleChannelSteps = 5
|
@Published var singleChannelSteps = 5
|
||||||
var neutralStepsEnabled = false
|
@Published var neutralStepsEnabled = false
|
||||||
var neutralSteps = 3
|
@Published var neutralSteps = 3
|
||||||
var neutralConcEnabled = false
|
@Published var neutralConcEnabled = false
|
||||||
var neutralConcentration = 0.50
|
@Published var neutralConcentration = 0.50
|
||||||
var preconditioningProfile: String?
|
@Published var preconditioningProfile: String?
|
||||||
var highQuality = false
|
@Published var highQuality = false
|
||||||
var adaptationEnabled = false
|
@Published var adaptationEnabled = false
|
||||||
var adaptation = 0.10
|
@Published var adaptation = 0.10
|
||||||
var algorithm: FullSpreadAlgorithm = .ofps
|
@Published var algorithm: FullSpreadAlgorithm = .ofps
|
||||||
var inkLimitEnabled = false
|
@Published var inkLimitEnabled = false
|
||||||
var totalInkLimit = 320
|
@Published var totalInkLimit = 320
|
||||||
var darkEmphasisEnabled = false
|
@Published var darkEmphasisEnabled = false
|
||||||
var darkEmphasis = 1.0
|
@Published var darkEmphasis = 1.0
|
||||||
var devicePowerEnabled = false
|
@Published var devicePowerEnabled = false
|
||||||
var devicePower = 1.0
|
@Published var devicePower = 1.0
|
||||||
|
|
||||||
/// `#targetBasename` — no placeholder is ever invented (#60).
|
/// `#targetBasename` — no placeholder is ever invented (#60).
|
||||||
var targetBasename = ""
|
@Published var targetBasename = ""
|
||||||
/// `#selectedPathDisplay` / resolved cwd.
|
/// `#selectedPathDisplay` / resolved cwd.
|
||||||
var targetDirectory: URL?
|
@Published var targetDirectory: URL?
|
||||||
|
|
||||||
// MARK: - Stage 2 form (printtarg)
|
// MARK: - Stage 2 form (printtarg)
|
||||||
|
|
||||||
var instrument: PrintInstrument = .i1
|
@Published var instrument: PrintInstrument = .i1
|
||||||
var pageSize: PageSize = .a4
|
@Published var pageSize: PageSize = .a4
|
||||||
var customPageW = 210.0
|
@Published var customPageW = 210.0
|
||||||
var customPageH = 297.0
|
@Published var customPageH = 297.0
|
||||||
var bitDepth: TiffBitDepth = .eight
|
@Published var bitDepth: TiffBitDepth = .eight
|
||||||
/// `#tiffDpi` — two-way bound; presets can change it (150-DPI draft
|
/// `#tiffDpi` — two-way bound; presets can change it (150-DPI draft
|
||||||
/// regression must be visible here).
|
/// regression must be visible here).
|
||||||
var tiffDpi = 300
|
@Published var tiffDpi = 300
|
||||||
var layoutOrder: LayoutOrder = .deterministic
|
@Published var layoutOrder: LayoutOrder = .deterministic
|
||||||
var customSeed = 1
|
@Published var customSeed = 1
|
||||||
var labelIsCustom = false
|
@Published var labelIsCustom = false
|
||||||
var customLabel = ""
|
@Published var customLabel = ""
|
||||||
var metaPrinter = ""
|
@Published var metaPrinter = ""
|
||||||
var metaInkSet = ""
|
@Published var metaInkSet = ""
|
||||||
var metaDriverPaper = ""
|
@Published var metaDriverPaper = ""
|
||||||
var metaActualPaper = ""
|
@Published var metaActualPaper = ""
|
||||||
|
|
||||||
// MARK: - Run state
|
// MARK: - Run state
|
||||||
|
|
||||||
var targenRunning = false
|
@Published var targenRunning = false
|
||||||
var targenLog: [String] = []
|
@Published var targenLog: [String] = []
|
||||||
var printtargRunning = false
|
@Published var printtargRunning = false
|
||||||
var printtargLog: [String] = []
|
@Published var printtargLog: [String] = []
|
||||||
var printtargResult: PrinttargResult?
|
@Published var printtargResult: PrinttargResult?
|
||||||
/// Sticky until the target changes: `.ti2` resume landed us on
|
/// Sticky until the target changes: `.ti2` resume landed us on
|
||||||
/// Stage 3 (`#stage3LoadedTargetBanner` data).
|
/// Stage 3 (`#stage3LoadedTargetBanner` data).
|
||||||
var resumedFromTi2 = false
|
@Published var resumedFromTi2 = false
|
||||||
|
|
||||||
// MARK: - Presets
|
// MARK: - Presets
|
||||||
|
|
||||||
var presets: [ProfilingPreset] = []
|
@Published var presets: [ProfilingPreset] = []
|
||||||
var selectedPresetID = "none"
|
@Published var selectedPresetID = "none"
|
||||||
var showingSavePreset = false
|
@Published var showingSavePreset = false
|
||||||
var showingManagePresets = false
|
@Published var showingManagePresets = false
|
||||||
var savePresetName = ""
|
@Published var savePresetName = ""
|
||||||
var savePresetDesc = ""
|
@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
|
/// Stage 3 measurement workflow, owned at the app level so it persists
|
||||||
/// across stage switches and can observe settings changes.
|
/// across stage switches and can observe settings changes.
|
||||||
var measurement: MeasurementWorkflowViewModel
|
@Published var measurement: MeasurementWorkflowViewModel
|
||||||
/// Stage 4/5 profile workflow, owned at the app level so it persists
|
/// Stage 4/5 profile workflow, owned at the app level so it persists
|
||||||
/// across stage switches and can observe preset values.
|
/// across stage switches and can observe preset values.
|
||||||
var profile: ProfileWorkflowViewModel
|
@Published var profile: ProfileWorkflowViewModel
|
||||||
/// Stage 0 calibration workflow.
|
/// Stage 0 calibration workflow.
|
||||||
var calibration: CalibrationViewModel!
|
@Published var calibration: CalibrationViewModel!
|
||||||
/// Stage 2 unmanaged print session.
|
/// Stage 2 unmanaged print session.
|
||||||
var print: PrintSessionViewModel!
|
@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()) {
|
init(environment: AppEnvironment = .live()) {
|
||||||
self.environment = environment
|
self.environment = environment
|
||||||
@@ -121,12 +137,27 @@ final class TargetWorkflowViewModel {
|
|||||||
environment: environment
|
environment: environment
|
||||||
)
|
)
|
||||||
self.print = PrintSessionViewModel(wizard: wizard, environment: environment)
|
self.print = PrintSessionViewModel(wizard: wizard, environment: environment)
|
||||||
|
// Paper-size seeding reads the Stage 1 form through this weak
|
||||||
|
// back-reference; the print side never writes it (#183).
|
||||||
|
self.print.workflow = self
|
||||||
self.calibration = nil
|
self.calibration = nil
|
||||||
self.calibration = CalibrationViewModel(
|
self.calibration = CalibrationViewModel(
|
||||||
workflow: self,
|
workflow: self,
|
||||||
profile: self.profile,
|
profile: self.profile,
|
||||||
environment: environment
|
environment: environment
|
||||||
)
|
)
|
||||||
|
self.media = MediaLibraryViewModel(
|
||||||
|
workflow: self,
|
||||||
|
environment: environment
|
||||||
|
)
|
||||||
|
self.spotRead = SpotReadViewModel(
|
||||||
|
workflow: self,
|
||||||
|
environment: environment
|
||||||
|
)
|
||||||
|
self.project = ProjectSession(
|
||||||
|
workflow: self,
|
||||||
|
environment: environment
|
||||||
|
)
|
||||||
reloadPresets()
|
reloadPresets()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,8 +237,6 @@ final class TargetWorkflowViewModel {
|
|||||||
func generateTarget() {
|
func generateTarget() {
|
||||||
guard canGenerate, !targenRunning else { return }
|
guard canGenerate, !targenRunning else { return }
|
||||||
let config = buildTargenConfig()
|
let config = buildTargenConfig()
|
||||||
targenRunning = true
|
|
||||||
targenLog = []
|
|
||||||
resumedFromTi2 = false
|
resumedFromTi2 = false
|
||||||
let runner = environment.runner
|
let runner = environment.runner
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
@@ -228,7 +257,6 @@ final class TargetWorkflowViewModel {
|
|||||||
} catch {
|
} catch {
|
||||||
wizard.showNotice(
|
wizard.showNotice(
|
||||||
"targen failed: \(error.localizedDescription)", kind: .error)
|
"targen failed: \(error.localizedDescription)", kind: .error)
|
||||||
targenRunning = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -242,7 +270,12 @@ final class TargetWorkflowViewModel {
|
|||||||
? UITestHooks.datasetImportURL
|
? UITestHooks.datasetImportURL
|
||||||
: fileDialogs.selectDatasetFile()
|
: fileDialogs.selectDatasetFile()
|
||||||
guard let url else { return }
|
guard let url else { return }
|
||||||
|
importMeasurementDataset(from: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test seam (issue #80): unit tests pass missing or malformed URLs
|
||||||
|
/// directly instead of mutating the global environment.
|
||||||
|
func importMeasurementDataset(from url: URL) {
|
||||||
do {
|
do {
|
||||||
let dataset = try CGATSParser.parse(url: url)
|
let dataset = try CGATSParser.parse(url: url)
|
||||||
guard let directory = targetDirectory ?? wizard.effectiveWorkingDirectory else {
|
guard let directory = targetDirectory ?? wizard.effectiveWorkingDirectory else {
|
||||||
@@ -339,8 +372,6 @@ final class TargetWorkflowViewModel {
|
|||||||
func createLayout() {
|
func createLayout() {
|
||||||
guard wizard.isUnlocked(.layOutPrint), !printtargRunning else { return }
|
guard wizard.isUnlocked(.layOutPrint), !printtargRunning else { return }
|
||||||
let config = buildPrinttargConfig()
|
let config = buildPrinttargConfig()
|
||||||
printtargRunning = true
|
|
||||||
printtargLog = []
|
|
||||||
printtargResult = nil
|
printtargResult = nil
|
||||||
let runner = environment.runner
|
let runner = environment.runner
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
@@ -380,14 +411,18 @@ final class TargetWorkflowViewModel {
|
|||||||
func applyPreset(_ preset: ProfilingPreset) {
|
func applyPreset(_ preset: ProfilingPreset) {
|
||||||
let targen = TargenConfig(preset: preset, basename: targetBasename, workingDirectory: targetDirectory)
|
let targen = TargenConfig(preset: preset, basename: targetBasename, workingDirectory: targetDirectory)
|
||||||
applyTargenForm(targen)
|
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(
|
let printtarg = PrinttargConfig(
|
||||||
preset: preset,
|
preset: preset,
|
||||||
basename: wizard.basename,
|
basename: wizard.basename,
|
||||||
workingDirectory: wizard.effectiveWorkingDirectory,
|
workingDirectory: wizard.effectiveWorkingDirectory,
|
||||||
calibrationFile: profile.applyCalibration ? profile.calibrationFile : nil
|
calibrationFile: profile.applyCalibration && !profile.calibrationFile.isEmpty
|
||||||
|
? profile.calibrationFile : nil
|
||||||
)
|
)
|
||||||
applyPrinttargForm(printtarg)
|
applyPrinttargForm(printtarg)
|
||||||
profile.applyPreset(preset)
|
|
||||||
selectedPresetID = preset.id
|
selectedPresetID = preset.id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
|
||||||
import ICCeryCore
|
import ICCeryCore
|
||||||
|
|
||||||
/// Wizard state machine + artefact gating (issue #4, docs/06).
|
/// Wizard state machine + artefact gating (issue #4, docs/06).
|
||||||
@@ -10,47 +10,46 @@ import ICCeryCore
|
|||||||
/// `wizard_state.json`; unlocks come from `ArtefactProbe.verify` —
|
/// `wizard_state.json`; unlocks come from `ArtefactProbe.verify` —
|
||||||
/// navigation is disk, not buttons.
|
/// navigation is disk, not buttons.
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
final class WizardViewModel: ObservableObject {
|
||||||
final class WizardViewModel {
|
|
||||||
|
|
||||||
// MARK: - wizardState fields (persisted)
|
// MARK: - wizardState fields (persisted)
|
||||||
|
|
||||||
var stage: WizardStage {
|
@Published var stage: WizardStage {
|
||||||
didSet { if stage != oldValue { persist() } }
|
didSet { if stage != oldValue { persist() } }
|
||||||
}
|
}
|
||||||
/// `wizardState.basename` — empty until a real artefact names it (#60).
|
/// `wizardState.basename` — empty until a real artefact names it (#60).
|
||||||
var basename: String {
|
@Published var basename: String {
|
||||||
didSet { if basename != oldValue { refreshGating(); persist() } }
|
didSet { if basename != oldValue { refreshGating(); persist() } }
|
||||||
}
|
}
|
||||||
/// `wizardState.cwd` — resolved via `resolveSafeCwd` (#59).
|
/// `wizardState.cwd` — resolved via `resolveSafeCwd` (#59).
|
||||||
var workingDirectory: URL? {
|
@Published var workingDirectory: URL? {
|
||||||
didSet { if workingDirectory != oldValue { refreshGating(); persist() } }
|
didSet { if workingDirectory != oldValue { refreshGating(); persist() } }
|
||||||
}
|
}
|
||||||
var printerName: String? {
|
@Published var printerName: String? {
|
||||||
didSet { if printerName != oldValue { persist() } }
|
didSet { if printerName != oldValue { persist() } }
|
||||||
}
|
}
|
||||||
var sessionMode: SessionMode {
|
@Published var sessionMode: SessionMode {
|
||||||
didSet { if sessionMode != oldValue { persist() } }
|
didSet { if sessionMode != oldValue { persist() } }
|
||||||
}
|
}
|
||||||
/// `profileBasename` may differ after a `.ti3` import (#94).
|
/// `profileBasename` may differ after a `.ti3` import (#94).
|
||||||
var profileBasename: String? {
|
@Published var profileBasename: String? {
|
||||||
didSet { if profileBasename != oldValue { persist() } }
|
didSet { if profileBasename != oldValue { persist() } }
|
||||||
}
|
}
|
||||||
/// Pre-`CAL_` basename, persisted so relaunch/Force Quit can restore it (#29).
|
/// Pre-`CAL_` basename, persisted so relaunch/Force Quit can restore it (#29).
|
||||||
var calibrationOriginalBasename: String {
|
@Published var calibrationOriginalBasename: String {
|
||||||
didSet { if calibrationOriginalBasename != oldValue { persist() } }
|
didSet { if calibrationOriginalBasename != oldValue { persist() } }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Ephemeral
|
// MARK: - Ephemeral
|
||||||
|
|
||||||
/// Banner notice currently displayed (`#wizardNotification`).
|
/// Banner notice currently displayed (`#wizardNotification`).
|
||||||
var notice: Notice?
|
@Published var notice: Notice?
|
||||||
/// Current artefact probe result; recomputed on `refreshGating()`.
|
/// Current artefact probe result; recomputed on `refreshGating()`.
|
||||||
private(set) var artefacts = StageArtefacts()
|
@Published private(set) var artefacts = StageArtefacts()
|
||||||
/// Whether the 3D gamut viewer sheet is open (issue #28).
|
/// Whether the 3D gamut viewer sheet is open (issue #28).
|
||||||
var showingGamutViewer = false
|
@Published var showingGamutViewer = false
|
||||||
/// Optional `.gam` URL to show alongside the sRGB reference.
|
/// Optional `.gam` URL to show alongside the sRGB reference.
|
||||||
var gamutProfileURL: URL?
|
@Published var gamutProfileURL: URL?
|
||||||
|
|
||||||
private let stateStore: WizardStateStore
|
private let stateStore: WizardStateStore
|
||||||
private var noticeDismissTask: Task<Void, Never>?
|
private var noticeDismissTask: Task<Void, Never>?
|
||||||
@@ -201,7 +200,7 @@ final class WizardViewModel {
|
|||||||
self.notice = notice
|
self.notice = notice
|
||||||
if let delay = notice.autoHideAfter {
|
if let delay = notice.autoHideAfter {
|
||||||
noticeDismissTask = Task { [weak self] in
|
noticeDismissTask = Task { [weak self] in
|
||||||
try? await Task.sleep(for: .seconds(delay))
|
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
||||||
guard !Task.isCancelled else { return }
|
guard !Task.isCancelled else { return }
|
||||||
if self?.notice?.id == notice.id {
|
if self?.notice?.id == notice.id {
|
||||||
self?.notice = nil
|
self?.notice = nil
|
||||||
|
|||||||
@@ -1,27 +1,25 @@
|
|||||||
import Testing
|
import XCTest
|
||||||
import Foundation
|
import Foundation
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
@Suite("AppPaths")
|
final class AppPathsTests: XCTestCase {
|
||||||
struct AppPathsTests {
|
func testAppDataDirUsesBundleID() {
|
||||||
@Test func appDataDirUsesBundleID() {
|
XCTAssertTrue(AppPaths.appDataDir.path.contains("Library/Application Support/com.gronod.iccery2"))
|
||||||
#expect(AppPaths.appDataDir.path.contains("Library/Application Support/com.gronod.iccery2"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func logFileIsUnderLibraryLogs() {
|
func testLogFileIsUnderLibraryLogs() {
|
||||||
#expect(AppPaths.logFile.lastPathComponent == "iccery.log")
|
XCTAssertEqual(AppPaths.logFile.lastPathComponent, "iccery.log")
|
||||||
#expect(AppPaths.logFile.path.contains("Library/Logs/com.gronod.iccery2"))
|
XCTAssertTrue(AppPaths.logFile.path.contains("Library/Logs/com.gronod.iccery2"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func bundledArgyllDirIsInsideResources() {
|
func testBundledArgyllDirIsInsideResources() {
|
||||||
#expect(AppPaths.bundledArgyllDir.lastPathComponent == "Argyll")
|
XCTAssertEqual(AppPaths.bundledArgyllDir.lastPathComponent, "Argyll")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suite("WizardStage")
|
final class WizardStageTests: XCTestCase {
|
||||||
struct WizardStageTests {
|
func testStepperOrderIsOneThroughFive() {
|
||||||
@Test func stepperOrderIsOneThroughFive() {
|
XCTAssertEqual(WizardStage.stepperStages.map(\.stepperIndex), [1, 2, 3, 4, 5])
|
||||||
#expect(WizardStage.stepperStages.map(\.stepperIndex) == [1, 2, 3, 4, 5])
|
XCTAssertNil(WizardStage.calibrate.stepperIndex)
|
||||||
#expect(WizardStage.calibrate.stepperIndex == nil)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,19 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import XCTest
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
@Suite("ApplycalArgs")
|
final class ApplycalArgsTests: XCTestCase {
|
||||||
struct ApplycalArgsTests {
|
|
||||||
|
|
||||||
@Test("Apply argv")
|
func testApplyArgv() throws {
|
||||||
func applyArgv() throws {
|
|
||||||
let config = ApplycalConfig(
|
let config = ApplycalConfig(
|
||||||
calibrationPath: "/tmp/cal.cal",
|
calibrationPath: "/tmp/cal.cal",
|
||||||
inputProfileURL: URL(fileURLWithPath: "/tmp/profile.icc")
|
inputProfileURL: URL(fileURLWithPath: "/tmp/profile.icc")
|
||||||
)
|
)
|
||||||
let args = try ApplycalArgs.build(config: config)
|
let args = try ApplycalArgs.build(config: config)
|
||||||
#expect(args == ["-v", "-a", "/tmp/cal.cal", "/tmp/profile.icc"])
|
XCTAssertEqual(args, ["-v", "-a", "/tmp/cal.cal", "/tmp/profile.icc"])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Unapply is emitted when the caller explicitly sets it")
|
func testUnapplyEmittedWhenConfigSet() throws {
|
||||||
func unapplyEmittedWhenConfigSet() throws {
|
|
||||||
let config = ApplycalConfig(
|
let config = ApplycalConfig(
|
||||||
calibrationPath: "/tmp/cal.cal",
|
calibrationPath: "/tmp/cal.cal",
|
||||||
inputProfileURL: URL(fileURLWithPath: "/tmp/profile.icc"),
|
inputProfileURL: URL(fileURLWithPath: "/tmp/profile.icc"),
|
||||||
@@ -25,6 +22,6 @@ struct ApplycalArgsTests {
|
|||||||
let args = try ApplycalArgs.build(config: config)
|
let args = try ApplycalArgs.build(config: config)
|
||||||
// Builder emits -u only when the caller explicitly sets unapply.
|
// Builder emits -u only when the caller explicitly sets unapply.
|
||||||
// The UI layer never passes unapply: true in v2.0.
|
// The UI layer never passes unapply: true in v2.0.
|
||||||
#expect(args == ["-v", "-u", "/tmp/cal.cal", "/tmp/profile.icc"])
|
XCTAssertEqual(args, ["-v", "-u", "/tmp/cal.cal", "/tmp/profile.icc"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import Foundation
|
||||||
|
import XCTest
|
||||||
|
@testable import ICCeryCore
|
||||||
|
|
||||||
|
/// ``ApproximateLab`` sanity tests (issue #147).
|
||||||
|
///
|
||||||
|
/// These are loose sanity checks on a fixed-matrix approximation — not
|
||||||
|
/// ColorSync goldens.
|
||||||
|
final class ApproximateLabTests: XCTestCase {
|
||||||
|
|
||||||
|
func testWhiteMapsToHighLStar() {
|
||||||
|
let lab = ApproximateLab.srgb8ToLab(r: 255, g: 255, b: 255)
|
||||||
|
XCTAssertGreaterThan(lab.l, 95)
|
||||||
|
XCTAssertEqual(lab.a, 0, accuracy: 2)
|
||||||
|
XCTAssertEqual(lab.b, 0, accuracy: 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBlackMapsToZeroLStar() {
|
||||||
|
let lab = ApproximateLab.srgb8ToLab(r: 0, g: 0, b: 0)
|
||||||
|
XCTAssertEqual(lab.l, 0, accuracy: 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPureRedIsChromatic() {
|
||||||
|
let lab = ApproximateLab.srgb8ToLab(r: 255, g: 0, b: 0)
|
||||||
|
// sRGB red ≈ Lab D50 (54, 81, 70) — loose bounds only.
|
||||||
|
XCTAssertGreaterThan(lab.l, 40)
|
||||||
|
XCTAssertLessThan(lab.l, 65)
|
||||||
|
XCTAssertGreaterThan(lab.a, 60)
|
||||||
|
XCTAssertGreaterThan(lab.b, 40)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMidGreyIsNeutral() {
|
||||||
|
let lab = ApproximateLab.srgb8ToLab(r: 128, g: 128, b: 128)
|
||||||
|
XCTAssertGreaterThan(lab.l, 45)
|
||||||
|
XCTAssertLessThan(lab.l, 65)
|
||||||
|
XCTAssertEqual(lab.a, 0, accuracy: 1)
|
||||||
|
XCTAssertEqual(lab.b, 0, accuracy: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import XCTest
|
||||||
|
import Foundation
|
||||||
|
@testable import ICCeryCore
|
||||||
|
|
||||||
|
final class ArgsBuilderTests: XCTestCase {
|
||||||
|
|
||||||
|
// MARK: - option
|
||||||
|
|
||||||
|
func testOptionNil() {
|
||||||
|
XCTAssertEqual(ArgsBuilder.option("-f", nil), [])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOptionPresent() {
|
||||||
|
XCTAssertEqual(ArgsBuilder.option("-f", "abc"), ["-f", "abc"])
|
||||||
|
XCTAssertEqual(ArgsBuilder.option("-f", ""), ["-f", ""])
|
||||||
|
XCTAssertEqual(ArgsBuilder.option("-f", " padded "), ["-f", " padded "])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - optionIfNonEmpty
|
||||||
|
|
||||||
|
func testOptionIfNonEmptyNilEmpty() {
|
||||||
|
XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", nil), [])
|
||||||
|
XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", ""), [])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOptionIfNonEmptyWhitespace() {
|
||||||
|
XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", " "), [])
|
||||||
|
XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", " \t\n "), [])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOptionIfNonEmptyTrims() {
|
||||||
|
XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", " label "), ["-d", "label"])
|
||||||
|
XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", "\tcal.cal\n"), ["-d", "cal.cal"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - optionUnlessApprox
|
||||||
|
|
||||||
|
func testOptionUnlessApproxNil() {
|
||||||
|
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", nil, skip: 0.50), [])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOptionUnlessApproxExactSkip() {
|
||||||
|
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", 0.50, skip: 0.50), [])
|
||||||
|
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-V", 1.0, skip: 1.0), [])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOptionUnlessApproxWithinEpsilon() {
|
||||||
|
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", 0.5005, skip: 0.50), [])
|
||||||
|
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-V", 0.9995, skip: 1.0), [])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOptionUnlessApproxOutsideEpsilon() {
|
||||||
|
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", 0.75, skip: 0.50), ["-N", "0.75"])
|
||||||
|
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-V", 1.50, skip: 1.0), ["-V", "1.50"])
|
||||||
|
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", 0.498, skip: 0.50), ["-N", "0.50"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOptionUnlessApproxPOSIX() {
|
||||||
|
// 1234.5 must never produce a grouping separator or comma decimal.
|
||||||
|
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-p", 1234.5, skip: 1.0), ["-p", "1234.50"])
|
||||||
|
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-p", 2.0, skip: 1.0), ["-p", "2.00"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOptionUnlessApproxCustom() {
|
||||||
|
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-x", 1.005, skip: 1.0, epsilon: 0.01), [])
|
||||||
|
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-x", 1.5, skip: 1.0, format: "%.1f"), ["-x", "1.5"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - flag
|
||||||
|
|
||||||
|
func testFlagTrue() {
|
||||||
|
XCTAssertEqual(ArgsBuilder.flag("-G", when: true), ["-G"])
|
||||||
|
XCTAssertEqual(ArgsBuilder.flag("-r", when: true), ["-r"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFlagFalse() {
|
||||||
|
XCTAssertEqual(ArgsBuilder.flag("-G", when: false), [])
|
||||||
|
XCTAssertEqual(ArgsBuilder.flag("-r", when: false), [])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,16 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import XCTest
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
@Suite("ArgyllRunner Calibration")
|
final class ArgyllRunnerCalibrationTests: XCTestCase {
|
||||||
struct ArgyllRunnerCalibrationTests {
|
|
||||||
|
|
||||||
private func makeRunner() -> ArgyllRunner {
|
private func makeRunner(processManager: ProcessManager = ProcessManager()) -> ArgyllRunner {
|
||||||
let binDir = URL(fileURLWithPath: #filePath)
|
let binDir = URL(fileURLWithPath: #filePath)
|
||||||
.deletingLastPathComponent()
|
.deletingLastPathComponent()
|
||||||
.deletingLastPathComponent()
|
.deletingLastPathComponent()
|
||||||
.appendingPathComponent("ICCeryUITests/Fixtures/bin")
|
.appendingPathComponent("ICCeryUITests/Fixtures/bin")
|
||||||
return ArgyllRunner(
|
return ArgyllRunner(
|
||||||
processManager: .shared,
|
processManager: processManager,
|
||||||
binaryResolver: BinaryResolver(overrideDir: binDir)
|
binaryResolver: BinaryResolver(overrideDir: binDir)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -23,8 +22,7 @@ struct ArgyllRunnerCalibrationTests {
|
|||||||
return root
|
return root
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Calibration targen produces CAL_*.ti1")
|
func testCalibrationTargenProducesTi1() async throws {
|
||||||
func calibrationTargenProducesTi1() async throws {
|
|
||||||
let testRoot = try makeTestDir()
|
let testRoot = try makeTestDir()
|
||||||
let runner = makeRunner()
|
let runner = makeRunner()
|
||||||
let config = CalibrationTargenConfig(
|
let config = CalibrationTargenConfig(
|
||||||
@@ -36,13 +34,41 @@ struct ArgyllRunnerCalibrationTests {
|
|||||||
|
|
||||||
let url = try await runner.runCalibrationTargen(config: config)
|
let url = try await runner.runCalibrationTargen(config: config)
|
||||||
|
|
||||||
#expect(url.lastPathComponent == "CAL_demo.ti1")
|
XCTAssertEqual(url.lastPathComponent, "CAL_demo.ti1")
|
||||||
#expect(FileManager.default.fileExists(atPath: url.path))
|
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
|
||||||
try? FileManager.default.removeItem(at: testRoot)
|
try? FileManager.default.removeItem(at: testRoot)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("printcal captured run creates .cal")
|
func testCalibrationTargenProcessId() async throws {
|
||||||
func printcalProducesCal() async throws {
|
let testRoot = try makeTestDir()
|
||||||
|
let pm = ProcessManager()
|
||||||
|
let runner = makeRunner(processManager: pm)
|
||||||
|
let events = pm.events()
|
||||||
|
// Subscribed before spawn; the exit event is emitted before
|
||||||
|
// runCalibrationTargen returns, so this always terminates.
|
||||||
|
let sawExit = Task {
|
||||||
|
for await event in events {
|
||||||
|
guard event.id == "targen_CAL_foo" else { continue }
|
||||||
|
if case .exit = event { return true }
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
let config = CalibrationTargenConfig(
|
||||||
|
colourSpace: .rgb,
|
||||||
|
steps: 21,
|
||||||
|
basename: "foo",
|
||||||
|
workingDirectory: testRoot
|
||||||
|
)
|
||||||
|
|
||||||
|
let url = try await runner.runCalibrationTargen(config: config)
|
||||||
|
|
||||||
|
XCTAssertEqual(url.lastPathComponent, "CAL_foo.ti1")
|
||||||
|
let sawExitEvent = await sawExit.value
|
||||||
|
XCTAssertTrue(sawExitEvent)
|
||||||
|
try? FileManager.default.removeItem(at: testRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPrintcalProducesCal() async throws {
|
||||||
let testRoot = try makeTestDir()
|
let testRoot = try makeTestDir()
|
||||||
let runner = makeRunner()
|
let runner = makeRunner()
|
||||||
let output = testRoot.appendingPathComponent("CAL_demo.cal")
|
let output = testRoot.appendingPathComponent("CAL_demo.cal")
|
||||||
@@ -54,15 +80,32 @@ struct ArgyllRunnerCalibrationTests {
|
|||||||
|
|
||||||
let url = try await runner.runPrintcal(config: config)
|
let url = try await runner.runPrintcal(config: config)
|
||||||
|
|
||||||
#expect(url.lastPathComponent == "CAL_demo.cal")
|
XCTAssertEqual(url.lastPathComponent, "CAL_demo.cal")
|
||||||
#expect(FileManager.default.fileExists(atPath: url.path))
|
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
|
||||||
try? FileManager.default.removeItem(at: testRoot)
|
try? FileManager.default.removeItem(at: testRoot)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("printcal failure throws printcalFailed")
|
func testPrintcalFailureThrows() async throws {
|
||||||
func printcalFailureThrows() async throws {
|
|
||||||
let testRoot = try makeTestDir()
|
let testRoot = try makeTestDir()
|
||||||
let runner = makeRunner()
|
defer { try? FileManager.default.removeItem(at: testRoot) }
|
||||||
|
|
||||||
|
// Per-test mock printcal that always fails — no global
|
||||||
|
// environment mutation, no shared fixture changes.
|
||||||
|
let binDir = try makeTestDir()
|
||||||
|
defer { try? FileManager.default.removeItem(at: binDir) }
|
||||||
|
let mockURL = binDir.appendingPathComponent("printcal")
|
||||||
|
try """
|
||||||
|
#!/bin/sh
|
||||||
|
echo "printcal mock failure" >&2
|
||||||
|
exit 1
|
||||||
|
""".write(to: mockURL, atomically: true, encoding: .utf8)
|
||||||
|
try FileManager.default.setAttributes(
|
||||||
|
[.posixPermissions: 0o755], ofItemAtPath: mockURL.path)
|
||||||
|
|
||||||
|
let runner = ArgyllRunner(
|
||||||
|
processManager: ProcessManager(),
|
||||||
|
binaryResolver: BinaryResolver(bundledRoot: binDir, overrideDir: binDir)
|
||||||
|
)
|
||||||
let output = testRoot.appendingPathComponent("CAL_demo.cal")
|
let output = testRoot.appendingPathComponent("CAL_demo.cal")
|
||||||
let config = PrintcalConfig(
|
let config = PrintcalConfig(
|
||||||
ti3Basename: "CAL_demo",
|
ti3Basename: "CAL_demo",
|
||||||
@@ -70,12 +113,11 @@ struct ArgyllRunnerCalibrationTests {
|
|||||||
outputURL: output
|
outputURL: output
|
||||||
)
|
)
|
||||||
|
|
||||||
setenv("ICCERY_MOCK_PRINTCAL_EXIT", "1", 1)
|
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
|
||||||
defer { unsetenv("ICCERY_MOCK_PRINTCAL_EXIT") }
|
|
||||||
|
|
||||||
await #expect(throws: (any Error).self) {
|
|
||||||
_ = try await runner.runPrintcal(config: config)
|
_ = try await runner.runPrintcal(config: config)
|
||||||
|
} errorHandler: { error in
|
||||||
|
XCTAssertEqual(error, .toolFailed(
|
||||||
|
tool: "printcal", code: 1, logs: ["printcal mock failure\n"]))
|
||||||
}
|
}
|
||||||
try? FileManager.default.removeItem(at: testRoot)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import XCTest
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
final class LogHolder: @unchecked Sendable {
|
final class LogHolder: @unchecked Sendable {
|
||||||
@@ -19,11 +19,9 @@ final class LogHolder: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suite("ArgyllRunner colprof")
|
final class ArgyllRunnerColprofTests: XCTestCase {
|
||||||
struct ArgyllRunnerColprofTests {
|
|
||||||
|
|
||||||
@Test("Mock colprof produces .icc")
|
func testColprofProducesIcc() async throws {
|
||||||
func colprofProducesIcc() async throws {
|
|
||||||
let binDir = URL(fileURLWithPath: #filePath)
|
let binDir = URL(fileURLWithPath: #filePath)
|
||||||
.deletingLastPathComponent()
|
.deletingLastPathComponent()
|
||||||
.deletingLastPathComponent()
|
.deletingLastPathComponent()
|
||||||
@@ -33,7 +31,7 @@ struct ArgyllRunnerColprofTests {
|
|||||||
try FileManager.default.createDirectory(at: testRoot, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: testRoot, withIntermediateDirectories: true)
|
||||||
|
|
||||||
let runner = ArgyllRunner(
|
let runner = ArgyllRunner(
|
||||||
processManager: .shared,
|
processManager: ProcessManager(),
|
||||||
binaryResolver: BinaryResolver(overrideDir: binDir)
|
binaryResolver: BinaryResolver(overrideDir: binDir)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -43,10 +41,39 @@ struct ArgyllRunnerColprofTests {
|
|||||||
holder.append(batch)
|
holder.append(batch)
|
||||||
}
|
}
|
||||||
|
|
||||||
#expect(url.lastPathComponent == "testrun.icc")
|
XCTAssertEqual(url.lastPathComponent, "testrun.icc")
|
||||||
#expect(FileManager.default.fileExists(atPath: url.path))
|
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
|
||||||
#expect(holder.lines.contains { $0.contains("Gamut mapping") })
|
XCTAssertTrue(holder.lines.contains { $0.contains("Gamut mapping") })
|
||||||
|
|
||||||
try? FileManager.default.removeItem(at: testRoot)
|
try? FileManager.default.removeItem(at: testRoot)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testColprofFailureThrowsToolFailed() async throws {
|
||||||
|
let dir = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("colprof-fail-\(UUID().uuidString)")
|
||||||
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
defer { try? FileManager.default.removeItem(at: dir) }
|
||||||
|
|
||||||
|
let mockURL = dir.appendingPathComponent("colprof")
|
||||||
|
try """
|
||||||
|
#!/bin/sh
|
||||||
|
echo "colprof broke" >&2
|
||||||
|
exit 4
|
||||||
|
""".write(to: mockURL, atomically: true, encoding: .utf8)
|
||||||
|
try FileManager.default.setAttributes(
|
||||||
|
[.posixPermissions: 0o755], ofItemAtPath: mockURL.path)
|
||||||
|
|
||||||
|
let runner = ArgyllRunner(
|
||||||
|
processManager: ProcessManager(),
|
||||||
|
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir)
|
||||||
|
)
|
||||||
|
let config = ColprofConfig(basename: "failrun", workingDirectory: dir)
|
||||||
|
|
||||||
|
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
|
||||||
|
try await runner.runColprof(config: config)
|
||||||
|
} errorHandler: { error in
|
||||||
|
XCTAssertEqual(error, .toolFailed(
|
||||||
|
tool: "colprof", code: 4, logs: ["colprof broke"]))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import Foundation
|
||||||
|
import XCTest
|
||||||
|
@testable import ICCeryCore
|
||||||
|
|
||||||
|
/// Focused contracts for the shared `runStreamingTool` loop (#79).
|
||||||
|
///
|
||||||
|
/// Every test uses a per-test temporary directory, unique basenames,
|
||||||
|
/// and a fresh `ProcessManager` — no shared UI fixture scripts and no
|
||||||
|
/// process-environment mutation.
|
||||||
|
final class ArgyllRunnerStreamingLoopTests: XCTestCase {
|
||||||
|
|
||||||
|
private func makeTempDir() throws -> URL {
|
||||||
|
let dir = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("runner-loop-\(UUID().uuidString)")
|
||||||
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
private func writeMock(_ name: String, _ body: String, in dir: URL) throws {
|
||||||
|
let url = dir.appendingPathComponent(name)
|
||||||
|
try body.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
try FileManager.default.setAttributes(
|
||||||
|
[.posixPermissions: 0o755], ofItemAtPath: url.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeRunner(binDir: URL) -> ArgyllRunner {
|
||||||
|
ArgyllRunner(
|
||||||
|
processManager: ProcessManager(),
|
||||||
|
binaryResolver: BinaryResolver(bundledRoot: binDir, overrideDir: binDir))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNonZeroExitThrowsToolFailed() async throws {
|
||||||
|
let dir = try makeTempDir()
|
||||||
|
defer { try? FileManager.default.removeItem(at: dir) }
|
||||||
|
try writeMock("targen", """
|
||||||
|
#!/bin/sh
|
||||||
|
echo "Generating patches..."
|
||||||
|
echo "targen: too few patches" >&2
|
||||||
|
exit 3
|
||||||
|
""", in: dir)
|
||||||
|
let runner = makeRunner(binDir: dir)
|
||||||
|
let config = TargenConfig(
|
||||||
|
colourSpace: .rgb, patchCount: 800, whitePatches: 4,
|
||||||
|
blackPatches: 4, basename: "fail", workingDirectory: dir)
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try await runner.runTargen(config: config)
|
||||||
|
XCTFail("Expected toolFailed")
|
||||||
|
} catch let error as ArgyllRunnerError {
|
||||||
|
guard case .toolFailed(let tool, let code, let logs) = error else {
|
||||||
|
XCTFail("Expected toolFailed, got \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
XCTAssertEqual(tool, "targen")
|
||||||
|
XCTAssertEqual(code, 3)
|
||||||
|
XCTAssertTrue(logs.contains("Generating patches..."))
|
||||||
|
XCTAssertTrue(logs.contains("targen: too few patches"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testZeroExitMissingArtefact() async throws {
|
||||||
|
let dir = try makeTempDir()
|
||||||
|
defer { try? FileManager.default.removeItem(at: dir) }
|
||||||
|
try writeMock("targen", """
|
||||||
|
#!/bin/sh
|
||||||
|
echo "done but wrote nothing"
|
||||||
|
exit 0
|
||||||
|
""", in: dir)
|
||||||
|
let runner = makeRunner(binDir: dir)
|
||||||
|
let expectedPath = dir.appendingPathComponent("gone.ti1").path
|
||||||
|
let config = TargenConfig(
|
||||||
|
colourSpace: .rgb, patchCount: 800, whitePatches: 4,
|
||||||
|
blackPatches: 4, basename: "gone", workingDirectory: dir)
|
||||||
|
|
||||||
|
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
|
||||||
|
try await runner.runTargen(config: config)
|
||||||
|
} errorHandler: { error in
|
||||||
|
XCTAssertEqual(error, .missingArtefact(expectedPath))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testImmediateExitDeliversLine() async throws {
|
||||||
|
let dir = try makeTempDir()
|
||||||
|
defer { try? FileManager.default.removeItem(at: dir) }
|
||||||
|
try writeMock("targen", """
|
||||||
|
#!/bin/sh
|
||||||
|
last=""
|
||||||
|
for arg in "$@"; do last="$arg"; done
|
||||||
|
echo "only line"
|
||||||
|
touch "$last.ti1"
|
||||||
|
exit 0
|
||||||
|
""", in: dir)
|
||||||
|
let runner = makeRunner(binDir: dir)
|
||||||
|
let config = TargenConfig(
|
||||||
|
colourSpace: .rgb, patchCount: 800, whitePatches: 4,
|
||||||
|
blackPatches: 4, basename: "quick", workingDirectory: dir)
|
||||||
|
|
||||||
|
let holder = LogHolder()
|
||||||
|
let url = try await runner.runTargen(config: config) { batch in
|
||||||
|
holder.append(batch)
|
||||||
|
}
|
||||||
|
XCTAssertEqual(url.lastPathComponent, "quick.ti1")
|
||||||
|
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
|
||||||
|
XCTAssertTrue(holder.lines.contains("only line"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testColprofPartialLineFlush() async throws {
|
||||||
|
let dir = try makeTempDir()
|
||||||
|
defer { try? FileManager.default.removeItem(at: dir) }
|
||||||
|
// The fragment is printed without a newline, then the mock sleeps
|
||||||
|
// past the 500 ms partial-line flush interval before writing the
|
||||||
|
// artefact and exiting — so the tail is delivered mid-run.
|
||||||
|
try writeMock("colprof", """
|
||||||
|
#!/bin/sh
|
||||||
|
last=""
|
||||||
|
for arg in "$@"; do last="$arg"; done
|
||||||
|
printf 'Doing gamut mapping'
|
||||||
|
sleep 2
|
||||||
|
touch "$last.icc"
|
||||||
|
exit 0
|
||||||
|
""", in: dir)
|
||||||
|
let runner = makeRunner(binDir: dir)
|
||||||
|
let config = ColprofConfig(basename: "frag", workingDirectory: dir)
|
||||||
|
|
||||||
|
let holder = LogHolder()
|
||||||
|
let url = try await runner.runColprof(config: config) { batch in
|
||||||
|
holder.append(batch)
|
||||||
|
}
|
||||||
|
XCTAssertEqual(url.lastPathComponent, "frag.icc")
|
||||||
|
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
|
||||||
|
XCTAssertTrue(holder.lines.contains("Doing gamut mapping"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testToolDescriptions() {
|
||||||
|
let cases: [(tool: String, expected: String)] = [
|
||||||
|
(tool: "chartread", expected: "Chartread failed: boom"),
|
||||||
|
(tool: "average", expected: "Averaging failed: boom"),
|
||||||
|
(tool: "colprof", expected: "Profile creation failed: boom"),
|
||||||
|
(tool: "printcal", expected: "Calibration curve computation failed: boom"),
|
||||||
|
(tool: "applycal", expected: "Apply calibration failed: boom"),
|
||||||
|
(tool: "iccgamut", expected: "Gamut extraction failed: boom"),
|
||||||
|
(tool: "profcheck", expected: "Profile verification failed: boom"),
|
||||||
|
]
|
||||||
|
for (tool, expected) in cases {
|
||||||
|
let error = ArgyllRunnerError.toolFailed(tool: tool, code: 1, logs: ["boom"])
|
||||||
|
XCTAssertEqual(error.errorDescription, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testGenericFallbacks() {
|
||||||
|
let unknown = ArgyllRunnerError.toolFailed(tool: "targen", code: 7, logs: ["boom"])
|
||||||
|
XCTAssertEqual(unknown.errorDescription, "Process exited with code 7")
|
||||||
|
|
||||||
|
let emptyLogs = ArgyllRunnerError.toolFailed(tool: "colprof", code: 2, logs: [])
|
||||||
|
XCTAssertEqual(emptyLogs.errorDescription,
|
||||||
|
"Profile creation failed: exited with code 2")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import Testing
|
import XCTest
|
||||||
import Foundation
|
import Foundation
|
||||||
import ImageIO
|
import ImageIO
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
@@ -10,9 +10,8 @@ private func tempURL(_ name: String) -> URL {
|
|||||||
.appendingPathComponent(name)
|
.appendingPathComponent(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suite("Ti2Header")
|
final class Ti2HeaderTests: XCTestCase {
|
||||||
struct Ti2HeaderTests {
|
func testParsesKeywordsAndSibling() throws {
|
||||||
@Test func parsesKeywordsAndSibling() throws {
|
|
||||||
let dir = FileManager.default.temporaryDirectory
|
let dir = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("iccery-ti2-\(UUID().uuidString)")
|
.appendingPathComponent("iccery-ti2-\(UUID().uuidString)")
|
||||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
@@ -31,18 +30,18 @@ struct Ti2HeaderTests {
|
|||||||
)
|
)
|
||||||
|
|
||||||
let h = Ti2Header.parse(dir.appendingPathComponent("job.ti2"))
|
let h = Ti2Header.parse(dir.appendingPathComponent("job.ti2"))
|
||||||
#expect(h.instrument == "i1iO")
|
XCTAssertEqual(h.instrument, "i1iO")
|
||||||
#expect(h.patchCount == 800)
|
XCTAssertEqual(h.patchCount, 800)
|
||||||
#expect(h.pageCount == 3)
|
XCTAssertEqual(h.pageCount, 3)
|
||||||
#expect(h.hasSiblingTi1)
|
XCTAssertTrue(h.hasSiblingTi1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func missingFileYieldsEmptyHeader() {
|
func testMissingFileYieldsEmptyHeader() {
|
||||||
let h = Ti2Header.parse(URL(fileURLWithPath: "/nonexistent/x.ti2"))
|
let h = Ti2Header.parse(URL(fileURLWithPath: "/nonexistent/x.ti2"))
|
||||||
#expect(h.instrument == nil && h.patchCount == nil && !h.hasSiblingTi1)
|
XCTAssertTrue(h.instrument == nil && h.patchCount == nil && !h.hasSiblingTi1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func numberOfFieldsIsNotPatchCount() throws {
|
func testNumberOfFieldsIsNotPatchCount() throws {
|
||||||
let url = tempURL("t.ti2")
|
let url = tempURL("t.ti2")
|
||||||
try FileManager.default.createDirectory(
|
try FileManager.default.createDirectory(
|
||||||
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||||
@@ -50,12 +49,11 @@ struct Ti2HeaderTests {
|
|||||||
try "NUMBER_OF_FIELDS 9\nNUMBER_OF_SETS 52\nBEGIN_DATA\n".write(
|
try "NUMBER_OF_FIELDS 9\nNUMBER_OF_SETS 52\nBEGIN_DATA\n".write(
|
||||||
to: url, atomically: true, encoding: .utf8
|
to: url, atomically: true, encoding: .utf8
|
||||||
)
|
)
|
||||||
#expect(Ti2Header.parse(url).patchCount == 52)
|
XCTAssertEqual(Ti2Header.parse(url).patchCount, 52)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suite("TiffPreview")
|
final class TiffPreviewTests: XCTestCase {
|
||||||
struct TiffPreviewTests {
|
|
||||||
/// Builds a real 2000×1000 TIFF in a temp dir via ImageIO.
|
/// Builds a real 2000×1000 TIFF in a temp dir via ImageIO.
|
||||||
private func makeTiff(width: Int = 2000, height: Int = 1000) throws -> URL {
|
private func makeTiff(width: Int = 2000, height: Int = 1000) throws -> URL {
|
||||||
let url = tempURL("big.tif")
|
let url = tempURL("big.tif")
|
||||||
@@ -81,70 +79,190 @@ struct TiffPreviewTests {
|
|||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func producesCappedPNG() throws {
|
func testProducesCappedPNG() throws {
|
||||||
let tiff = try makeTiff()
|
let tiff = try makeTiff()
|
||||||
let png = TiffPreview.previewPNG(tiff: tiff)
|
let png = TiffPreview.previewPNG(tiff: tiff)
|
||||||
#expect(png != nil)
|
XCTAssertNotNil(png)
|
||||||
// PNG magic
|
// PNG magic
|
||||||
#expect(png!.prefix(8) == Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]))
|
XCTAssertEqual(png!.prefix(8), Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]))
|
||||||
// Verify the cap by decoding the thumbnail header.
|
// Verify the cap by decoding the thumbnail header.
|
||||||
let src = CGImageSourceCreateWithData(png! as CFData, nil)!
|
let src = CGImageSourceCreateWithData(png! as CFData, nil)!
|
||||||
let img = CGImageSourceCreateImageAtIndex(src, 0, nil)!
|
let img = CGImageSourceCreateImageAtIndex(src, 0, nil)!
|
||||||
#expect(max(img.width, img.height) <= TiffPreview.maxEdge)
|
XCTAssertTrue(max(img.width, img.height) <= TiffPreview.maxEdge)
|
||||||
#expect(img.width == 1200)
|
XCTAssertEqual(img.width, 1200)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func nonTiffReturnsNil() throws {
|
func testNonTiffReturnsNil() throws {
|
||||||
let url = tempURL("not-tiff.txt")
|
let url = tempURL("not-tiff.txt")
|
||||||
try FileManager.default.createDirectory(
|
try FileManager.default.createDirectory(
|
||||||
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||||
)
|
)
|
||||||
try "hello".write(to: url, atomically: true, encoding: .utf8)
|
try "hello".write(to: url, atomically: true, encoding: .utf8)
|
||||||
#expect(TiffPreview.previewPNG(tiff: url) == nil)
|
XCTAssertNil(TiffPreview.previewPNG(tiff: url))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suite("ArtefactFiles")
|
final class AppInfoTests: XCTestCase {
|
||||||
struct ArtefactFilesTests {
|
// MARK: displayVersion (#189)
|
||||||
@Test func base64RoundTrip() throws {
|
|
||||||
|
func testNoTagFallsBackToShortVersion() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
ArtefactFiles.displayVersion(shortVersion: "2.0.0", releaseTag: ""),
|
||||||
|
"2.0.0"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testWhitespaceTagFallsBackToShortVersion() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
ArtefactFiles.displayVersion(shortVersion: "2.0.0", releaseTag: " "),
|
||||||
|
"2.0.0"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testExactReleaseTagDedupes() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
ArtefactFiles.displayVersion(shortVersion: "2.0.0", releaseTag: "v2.0.0"),
|
||||||
|
"v2.0.0"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBareVersionTagDedupes() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
ArtefactFiles.displayVersion(shortVersion: "2.0.0", releaseTag: "2.0.0"),
|
||||||
|
"2.0.0"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPrereleaseTagShowsMarketingInParens() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
ArtefactFiles.displayVersion(
|
||||||
|
shortVersion: "2.0.0", releaseTag: "v2.0.0-pre2-grok"),
|
||||||
|
"v2.0.0-pre2-grok (2.0.0)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDescribeStringShowsMarketingInParens() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
ArtefactFiles.displayVersion(
|
||||||
|
shortVersion: "2.0.0", releaseTag: "v2.0.0-5-gdeadbee"),
|
||||||
|
"v2.0.0-5-gdeadbee (2.0.0)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAppInfoUsesComposedVersion() {
|
||||||
|
// .main in the test host is the ICCery app under test; whatever it
|
||||||
|
// resolves to, build must be a non-empty digits-or-default string.
|
||||||
|
let info = ArtefactFiles.appInfo()
|
||||||
|
XCTAssertFalse(info.version.isEmpty)
|
||||||
|
XCTAssertFalse(info.build.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class ArtefactFilesTests: XCTestCase {
|
||||||
|
func testBase64RoundTrip() throws {
|
||||||
let url = tempURL("a.txt")
|
let url = tempURL("a.txt")
|
||||||
try FileManager.default.createDirectory(
|
try FileManager.default.createDirectory(
|
||||||
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||||
)
|
)
|
||||||
try "hello".write(to: url, atomically: true, encoding: .utf8)
|
try "hello".write(to: url, atomically: true, encoding: .utf8)
|
||||||
let b64 = try ArtefactFiles.readBase64(url)
|
let b64 = try ArtefactFiles.readBase64(url)
|
||||||
#expect(Data(base64Encoded: b64) == Data("hello".utf8))
|
XCTAssertEqual(Data(base64Encoded: b64), Data("hello".utf8))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func defaultWorkingDirExists() {
|
func testDefaultWorkingDirExists() {
|
||||||
#expect(FileManager.default.fileExists(
|
XCTAssertTrue(FileManager.default.fileExists(
|
||||||
atPath: ArtefactFiles.defaultWorkingDirectory().path
|
atPath: ArtefactFiles.defaultWorkingDirectory().path
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suite("ArtefactProbe profile resolve")
|
final class ArtefactProbeProfileTests: XCTestCase {
|
||||||
struct ArtefactProbeProfileTests {
|
private func makeDir() throws -> URL {
|
||||||
@Test("basename probe prefers .icm")
|
|
||||||
func icmWins() throws {
|
|
||||||
let dir = FileManager.default.temporaryDirectory
|
let dir = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("probe-\(UUID().uuidString)")
|
.appendingPathComponent("probe-\(UUID().uuidString)")
|
||||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
try Data("icc".utf8).write(to: dir.appendingPathComponent("job.icc"))
|
return dir
|
||||||
try Data("icm".utf8).write(to: dir.appendingPathComponent("job.icm"))
|
|
||||||
let url = ArtefactProbe.resolveProfile(basename: "job", cwd: dir)
|
|
||||||
#expect(url?.pathExtension == "icm")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("explicit missing .icc flips to sibling .icm")
|
// MARK: Basename probe matrix (#69)
|
||||||
func flipExtension() throws {
|
|
||||||
let dir = FileManager.default.temporaryDirectory
|
func testOnlyIcc() throws {
|
||||||
.appendingPathComponent("probe-\(UUID().uuidString)")
|
let dir = try makeDir()
|
||||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
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 icc = dir.appendingPathComponent("job.icc")
|
||||||
let icm = dir.appendingPathComponent("job.icm")
|
let icm = dir.appendingPathComponent("job.icm")
|
||||||
try Data("icm".utf8).write(to: icm)
|
try Data("icm".utf8).write(to: icm)
|
||||||
let resolved = ArtefactProbe.resolveProfile(icc)
|
let resolved = ArtefactProbe.resolveProfile(icc)
|
||||||
#expect(resolved.path == icm.path)
|
XCTAssertEqual(resolved.path, icm.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFlipToIcc() throws {
|
||||||
|
let dir = try makeDir()
|
||||||
|
let icc = dir.appendingPathComponent("job.icc")
|
||||||
|
let icm = dir.appendingPathComponent("job.icm")
|
||||||
|
try Data("icc".utf8).write(to: icc)
|
||||||
|
XCTAssertEqual(ArtefactProbe.resolveProfile(icm).path, icc.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMissingBoth() throws {
|
||||||
|
let dir = try makeDir()
|
||||||
|
let icc = dir.appendingPathComponent("job.icc")
|
||||||
|
XCTAssertEqual(ArtefactProbe.resolveProfile(icc).path, icc.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUnrelatedExtension() throws {
|
||||||
|
let dir = try makeDir()
|
||||||
|
let mpp = dir.appendingPathComponent("job.mpp")
|
||||||
|
let icc = dir.appendingPathComponent("job.icc")
|
||||||
|
try Data("icc".utf8).write(to: icc)
|
||||||
|
// Even though a sibling .icc exists, a missing .mpp stays .mpp.
|
||||||
|
XCTAssertEqual(ArtefactProbe.resolveProfile(mpp).path, mpp.path)
|
||||||
|
let txt = dir.appendingPathComponent("job.txt")
|
||||||
|
XCTAssertEqual(ArtefactProbe.resolveProfile(txt).path, txt.path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import Testing
|
import XCTest
|
||||||
import Foundation
|
import Foundation
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
@Suite("BinaryResolver")
|
final class BinaryResolverTests: XCTestCase {
|
||||||
struct BinaryResolverTests {
|
|
||||||
|
|
||||||
private func makeTree(_ body: (URL) throws -> Void) throws -> URL {
|
private func makeTree(_ body: (URL) throws -> Void) throws -> URL {
|
||||||
let root = FileManager.default.temporaryDirectory
|
let root = FileManager.default.temporaryDirectory
|
||||||
@@ -22,16 +21,16 @@ struct BinaryResolverTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func overrideDirWinsWhenFileExists() throws {
|
func testOverrideDirWinsWhenFileExists() throws {
|
||||||
let override = try makeTree { root in
|
let override = try makeTree { root in
|
||||||
try touch(root.appendingPathComponent("targen"))
|
try touch(root.appendingPathComponent("targen"))
|
||||||
}
|
}
|
||||||
let bundled = try makeTree { _ in }
|
let bundled = try makeTree { _ in }
|
||||||
let r = BinaryResolver(bundledRoot: bundled, overrideDir: override)
|
let r = BinaryResolver(bundledRoot: bundled, overrideDir: override)
|
||||||
#expect(r.resolve("targen") == override.appendingPathComponent("targen"))
|
XCTAssertEqual(r.resolve("targen"), override.appendingPathComponent("targen"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func overrideFallsThroughWhenMissing() throws {
|
func testOverrideFallsThroughWhenMissing() throws {
|
||||||
let override = try makeTree { _ in }
|
let override = try makeTree { _ in }
|
||||||
let bundled = try makeTree { root in
|
let bundled = try makeTree { root in
|
||||||
let dir = root.appendingPathComponent("macos-universal")
|
let dir = root.appendingPathComponent("macos-universal")
|
||||||
@@ -39,10 +38,10 @@ struct BinaryResolverTests {
|
|||||||
try touch(dir.appendingPathComponent("instlist"))
|
try touch(dir.appendingPathComponent("instlist"))
|
||||||
}
|
}
|
||||||
let r = BinaryResolver(bundledRoot: bundled, overrideDir: override)
|
let r = BinaryResolver(bundledRoot: bundled, overrideDir: override)
|
||||||
#expect(r.resolve("targen").path.contains("macos-universal/targen"))
|
XCTAssertTrue(r.resolve("targen").path.contains("macos-universal/targen"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func universalPreferredWhenMarkerPresent() throws {
|
func testUniversalPreferredWhenMarkerPresent() throws {
|
||||||
let bundled = try makeTree { root in
|
let bundled = try makeTree { root in
|
||||||
for dir in ["macos-universal", "macos-x86_64"] {
|
for dir in ["macos-universal", "macos-x86_64"] {
|
||||||
let d = root.appendingPathComponent(dir)
|
let d = root.appendingPathComponent(dir)
|
||||||
@@ -51,10 +50,10 @@ struct BinaryResolverTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let r = BinaryResolver(bundledRoot: bundled)
|
let r = BinaryResolver(bundledRoot: bundled)
|
||||||
#expect(r.platformDir() == "macos-universal")
|
XCTAssertEqual(r.platformDir(), "macos-universal")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func fallsBackToArchDir() throws {
|
func testFallsBackToArchDir() throws {
|
||||||
let bundled = try makeTree { root in
|
let bundled = try makeTree { root in
|
||||||
let d = root.appendingPathComponent("macos-x86_64")
|
let d = root.appendingPathComponent("macos-x86_64")
|
||||||
try FileManager.default.createDirectory(at: d, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: d, withIntermediateDirectories: true)
|
||||||
@@ -64,20 +63,20 @@ struct BinaryResolverTests {
|
|||||||
bundledRoot: bundled,
|
bundledRoot: bundled,
|
||||||
archDirs: ["macos-universal", "macos-x86_64"]
|
archDirs: ["macos-universal", "macos-x86_64"]
|
||||||
)
|
)
|
||||||
#expect(r.platformDir() == "macos-x86_64")
|
XCTAssertEqual(r.platformDir(), "macos-x86_64")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func missingEverythingReturnsConstructedPath() throws {
|
func testMissingEverythingReturnsConstructedPath() throws {
|
||||||
let bundled = try makeTree { _ in }
|
let bundled = try makeTree { _ in }
|
||||||
let r = BinaryResolver(bundledRoot: bundled)
|
let r = BinaryResolver(bundledRoot: bundled)
|
||||||
// v1 semantic: path is returned; spawn surfaces the error.
|
// v1 semantic: path is returned; spawn surfaces the error.
|
||||||
#expect(r.resolve("targen").path.hasSuffix("macos-universal/targen"))
|
XCTAssertTrue(r.resolve("targen").path.hasSuffix("macos-universal/targen"))
|
||||||
#expect(!r.exists(r.resolve("targen")))
|
XCTAssertFalse(r.exists(r.resolve("targen")))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func mockAndGamutPaths() throws {
|
func testMockAndGamutPaths() throws {
|
||||||
let r = BinaryResolver(bundledRoot: URL(fileURLWithPath: "/x"))
|
let r = BinaryResolver(bundledRoot: URL(fileURLWithPath: "/x"))
|
||||||
#expect(r.mock("chartread").path == "/x/mocks/chartread.mock")
|
XCTAssertEqual(r.mock("chartread").path, "/x/mocks/chartread.mock")
|
||||||
#expect(r.referenceGamut("sRGB.gam").path == "/x/reference_gamuts/sRGB.gam")
|
XCTAssertEqual(r.referenceGamut("sRGB.gam").path, "/x/reference_gamuts/sRGB.gam")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import XCTest
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
@Suite("CGATS Parser & Writer")
|
final class CGATSParserTests: XCTestCase {
|
||||||
struct CGATSParserTests {
|
|
||||||
|
|
||||||
private static let canonicalCTI3 = """
|
private static let canonicalCTI3 = """
|
||||||
CTI3
|
CTI3
|
||||||
@@ -21,44 +20,40 @@ struct CGATSParserTests {
|
|||||||
END_DATA
|
END_DATA
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@Test("Parses CTI3 with canonical field names")
|
func testParseCTI3() throws {
|
||||||
func parseCTI3() throws {
|
|
||||||
let dataset = try CGATSParser.parse(Self.canonicalCTI3)
|
let dataset = try CGATSParser.parse(Self.canonicalCTI3)
|
||||||
#expect(dataset.format == .cti3)
|
XCTAssertEqual(dataset.format, .cti3)
|
||||||
#expect(dataset.samples.count == 2)
|
XCTAssertEqual(dataset.samples.count, 2)
|
||||||
#expect(dataset.colorRep == "RGB")
|
XCTAssertEqual(dataset.colorRep, "RGB")
|
||||||
#expect(dataset.deviceClass == "DISPLAY")
|
XCTAssertEqual(dataset.deviceClass, "DISPLAY")
|
||||||
#expect(dataset.samples[0].id == "1")
|
XCTAssertEqual(dataset.samples[0].id, "1")
|
||||||
#expect(dataset.samples[0].loc == "A1")
|
XCTAssertEqual(dataset.samples[0].loc, "A1")
|
||||||
#expect(dataset.samples[1].values["RGB_G"] == "50.0000")
|
XCTAssertEqual(dataset.samples[1].values["RGB_G"], "50.0000")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Round-trips parse, write, reparse")
|
func testRoundTrip() throws {
|
||||||
func roundTrip() throws {
|
|
||||||
let first = try CGATSParser.parse(Self.canonicalCTI3)
|
let first = try CGATSParser.parse(Self.canonicalCTI3)
|
||||||
let text = try CGATSWriter.write(first)
|
let text = try CGATSWriter.write(first)
|
||||||
let second = try CGATSParser.parse(text)
|
let second = try CGATSParser.parse(text)
|
||||||
#expect(second.format == first.format)
|
XCTAssertEqual(second.format, first.format)
|
||||||
#expect(second.samples.count == first.samples.count)
|
XCTAssertEqual(second.samples.count, first.samples.count)
|
||||||
#expect(second.colorRep == first.colorRep)
|
XCTAssertEqual(second.colorRep, first.colorRep)
|
||||||
#expect(second.deviceClass == first.deviceClass)
|
XCTAssertEqual(second.deviceClass, first.deviceClass)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Parses CSV with comma delimiters")
|
func testParseCSV() throws {
|
||||||
func parseCSV() throws {
|
|
||||||
let csv = """
|
let csv = """
|
||||||
SAMPLE_ID,SAMPLE_LOC,RGB_R,RGB_G,RGB_B,XYZ_X,XYZ_Y,XYZ_Z,LAB_L,LAB_A,LAB_B
|
SAMPLE_ID,SAMPLE_LOC,RGB_R,RGB_G,RGB_B,XYZ_X,XYZ_Y,XYZ_Z,LAB_L,LAB_A,LAB_B
|
||||||
1,A1,50,0,0,20,10,5,50,60,30
|
1,A1,50,0,0,20,10,5,50,60,30
|
||||||
2,A2,0,50,0,10,30,5,60,-50,40
|
2,A2,0,50,0,10,30,5,60,-50,40
|
||||||
"""
|
"""
|
||||||
let dataset = try CGATSParser.parse(csv, sourceURL: URL(fileURLWithPath: "/tmp/sample.csv"))
|
let dataset = try CGATSParser.parse(csv, sourceURL: URL(fileURLWithPath: "/tmp/sample.csv"))
|
||||||
#expect(dataset.format == .csv)
|
XCTAssertEqual(dataset.format, .csv)
|
||||||
#expect(dataset.samples.count == 2)
|
XCTAssertEqual(dataset.samples.count, 2)
|
||||||
#expect(dataset.samples[0].values["RGB_R"] == "50.0000")
|
XCTAssertEqual(dataset.samples[0].values["RGB_R"], "50.0000")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Converts 0-255 device values to 0-100")
|
func testConverts255To100() throws {
|
||||||
func converts255To100() throws {
|
|
||||||
let rgb = """
|
let rgb = """
|
||||||
CTI3
|
CTI3
|
||||||
COLOR_REP RGB
|
COLOR_REP RGB
|
||||||
@@ -72,12 +67,11 @@ struct CGATSParserTests {
|
|||||||
END_DATA
|
END_DATA
|
||||||
"""
|
"""
|
||||||
let dataset = try CGATSParser.parse(rgb)
|
let dataset = try CGATSParser.parse(rgb)
|
||||||
#expect(dataset.samples[0].values["RGB_R"] == "100.0000")
|
XCTAssertEqual(dataset.samples[0].values["RGB_R"], "100.0000")
|
||||||
#expect(dataset.samples[0].values["RGB_G"] == "50.1961")
|
XCTAssertEqual(dataset.samples[0].values["RGB_G"], "50.1961")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Synthesizes COLOR_REP and DEVICE_CLASS when missing")
|
func testSynthesizesMetadata() throws {
|
||||||
func synthesizesMetadata() throws {
|
|
||||||
let cmyk = """
|
let cmyk = """
|
||||||
CTI3
|
CTI3
|
||||||
NUMBER_OF_FIELDS 6
|
NUMBER_OF_FIELDS 6
|
||||||
@@ -90,19 +84,15 @@ struct CGATSParserTests {
|
|||||||
END_DATA
|
END_DATA
|
||||||
"""
|
"""
|
||||||
let dataset = try CGATSParser.parse(cmyk)
|
let dataset = try CGATSParser.parse(cmyk)
|
||||||
#expect(dataset.colorRep == "CMYK")
|
XCTAssertEqual(dataset.colorRep, "CMYK")
|
||||||
#expect(dataset.deviceClass == "PRINTER")
|
XCTAssertEqual(dataset.deviceClass, "PRINTER")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Rejects empty file")
|
func testRejectsEmpty() {
|
||||||
func rejectsEmpty() {
|
XCTAssertThrowsError(try CGATSParser.parse(""))
|
||||||
#expect(throws: (any Error).self) {
|
|
||||||
_ = try CGATSParser.parse("")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Rejects malformed arity")
|
func testRejectsArity() {
|
||||||
func rejectsArity() {
|
|
||||||
let bad = """
|
let bad = """
|
||||||
CTI3
|
CTI3
|
||||||
NUMBER_OF_FIELDS 2
|
NUMBER_OF_FIELDS 2
|
||||||
@@ -114,21 +104,18 @@ struct CGATSParserTests {
|
|||||||
1
|
1
|
||||||
END_DATA
|
END_DATA
|
||||||
"""
|
"""
|
||||||
#expect(throws: (any Error).self) {
|
XCTAssertThrowsError(try CGATSParser.parse(bad))
|
||||||
_ = try CGATSParser.parse(bad)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Writer emits valid .ti3 with tabs and required keywords")
|
func testWriterFormat() throws {
|
||||||
func writerFormat() throws {
|
|
||||||
let dataset = try CGATSParser.parse(Self.canonicalCTI3)
|
let dataset = try CGATSParser.parse(Self.canonicalCTI3)
|
||||||
let text = try CGATSWriter.write(dataset)
|
let text = try CGATSWriter.write(dataset)
|
||||||
#expect(text.contains("CTI3"))
|
XCTAssertTrue(text.contains("CTI3"))
|
||||||
#expect(text.contains("BEGIN_DATA_FORMAT"))
|
XCTAssertTrue(text.contains("BEGIN_DATA_FORMAT"))
|
||||||
#expect(text.contains("BEGIN_DATA"))
|
XCTAssertTrue(text.contains("BEGIN_DATA"))
|
||||||
#expect(text.contains("END_DATA"))
|
XCTAssertTrue(text.contains("END_DATA"))
|
||||||
#expect(text.contains("COLOR_REP"))
|
XCTAssertTrue(text.contains("COLOR_REP"))
|
||||||
#expect(text.contains("DEVICE_CLASS"))
|
XCTAssertTrue(text.contains("DEVICE_CLASS"))
|
||||||
#expect(text.contains("\t"))
|
XCTAssertTrue(text.contains("\t"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import Foundation
|
||||||
|
import XCTest
|
||||||
|
@testable import ICCeryCore
|
||||||
|
|
||||||
|
/// Issue #83 — canonical `CAL_` / original-stem pairing.
|
||||||
|
final class CalibrationIdentityTests: XCTestCase {
|
||||||
|
func testLivePlain() {
|
||||||
|
let id = CalibrationIdentity.parse(liveBasename: "foo", persistedOriginal: "")
|
||||||
|
XCTAssertEqual(id.originalBasename, "foo")
|
||||||
|
XCTAssertEqual(id.calibrationBasename, "CAL_foo")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLivePlainIgnoresPersisted() {
|
||||||
|
let id = CalibrationIdentity.parse(liveBasename: "foo", persistedOriginal: "bar")
|
||||||
|
XCTAssertEqual(id.originalBasename, "foo")
|
||||||
|
XCTAssertEqual(id.calibrationBasename, "CAL_foo")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLiveCalPersisted() {
|
||||||
|
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "foo")
|
||||||
|
XCTAssertEqual(id.originalBasename, "foo")
|
||||||
|
XCTAssertEqual(id.calibrationBasename, "CAL_foo")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLiveCalNoPersist() {
|
||||||
|
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "")
|
||||||
|
XCTAssertEqual(id.originalBasename, "foo")
|
||||||
|
XCTAssertEqual(id.calibrationBasename, "CAL_foo")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPersistedWins() {
|
||||||
|
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "bar")
|
||||||
|
XCTAssertEqual(id.originalBasename, "bar")
|
||||||
|
XCTAssertEqual(id.calibrationBasename, "CAL_bar")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEmptyLiveWithPersisted() {
|
||||||
|
let id = CalibrationIdentity.parse(liveBasename: "", persistedOriginal: "foo")
|
||||||
|
XCTAssertTrue(id.originalBasename.isEmpty)
|
||||||
|
XCTAssertTrue(id.calibrationBasename.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEmptyLive() {
|
||||||
|
let id = CalibrationIdentity.parse(liveBasename: "", persistedOriginal: "")
|
||||||
|
XCTAssertTrue(id.originalBasename.isEmpty)
|
||||||
|
XCTAssertTrue(id.calibrationBasename.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAlreadyPrefixed() {
|
||||||
|
XCTAssertEqual(CalibrationIdentity.prefix("CAL_foo"), "CAL_foo")
|
||||||
|
XCTAssertEqual(CalibrationIdentity.prefix("foo"), "CAL_foo")
|
||||||
|
let id = CalibrationIdentity.parse(liveBasename: "CAL_CAL_foo", persistedOriginal: "")
|
||||||
|
XCTAssertEqual(id.originalBasename, "CAL_foo")
|
||||||
|
XCTAssertEqual(id.calibrationBasename, "CAL_foo")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPrefixEmpty() {
|
||||||
|
XCTAssertTrue(CalibrationIdentity.prefix("").isEmpty)
|
||||||
|
XCTAssertEqual(CalibrationIdentity.strip("foo"), "foo")
|
||||||
|
XCTAssertEqual(CalibrationIdentity.strip("CAL_foo"), "foo")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testProcessIdMatches() {
|
||||||
|
let cal = CalibrationIdentity.prefix("foo")
|
||||||
|
XCTAssertEqual(ProcessID.targen(cal), "targen_CAL_foo")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import XCTest
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
@Suite("CalibrationStore")
|
final class CalibrationStoreTests: XCTestCase {
|
||||||
struct CalibrationStoreTests {
|
|
||||||
|
|
||||||
private static let sampleCal = """
|
private static let sampleCal = """
|
||||||
CTI3
|
CTI3
|
||||||
@@ -23,8 +22,7 @@ struct CalibrationStoreTests {
|
|||||||
END_DATA
|
END_DATA
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@Test("Loads metadata and curves from .cal")
|
func testParseCal() async throws {
|
||||||
func parseCal() async throws {
|
|
||||||
let url = FileManager.default.temporaryDirectory
|
let url = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("test_\(UUID().uuidString).cal")
|
.appendingPathComponent("test_\(UUID().uuidString).cal")
|
||||||
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
|
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
|
||||||
@@ -33,17 +31,16 @@ struct CalibrationStoreTests {
|
|||||||
try await store.load(url: url)
|
try await store.load(url: url)
|
||||||
|
|
||||||
let data = await store.data
|
let data = await store.data
|
||||||
#expect(data?.colorRep == "RGB")
|
XCTAssertEqual(data?.colorRep, "RGB")
|
||||||
#expect(data?.descriptor == "Test printer")
|
XCTAssertEqual(data?.descriptor, "Test printer")
|
||||||
#expect(data?.maxTac == 300)
|
XCTAssertEqual(data?.maxTac, 300)
|
||||||
#expect(data?.curves.count == 3)
|
XCTAssertEqual(data?.curves.count, 3)
|
||||||
|
|
||||||
let r = data?.curves.first { $0.channel == "R" }
|
let r = data?.curves.first { $0.channel == "R" }
|
||||||
#expect(r?.output == [0, 64, 255])
|
XCTAssertEqual(r?.output, [0, 64, 255])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Staleness is true for a very old calibration")
|
func testStaleCalibration() async throws {
|
||||||
func staleCalibration() async throws {
|
|
||||||
let url = FileManager.default.temporaryDirectory
|
let url = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("stale_\(UUID().uuidString).cal")
|
.appendingPathComponent("stale_\(UUID().uuidString).cal")
|
||||||
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
|
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
|
||||||
@@ -51,11 +48,10 @@ struct CalibrationStoreTests {
|
|||||||
let store = CalibrationStore(staleDays: 0)
|
let store = CalibrationStore(staleDays: 0)
|
||||||
try await store.load(url: url)
|
try await store.load(url: url)
|
||||||
let stale = await store.isStale(comparedTo: "Other")
|
let stale = await store.isStale(comparedTo: "Other")
|
||||||
#expect(stale == true)
|
XCTAssertEqual(stale, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Printer mismatch is flagged as stale")
|
func testPrinterMismatch() async throws {
|
||||||
func printerMismatch() async throws {
|
|
||||||
let url = FileManager.default.temporaryDirectory
|
let url = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("mismatch_\(UUID().uuidString).cal")
|
.appendingPathComponent("mismatch_\(UUID().uuidString).cal")
|
||||||
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
|
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
|
||||||
@@ -64,6 +60,6 @@ struct CalibrationStoreTests {
|
|||||||
try await store.load(url: url)
|
try await store.load(url: url)
|
||||||
await store.setPrinterName("Printer A")
|
await store.setPrinterName("Printer A")
|
||||||
let stale = await store.isStale(comparedTo: "Printer B")
|
let stale = await store.isStale(comparedTo: "Printer B")
|
||||||
#expect(stale == true)
|
XCTAssertEqual(stale, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import XCTest
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
@Suite("CalibrationTargenArgs")
|
final class CalibrationTargenArgsTests: XCTestCase {
|
||||||
struct CalibrationTargenArgsTests {
|
|
||||||
|
|
||||||
@Test("RGB baseline")
|
func testRgbBaseline() throws {
|
||||||
func rgbBaseline() throws {
|
|
||||||
let config = CalibrationTargenConfig(
|
let config = CalibrationTargenConfig(
|
||||||
colourSpace: .rgb,
|
colourSpace: .rgb,
|
||||||
steps: 21,
|
steps: 21,
|
||||||
@@ -15,11 +13,10 @@ struct CalibrationTargenArgsTests {
|
|||||||
workingDirectory: URL(fileURLWithPath: "/tmp")
|
workingDirectory: URL(fileURLWithPath: "/tmp")
|
||||||
)
|
)
|
||||||
let args = try CalibrationTargenArgs.build(config: config)
|
let args = try CalibrationTargenArgs.build(config: config)
|
||||||
#expect(args == ["-v", "-d", "2", "-s", "21", "-g", "21", "-e", "4", "-f", "0", "CAL_demo"])
|
XCTAssertEqual(args, ["-v", "-d", "2", "-s", "21", "-g", "21", "-e", "4", "-f", "0", "CAL_demo"])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("CMYK baseline with ink limit and neutral emphasis")
|
func testCmykWithOptions() throws {
|
||||||
func cmykWithOptions() throws {
|
|
||||||
let config = CalibrationTargenConfig(
|
let config = CalibrationTargenConfig(
|
||||||
colourSpace: .cmyk,
|
colourSpace: .cmyk,
|
||||||
steps: 25,
|
steps: 25,
|
||||||
@@ -30,33 +27,26 @@ struct CalibrationTargenArgsTests {
|
|||||||
workingDirectory: URL(fileURLWithPath: "/tmp")
|
workingDirectory: URL(fileURLWithPath: "/tmp")
|
||||||
)
|
)
|
||||||
let args = try CalibrationTargenArgs.build(config: config)
|
let args = try CalibrationTargenArgs.build(config: config)
|
||||||
#expect(args == ["-v", "-d", "4", "-s", "25", "-g", "25", "-e", "4", "-f", "0", "-n", "25", "-l", "320", "CAL_printer"])
|
XCTAssertEqual(args, ["-v", "-d", "4", "-s", "25", "-g", "25", "-e", "4", "-f", "0", "-n", "25", "-l", "320", "CAL_printer"])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Rejects out-of-range steps")
|
func testRejectsBadSteps() {
|
||||||
func rejectsBadSteps() {
|
|
||||||
let config = CalibrationTargenConfig(steps: 5, basename: "demo")
|
let config = CalibrationTargenConfig(steps: 5, basename: "demo")
|
||||||
#expect(throws: (any Error).self) {
|
XCTAssertThrowsError(try CalibrationTargenArgs.build(config: config))
|
||||||
_ = try CalibrationTargenArgs.build(config: config)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Rejects bad CMYK ink limit")
|
func testRejectsBadInkLimit() {
|
||||||
func rejectsBadInkLimit() {
|
|
||||||
let config = CalibrationTargenConfig(
|
let config = CalibrationTargenConfig(
|
||||||
colourSpace: .cmyk,
|
colourSpace: .cmyk,
|
||||||
inkLimit: 500,
|
inkLimit: 500,
|
||||||
basename: "demo"
|
basename: "demo"
|
||||||
)
|
)
|
||||||
#expect(throws: (any Error).self) {
|
XCTAssertThrowsError(try CalibrationTargenArgs.build(config: config))
|
||||||
_ = try CalibrationTargenArgs.build(config: config)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Does not double-prefix an existing CAL_ basename")
|
func testNoDoublePrefix() throws {
|
||||||
func noDoublePrefix() throws {
|
|
||||||
let config = CalibrationTargenConfig(basename: "CAL_test")
|
let config = CalibrationTargenConfig(basename: "CAL_test")
|
||||||
let args = try CalibrationTargenArgs.build(config: config)
|
let args = try CalibrationTargenArgs.build(config: config)
|
||||||
#expect(args.last == "CAL_test")
|
XCTAssertEqual(args.last, "CAL_test")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,72 +1,63 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import XCTest
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
@Suite("ColprofArgs")
|
final class ColprofArgsTests: XCTestCase {
|
||||||
struct ColprofArgsTests {
|
|
||||||
|
|
||||||
@Test("Default algorithm and quality")
|
func testDefaults() throws {
|
||||||
func defaults() throws {
|
|
||||||
let config = ColprofConfig(basename: "target")
|
let config = ColprofConfig(basename: "target")
|
||||||
let args = try ColprofArgs.build(config: config)
|
let args = try ColprofArgs.build(config: config)
|
||||||
#expect(args == ["-v", "-a", "l", "-q", "m", "target"])
|
XCTAssertEqual(args, ["-v", "-a", "l", "-q", "m", "target"])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("FWA bare -f when empty string")
|
func testFwaBareFlag() throws {
|
||||||
func fwaBareFlag() throws {
|
|
||||||
let config = ColprofConfig(fwa: "", basename: "target")
|
let config = ColprofConfig(fwa: "", basename: "target")
|
||||||
let args = try ColprofArgs.build(config: config)
|
let args = try ColprofArgs.build(config: config)
|
||||||
#expect(args == ["-v", "-a", "l", "-q", "m", "-f", "target"])
|
XCTAssertEqual(args, ["-v", "-a", "l", "-q", "m", "-f", "target"])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("FWA D50 and D65 emit -f value")
|
func testFwaD50() throws {
|
||||||
func fwaD50() throws {
|
|
||||||
let config = ColprofConfig(fwa: "D50", basename: "target")
|
let config = ColprofConfig(fwa: "D50", basename: "target")
|
||||||
let args = try ColprofArgs.build(config: config)
|
let args = try ColprofArgs.build(config: config)
|
||||||
#expect(args.contains("-f"))
|
XCTAssertTrue(args.contains("-f"))
|
||||||
#expect(args.contains("D50"))
|
XCTAssertTrue(args.contains("D50"))
|
||||||
#expect(args.last == "target")
|
XCTAssertEqual(args.last, "target")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("FWA none is omitted")
|
func testFwaNoneOmitted() throws {
|
||||||
func fwaNoneOmitted() throws {
|
|
||||||
let config = ColprofConfig(fwa: "none", basename: "target")
|
let config = ColprofConfig(fwa: "none", basename: "target")
|
||||||
let args = try ColprofArgs.build(config: config)
|
let args = try ColprofArgs.build(config: config)
|
||||||
#expect(!args.contains("-f"))
|
XCTAssertFalse(args.contains("-f"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Viewing conditions skip none")
|
func testViewingCondNoneSkipped() throws {
|
||||||
func viewingCondNoneSkipped() throws {
|
|
||||||
let config = ColprofConfig(
|
let config = ColprofConfig(
|
||||||
inputViewingCond: "none",
|
inputViewingCond: "none",
|
||||||
outputViewingCond: "mt",
|
outputViewingCond: "mt",
|
||||||
basename: "target"
|
basename: "target"
|
||||||
)
|
)
|
||||||
let args = try ColprofArgs.build(config: config)
|
let args = try ColprofArgs.build(config: config)
|
||||||
#expect(!args.contains("-c"))
|
XCTAssertFalse(args.contains("-c"))
|
||||||
#expect(args.contains("-d"))
|
XCTAssertTrue(args.contains("-d"))
|
||||||
#expect(args.contains("mt"))
|
XCTAssertTrue(args.contains("mt"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Description falls back to basename when empty")
|
func testDescriptionFallback() throws {
|
||||||
func descriptionFallback() throws {
|
|
||||||
let config = ColprofConfig(description: "", basename: "target")
|
let config = ColprofConfig(description: "", basename: "target")
|
||||||
let args = try ColprofArgs.build(config: config)
|
let args = try ColprofArgs.build(config: config)
|
||||||
#expect(!args.contains("-D"))
|
XCTAssertFalse(args.contains("-D"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Copyright only when non-empty")
|
func testCopyright() throws {
|
||||||
func copyright() throws {
|
|
||||||
let config = ColprofConfig(copyright: "Gronod 2026", basename: "target")
|
let config = ColprofConfig(copyright: "Gronod 2026", basename: "target")
|
||||||
let args = try ColprofArgs.build(config: config)
|
let args = try ColprofArgs.build(config: config)
|
||||||
#expect(args.contains("-C"))
|
XCTAssertTrue(args.contains("-C"))
|
||||||
#expect(args.contains("Gronod 2026"))
|
XCTAssertTrue(args.contains("Gronod 2026"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("No -u passed")
|
func testNoProgressJsonFlag() throws {
|
||||||
func noProgressJsonFlag() throws {
|
|
||||||
let config = ColprofConfig(basename: "target")
|
let config = ColprofConfig(basename: "target")
|
||||||
let args = try ColprofArgs.build(config: config)
|
let args = try ColprofArgs.build(config: config)
|
||||||
#expect(!args.contains("-u"))
|
XCTAssertFalse(args.contains("-u"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,20 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import XCTest
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
@Suite("ColprofProgress")
|
final class ColprofProgressTests: XCTestCase {
|
||||||
struct ColprofProgressTests {
|
|
||||||
|
|
||||||
@Test("Classifies gamut mapping")
|
func testGamutMapping() {
|
||||||
func gamutMapping() {
|
XCTAssertEqual(ColprofProgressClassifier.classify(line: "Gamut mapping calculation in progress"), .gamutMapping)
|
||||||
#expect(ColprofProgressClassifier.classify(line: "Gamut mapping calculation in progress") == .gamutMapping)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Classifies fitting or clut")
|
func testFitting() {
|
||||||
func fitting() {
|
XCTAssertEqual(ColprofProgressClassifier.classify(line: "Fitting cLUT grid points"), .fittingClut)
|
||||||
#expect(ColprofProgressClassifier.classify(line: "Fitting cLUT grid points") == .fittingClut)
|
XCTAssertEqual(ColprofProgressClassifier.classify(line: "clut table"), .fittingClut)
|
||||||
#expect(ColprofProgressClassifier.classify(line: "clut table") == .fittingClut)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Classifies writing")
|
func testWriting() {
|
||||||
func writing() {
|
XCTAssertEqual(ColprofProgressClassifier.classify(line: "Writing ICC profile header"), .writingIcc)
|
||||||
#expect(ColprofProgressClassifier.classify(line: "Writing ICC profile header") == .writingIcc)
|
XCTAssertEqual(ColprofProgressClassifier.classify(line: "icc profile written"), .writingIcc)
|
||||||
#expect(ColprofProgressClassifier.classify(line: "icc profile written") == .writingIcc)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import Testing
|
import XCTest
|
||||||
import Foundation
|
import Foundation
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
@testable import ICCery
|
@testable import ICCery
|
||||||
@@ -6,48 +6,54 @@ import AppKit
|
|||||||
import ApplicationServices
|
import ApplicationServices
|
||||||
|
|
||||||
/// Issue 14 — PMPrintSettingsToOptions capture filter (docs/11 layer ⑥).
|
/// Issue 14 — PMPrintSettingsToOptions capture filter (docs/11 layer ⑥).
|
||||||
@Suite("CupsOptionsFilter")
|
final class CupsOptionsFilterTests: XCTestCase {
|
||||||
struct CupsOptionsFilterTests {
|
|
||||||
|
|
||||||
@Test("Drops com.apple.*, collate, copies, job-sheets, AP_* keys")
|
func testDropsReserved() {
|
||||||
func dropsReserved() {
|
|
||||||
let raw = "AP_ColorMatchingMode=AP_ApplicationColorMatching "
|
let raw = "AP_ColorMatchingMode=AP_ApplicationColorMatching "
|
||||||
+ "AP.ColorMatchingMode=AP_ApplicationColorMatching "
|
+ "AP.ColorMatchingMode=AP_ApplicationColorMatching "
|
||||||
+ "com.apple.print.JobTicket.PMTotalSidesImaged=0 "
|
+ "com.apple.print.JobTicket.PMTotalSidesImaged=0 "
|
||||||
+ "collate=true copies=1 job-sheets=none,none "
|
+ "collate=true copies=1 job-sheets=none,none "
|
||||||
+ "pserrorhandler-requested=standard "
|
+ "pserrorhandler-requested=standard "
|
||||||
+ "MediaType=PhotographicGlossy"
|
+ "MediaType=PhotographicGlossy"
|
||||||
#expect(CupsOptionsFilter.filter(raw) == "MediaType=PhotographicGlossy")
|
XCTAssertEqual(CupsOptionsFilter.filter(raw), "MediaType=PhotographicGlossy")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Keeps relevant driver keys, order preserved")
|
func testKeepsRelevant() {
|
||||||
func keepsRelevant() {
|
|
||||||
let raw = "InputSlot=Rear PageSize=A4 CNIJIntent2=4 "
|
let raw = "InputSlot=Rear PageSize=A4 CNIJIntent2=4 "
|
||||||
+ "Resolution=600x600dpi Duplex=None"
|
+ "Resolution=600x600dpi Duplex=None"
|
||||||
#expect(CupsOptionsFilter.filter(raw) == raw)
|
XCTAssertEqual(CupsOptionsFilter.filter(raw), raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Permissive: unknown non-com.* keys survive")
|
/// #180 — a captured `EPIJ_Qual` (and the other canonical quality
|
||||||
func keepsUnknown() {
|
/// keys) survives the filter so it wins over the Stage 2 explicit
|
||||||
|
/// quality in `LpArgs`.
|
||||||
|
func testKeepsQualityKeys() {
|
||||||
|
let raw = "EPIJ_Qual=304 CNIJPrintQuality=3 PrintQuality=2 "
|
||||||
|
+ "cupsPrintQuality=High Quality=Best "
|
||||||
|
+ "com.apple.print.JobTicket.PMTotalSidesImaged=0"
|
||||||
|
XCTAssertEqual(CupsOptionsFilter.filter(raw),
|
||||||
|
"EPIJ_Qual=304 CNIJPrintQuality=3 PrintQuality=2 "
|
||||||
|
+ "cupsPrintQuality=High Quality=Best")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testKeepsUnknown() {
|
||||||
let raw = "VendorFooBar=baz MediaType=Plain"
|
let raw = "VendorFooBar=baz MediaType=Plain"
|
||||||
#expect(CupsOptionsFilter.filter(raw) == raw)
|
XCTAssertEqual(CupsOptionsFilter.filter(raw), raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Drops empty keys and values")
|
func testDropsEmpty() {
|
||||||
func dropsEmpty() {
|
|
||||||
let raw = "=noval MediaType= InputSlot=Rear"
|
let raw = "=noval MediaType= InputSlot=Rear"
|
||||||
// "MediaType=" has an empty value → dropped; "=noval" empty key.
|
// "MediaType=" has an empty value → dropped; "=noval" empty key.
|
||||||
#expect(CupsOptionsFilter.filter(raw) == "InputSlot=Rear")
|
XCTAssertEqual(CupsOptionsFilter.filter(raw), "InputSlot=Rear")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("extractMediaType prefers MediaType then EPIJ_Medi")
|
func testExtractMedia() {
|
||||||
func extractMedia() {
|
XCTAssertEqual(CupsParsers.extractMediaType(
|
||||||
#expect(CupsParsers.extractMediaType(
|
fromOptionsString: "MediaType=Photo EPIJ_Medi=1"), "Photo")
|
||||||
fromOptionsString: "MediaType=Photo EPIJ_Medi=1") == "Photo")
|
XCTAssertEqual(CupsParsers.extractMediaType(
|
||||||
#expect(CupsParsers.extractMediaType(
|
fromOptionsString: "EPIJ_Medi=7"), "7")
|
||||||
fromOptionsString: "EPIJ_Medi=7") == "7")
|
XCTAssertNil(CupsParsers.extractMediaType(
|
||||||
#expect(CupsParsers.extractMediaType(
|
fromOptionsString: "PageSize=A4"))
|
||||||
fromOptionsString: "PageSize=A4") == nil)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,9 +61,8 @@ struct CupsOptionsFilterTests {
|
|||||||
/// `@convention(c)` closures can't capture, so recording goes through
|
/// `@convention(c)` closures can't capture, so recording goes through
|
||||||
/// a file-scope recorder keyed by global state; no private symbols are
|
/// a file-scope recorder keyed by global state; no private symbols are
|
||||||
/// touched.
|
/// touched.
|
||||||
@Suite("ColorSyncSuppressor")
|
|
||||||
@MainActor
|
@MainActor
|
||||||
struct ColorSyncSuppressorTests {
|
final class ColorSyncSuppressorTests: XCTestCase {
|
||||||
|
|
||||||
/// Fake PMPrintSession — the injected resolver never dereferences it.
|
/// Fake PMPrintSession — the injected resolver never dereferences it.
|
||||||
private var fakeSession: PMPrintSession {
|
private var fakeSession: PMPrintSession {
|
||||||
@@ -78,10 +83,14 @@ struct ColorSyncSuppressorTests {
|
|||||||
s.modeResolver = { name in
|
s.modeResolver = { name in
|
||||||
if Self.missing.contains(name) { return nil }
|
if Self.missing.contains(name) { return nil }
|
||||||
Self.currentSymbol = name
|
Self.currentSymbol = name
|
||||||
|
// `Self` inside a @convention(c) closure is a dynamic-Self
|
||||||
|
// capture — spell the (final) class name instead.
|
||||||
return { _, modeArg in
|
return { _, modeArg in
|
||||||
Self.recorded.append((Self.currentSymbol, modeArg as String))
|
ColorSyncSuppressorTests.recorded.append(
|
||||||
if let ok = Self.succeeding,
|
(ColorSyncSuppressorTests.currentSymbol, modeArg as String))
|
||||||
Self.currentSymbol == ok.0, (modeArg as String) == ok.1 {
|
if let ok = ColorSyncSuppressorTests.succeeding,
|
||||||
|
ColorSyncSuppressorTests.currentSymbol == ok.0,
|
||||||
|
(modeArg as String) == ok.1 {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
return 1
|
return 1
|
||||||
@@ -90,55 +99,50 @@ struct ColorSyncSuppressorTests {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Attempt order: Lock → Mode → NoLock, AP_ prefix first")
|
func testAttemptOrder() {
|
||||||
func attemptOrder() {
|
|
||||||
Self.recorded = []
|
Self.recorded = []
|
||||||
Self.succeeding = nil
|
Self.succeeding = nil
|
||||||
Self.missing = ["PMSessionSetColorMatchingModeLock"]
|
Self.missing = ["PMSessionSetColorMatchingModeLock"]
|
||||||
let s = makeSuppressor()
|
let s = makeSuppressor()
|
||||||
#expect(s.applySPIMode(to: fakeSession) == false)
|
XCTAssertEqual(s.applySPIMode(to: fakeSession), false)
|
||||||
// Lock is unresolvable → skipped; the rest plays out in order.
|
// Lock is unresolvable → skipped; the rest plays out in order.
|
||||||
#expect(Self.recorded.map { "\($0.0)|\($0.1)" }
|
XCTAssertEqual(Self.recorded.map { "\($0.0)|\($0.1)" }, ColorMatchingAttempts.attempts
|
||||||
== ColorMatchingAttempts.attempts
|
|
||||||
.filter { $0.symbol != "PMSessionSetColorMatchingModeLock" }
|
.filter { $0.symbol != "PMSessionSetColorMatchingModeLock" }
|
||||||
.map { "\($0.symbol)|\($0.mode)" })
|
.map { "\($0.symbol)|\($0.mode)" })
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("First zero wins — later symbols/modes not called")
|
func testFirstZeroWins() {
|
||||||
func firstZeroWins() {
|
|
||||||
Self.recorded = []
|
Self.recorded = []
|
||||||
Self.succeeding = ("PMSessionSetColorMatchingModeLock",
|
Self.succeeding = ("PMSessionSetColorMatchingModeLock",
|
||||||
"AP_ApplicationColorMatching")
|
"AP_ApplicationColorMatching")
|
||||||
Self.missing = []
|
Self.missing = []
|
||||||
let s = makeSuppressor()
|
let s = makeSuppressor()
|
||||||
#expect(s.applySPIMode(to: fakeSession))
|
XCTAssertTrue(s.applySPIMode(to: fakeSession))
|
||||||
#expect(Self.recorded.map { "\($0.0)|\($0.1)" } == [
|
XCTAssertEqual(Self.recorded.map { "\($0.0)|\($0.1)" }, [
|
||||||
"PMSessionSetColorMatchingModeLock|AP_ApplicationColorMatching",
|
"PMSessionSetColorMatchingModeLock|AP_ApplicationColorMatching",
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Mode fallback: AP_ rejected → ApplicationColorMatching tried")
|
func testModeFallback() {
|
||||||
func modeFallback() {
|
|
||||||
Self.recorded = []
|
Self.recorded = []
|
||||||
Self.succeeding = ("PMSessionSetColorMatchingModeLock",
|
Self.succeeding = ("PMSessionSetColorMatchingModeLock",
|
||||||
"ApplicationColorMatching")
|
"ApplicationColorMatching")
|
||||||
Self.missing = []
|
Self.missing = []
|
||||||
let s = makeSuppressor()
|
let s = makeSuppressor()
|
||||||
#expect(s.applySPIMode(to: fakeSession))
|
XCTAssertTrue(s.applySPIMode(to: fakeSession))
|
||||||
#expect(Self.recorded[0].0 == "PMSessionSetColorMatchingModeLock")
|
XCTAssertEqual(Self.recorded[0].0, "PMSessionSetColorMatchingModeLock")
|
||||||
#expect(Self.recorded[0].1 == "AP_ApplicationColorMatching")
|
XCTAssertEqual(Self.recorded[0].1, "AP_ApplicationColorMatching")
|
||||||
#expect(Self.recorded[1].0 == "PMSessionSetColorMatchingModeLock")
|
XCTAssertEqual(Self.recorded[1].0, "PMSessionSetColorMatchingModeLock")
|
||||||
#expect(Self.recorded[1].1 == "ApplicationColorMatching")
|
XCTAssertEqual(Self.recorded[1].1, "ApplicationColorMatching")
|
||||||
#expect(Self.recorded.count == 2)
|
XCTAssertEqual(Self.recorded.count, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("All symbols missing → false, no calls")
|
func testAllMissing() {
|
||||||
func allMissing() {
|
|
||||||
Self.recorded = []
|
Self.recorded = []
|
||||||
Self.succeeding = nil
|
Self.succeeding = nil
|
||||||
Self.missing = Set(ColorMatchingAttempts.symbols)
|
Self.missing = Set(ColorMatchingAttempts.symbols)
|
||||||
let s = makeSuppressor()
|
let s = makeSuppressor()
|
||||||
#expect(s.applySPIMode(to: fakeSession) == false)
|
XCTAssertEqual(s.applySPIMode(to: fakeSession), false)
|
||||||
#expect(Self.recorded.isEmpty)
|
XCTAssertTrue(Self.recorded.isEmpty)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import Testing
|
import XCTest
|
||||||
import Foundation
|
import Foundation
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
/// Issue 12 — CUPS enumeration parsers on recorded fixtures
|
/// Issue 12 — CUPS enumeration parsers on recorded fixtures
|
||||||
/// (docs/10–11). No live `lpstat`/`lpoptions` is spawned here.
|
/// (docs/10–11). No live `lpstat`/`lpoptions` is spawned here.
|
||||||
@Suite("CupsParsers")
|
final class CupsParserTests: XCTestCase {
|
||||||
struct CupsParsersTests {
|
|
||||||
|
|
||||||
// Recorded on an Epson XP-55 + Canon Pro9500 host.
|
// Recorded on an Epson XP-55 + Canon Pro9500 host.
|
||||||
private let lpstatE = """
|
private let lpstatE = """
|
||||||
@@ -31,115 +30,429 @@ struct CupsParsersTests {
|
|||||||
MediaType/Media Type: *Stationery PhotographicHighGloss Photographic PhotographicMatte Envelope
|
MediaType/Media Type: *Stationery PhotographicHighGloss Photographic PhotographicMatte Envelope
|
||||||
ColorModel/Output Mode: *RGB Gray
|
ColorModel/Output Mode: *RGB Gray
|
||||||
Duplex/Duplex: *None DuplexNoTumble DuplexTumble
|
Duplex/Duplex: *None DuplexNoTumble DuplexTumble
|
||||||
cupsPrintQuality/cupsPrintQuality: Draft *Normal High
|
EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@Test("lpstat -e: one destination per line; empty = success")
|
func testDestinations() {
|
||||||
func destinations() {
|
XCTAssertEqual(CupsParsers.lpstatDestinations(lpstatE), [
|
||||||
#expect(CupsParsers.lpstatDestinations(lpstatE) == [
|
|
||||||
"Canon_Pro9500_II_series_XPS",
|
"Canon_Pro9500_II_series_XPS",
|
||||||
"Epson_XP_55_LPD",
|
"Epson_XP_55_LPD",
|
||||||
"EPSON_XP_55_Series",
|
"EPSON_XP_55_Series",
|
||||||
])
|
])
|
||||||
#expect(CupsParsers.lpstatDestinations("") == [])
|
XCTAssertEqual(CupsParsers.lpstatDestinations(""), [])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("lpstat -p: idle / now-printing / disabled statuses")
|
func testStatuses() {
|
||||||
func statuses() {
|
|
||||||
let s = CupsParsers.lpstatStatuses(lpstatP)
|
let s = CupsParsers.lpstatStatuses(lpstatP)
|
||||||
#expect(s["Canon_Pro9500_II_series_XPS"] == .idle)
|
XCTAssertEqual(s["Canon_Pro9500_II_series_XPS"], .idle)
|
||||||
#expect(s["Epson_XP_55_LPD"] == .printing)
|
XCTAssertEqual(s["Epson_XP_55_LPD"], .printing)
|
||||||
#expect(s["EPSON_XP_55_Series"] == .stopped)
|
XCTAssertEqual(s["EPSON_XP_55_Series"], .stopped)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("lpstat -d: default destination or none")
|
func testDefaultDestination() {
|
||||||
func defaultDestination() {
|
XCTAssertEqual(CupsParsers.lpstatDefault(
|
||||||
#expect(CupsParsers.lpstatDefault(
|
"system default destination: Canon_Pro9500_II_series_XPS\n"), "Canon_Pro9500_II_series_XPS")
|
||||||
"system default destination: Canon_Pro9500_II_series_XPS\n")
|
XCTAssertNil(CupsParsers.lpstatDefault("no system default destination\n"))
|
||||||
== "Canon_Pro9500_II_series_XPS")
|
|
||||||
#expect(CupsParsers.lpstatDefault("no system default destination\n") == nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("lpoptions -p: quoted printer-info, bare flags ignored")
|
func testDisplayName() {
|
||||||
func displayName() {
|
XCTAssertEqual(CupsParsers.lpoptionsDisplayName(lpoptionsP), "EPSON XP-55 Series")
|
||||||
#expect(CupsParsers.lpoptionsDisplayName(lpoptionsP) == "EPSON XP-55 Series")
|
XCTAssertNil(CupsParsers.lpoptionsDisplayName("printer-type=42\n"))
|
||||||
#expect(CupsParsers.lpoptionsDisplayName("printer-type=42\n") == nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("lpoptions -l: key/label split, * marks the default")
|
func testOptionListings() {
|
||||||
func optionListings() {
|
|
||||||
let listings = CupsParsers.lpoptionsList(lpoptionsL)
|
let listings = CupsParsers.lpoptionsList(lpoptionsL)
|
||||||
#expect(listings.count == 6)
|
XCTAssertEqual(listings.count, 6)
|
||||||
|
|
||||||
let page = listings[0]
|
let page = listings[0]
|
||||||
#expect(page.key == "PageSize")
|
XCTAssertEqual(page.key, "PageSize")
|
||||||
#expect(page.label == "Media Size")
|
XCTAssertEqual(page.label, "Media Size")
|
||||||
#expect(page.defaultChoice == "A4")
|
XCTAssertEqual(page.defaultChoice, "A4")
|
||||||
#expect(page.choices.contains("Custom.WIDTHxHEIGHT"))
|
XCTAssertTrue(page.choices.contains("Custom.WIDTHxHEIGHT"))
|
||||||
#expect(!page.choices.contains("*A4"))
|
XCTAssertFalse(page.choices.contains("*A4"))
|
||||||
|
|
||||||
let slot = listings[1]
|
let slot = listings[1]
|
||||||
#expect(slot.key == "InputSlot")
|
XCTAssertEqual(slot.key, "InputSlot")
|
||||||
#expect(slot.choices == ["Auto", "Main", "Photo", "Rear"])
|
XCTAssertEqual(slot.choices, ["Auto", "Main", "Photo", "Rear"])
|
||||||
#expect(slot.defaultChoice == "Main")
|
XCTAssertEqual(slot.defaultChoice, "Main")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("capabilities: trays/sizes index 1-based, media uses detected key")
|
func testCapabilities() {
|
||||||
func capabilities() {
|
|
||||||
let service = CupsService()
|
let service = CupsService()
|
||||||
let listings = CupsParsers.lpoptionsList(lpoptionsL)
|
let listings = CupsParsers.lpoptionsList(lpoptionsL)
|
||||||
let caps = service.capabilities(from: listings, ppd: nil)
|
let caps = service.capabilities(from: listings, ppd: nil)
|
||||||
|
|
||||||
#expect(caps.trays == [
|
XCTAssertEqual(caps.trays, [
|
||||||
PrinterTray(id: 1, name: "Auto"),
|
PrinterTray(id: 1, name: "Auto"),
|
||||||
PrinterTray(id: 2, name: "Main"),
|
PrinterTray(id: 2, name: "Main"),
|
||||||
PrinterTray(id: 3, name: "Photo"),
|
PrinterTray(id: 3, name: "Photo"),
|
||||||
PrinterTray(id: 4, name: "Rear"),
|
PrinterTray(id: 4, name: "Rear"),
|
||||||
])
|
])
|
||||||
#expect(caps.paperSizes.first == PrinterPaperSize(id: 1, name: "3.5x5"))
|
XCTAssertEqual(caps.paperSizes.first, PrinterPaperSize(id: 1, name: "3.5x5"))
|
||||||
#expect(caps.paperSizes.count == 10)
|
XCTAssertEqual(caps.paperSizes.count, 10)
|
||||||
#expect(caps.mediaTypes.map(\.id) == [
|
XCTAssertEqual(caps.mediaTypes.map(\.id), [
|
||||||
"Stationery", "PhotographicHighGloss", "Photographic",
|
"Stationery", "PhotographicHighGloss", "Photographic",
|
||||||
"PhotographicMatte", "Envelope",
|
"PhotographicMatte", "Envelope",
|
||||||
])
|
])
|
||||||
#expect(caps.supportsOrientation)
|
XCTAssertTrue(caps.supportsOrientation)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("PPD enrichment maps id → human label")
|
func testPpdLabels() {
|
||||||
func ppdLabels() {
|
|
||||||
let ppd = """
|
let ppd = """
|
||||||
*CNIJMediaType 42/Photo Paper Plus Semi-gloss: "<</MediaType(42)>>"
|
*CNIJMediaType 42/Photo Paper Plus Semi-gloss: "<</MediaType(42)>>"
|
||||||
*CNIJMediaType 0/Plain Paper: ""
|
*CNIJMediaType 0/Plain Paper: ""
|
||||||
*en_US.CNIJMediaType 13/Envelope: ""
|
*en_US.CNIJMediaType 13/Envelope: ""
|
||||||
"""
|
"""
|
||||||
let labels = CupsParsers.ppdChoiceLabels(ppd, key: "CNIJMediaType")
|
let labels = CupsParsers.ppdChoiceLabels(ppd, key: "CNIJMediaType")
|
||||||
#expect(labels["42"] == "Photo Paper Plus Semi-gloss")
|
XCTAssertEqual(labels["42"], "Photo Paper Plus Semi-gloss")
|
||||||
#expect(labels["0"] == "Plain Paper")
|
XCTAssertEqual(labels["0"], "Plain Paper")
|
||||||
#expect(labels["13"] == "Envelope")
|
XCTAssertEqual(labels["13"], "Envelope")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("detectMediaTypeKey prefers vendor keys in order")
|
func testMediaTypeKey() {
|
||||||
func mediaTypeKey() {
|
XCTAssertEqual(CupsParsers.detectMediaTypeKey(
|
||||||
#expect(CupsParsers.detectMediaTypeKey(
|
optionKeys: ["MediaType", "CNIJMediaType"]), "CNIJMediaType")
|
||||||
optionKeys: ["MediaType", "CNIJMediaType"]) == "CNIJMediaType")
|
XCTAssertEqual(CupsParsers.detectMediaTypeKey(
|
||||||
#expect(CupsParsers.detectMediaTypeKey(
|
optionKeys: ["PageSize", "MediaType"]), "MediaType")
|
||||||
optionKeys: ["PageSize", "MediaType"]) == "MediaType")
|
XCTAssertNil(CupsParsers.detectMediaTypeKey(optionKeys: ["PageSize"]))
|
||||||
#expect(CupsParsers.detectMediaTypeKey(optionKeys: ["PageSize"]) == nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Driver bypass: Canon Intent2 > Intent; Epson CCor > CMat")
|
// MARK: - #183 quality key + option extraction
|
||||||
func driverBypass() {
|
|
||||||
|
func testQualityKeyRosterOrder() {
|
||||||
|
// Vendor keys beat the generic ones; OutputMode/Resolution sit
|
||||||
|
// last (they are colour-ish keys on some drivers — #183/#180).
|
||||||
|
XCTAssertEqual(CupsParsers.detectQualityKey(
|
||||||
|
optionKeys: ["EPIJ_Qual", "Quality", "OutputMode"]), "EPIJ_Qual")
|
||||||
|
// A full Epson key set — EPIJ_Qual wins over the colour-mode
|
||||||
|
// key, the generic keys, and Resolution (#180, R11).
|
||||||
|
XCTAssertEqual(CupsParsers.detectQualityKey(
|
||||||
|
optionKeys: ["EPIJ_Qual", "OutputMode", "Resolution",
|
||||||
|
"cupsPrintQuality", "PrintQuality",
|
||||||
|
"ColorModel"]), "EPIJ_Qual")
|
||||||
|
XCTAssertEqual(CupsParsers.detectQualityKey(
|
||||||
|
optionKeys: ["Quality", "OutputMode", "Resolution"]), "Quality")
|
||||||
|
XCTAssertEqual(CupsParsers.detectQualityKey(
|
||||||
|
optionKeys: ["cupsPrintQuality", "CNIJQuality"]),
|
||||||
|
"CNIJQuality")
|
||||||
|
XCTAssertEqual(CupsParsers.detectQualityKey(
|
||||||
|
optionKeys: ["OutputMode", "Resolution"]), "OutputMode")
|
||||||
|
XCTAssertEqual(CupsParsers.detectQualityKey(
|
||||||
|
optionKeys: ["Resolution"]), "Resolution")
|
||||||
|
XCTAssertNil(CupsParsers.detectQualityKey(optionKeys: ["PageSize"]))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCapabilitiesQuality() {
|
||||||
|
let service = CupsService()
|
||||||
|
let listings = CupsParsers.lpoptionsList(lpoptionsL)
|
||||||
|
let caps = service.capabilities(from: listings, ppd: nil)
|
||||||
|
|
||||||
|
// EPIJ_Qual is the roster member — all seven Epson codes
|
||||||
|
// enumerate in the driver's own (non-sorted) order (#180).
|
||||||
|
XCTAssertEqual(caps.qualityKey, "EPIJ_Qual")
|
||||||
|
XCTAssertEqual(caps.qualities.map(\.id),
|
||||||
|
["301", "302", "303", "308", "304", "305", "307"])
|
||||||
|
XCTAssertEqual(caps.qualityDefault, "303")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCapabilitiesQualityPpdLabels() {
|
||||||
|
// Epson XP-55 PPD fragment — the seven `*EPIJ_Qual id/Label`
|
||||||
|
// lines in the driver's own order (#180).
|
||||||
|
let ppd = """
|
||||||
|
*OpenUI *EPIJ_Qual/Print Quality: PickOne
|
||||||
|
*DefaultEPIJ_Qual: 303
|
||||||
|
*EPIJ_Qual 301/Fast Economy: ""
|
||||||
|
*EPIJ_Qual 302/Economy: ""
|
||||||
|
*EPIJ_Qual 303/Normal: ""
|
||||||
|
*EPIJ_Qual 308/Draft: ""
|
||||||
|
*EPIJ_Qual 304/Fine: ""
|
||||||
|
*EPIJ_Qual 305/Quality: ""
|
||||||
|
*EPIJ_Qual 307/Best Quality: ""
|
||||||
|
*CloseUI: *EPIJ_Qual
|
||||||
|
"""
|
||||||
|
let service = CupsService()
|
||||||
|
let listings = CupsParsers.lpoptionsList(
|
||||||
|
"EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307\n")
|
||||||
|
let caps = service.capabilities(from: listings, ppd: ppd)
|
||||||
|
|
||||||
|
XCTAssertEqual(caps.qualityKey, "EPIJ_Qual")
|
||||||
|
XCTAssertEqual(caps.qualities, [
|
||||||
|
PrinterQuality(id: "301", name: "Fast Economy"),
|
||||||
|
PrinterQuality(id: "302", name: "Economy"),
|
||||||
|
PrinterQuality(id: "303", name: "Normal"),
|
||||||
|
PrinterQuality(id: "308", name: "Draft"),
|
||||||
|
PrinterQuality(id: "304", name: "Fine"),
|
||||||
|
PrinterQuality(id: "305", name: "Quality"),
|
||||||
|
PrinterQuality(id: "307", name: "Best Quality"),
|
||||||
|
])
|
||||||
|
XCTAssertEqual(caps.qualityDefault, "303")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #180 — the Epson listing also carries `OutputMode` (a colour
|
||||||
|
/// mode) and `Resolution`; detection must still pick `EPIJ_Qual`.
|
||||||
|
func testCapabilitiesQualityEpsonDetection() {
|
||||||
|
let service = CupsService()
|
||||||
|
let listings = CupsParsers.lpoptionsList("""
|
||||||
|
PageSize/Media Size: *A4 Letter
|
||||||
|
EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307
|
||||||
|
OutputMode/Color Mode: *Color Mono
|
||||||
|
Resolution/Resolution: *360dpi 720dpi
|
||||||
|
""")
|
||||||
|
let caps = service.capabilities(from: listings, ppd: nil)
|
||||||
|
|
||||||
|
XCTAssertEqual(caps.qualityKey, "EPIJ_Qual")
|
||||||
|
XCTAssertEqual(caps.qualities.count, 7)
|
||||||
|
XCTAssertEqual(caps.qualityDefault, "303")
|
||||||
|
XCTAssertFalse(caps.qualities.contains { $0.id == "Color" })
|
||||||
|
}
|
||||||
|
|
||||||
|
func testExtractOption() {
|
||||||
|
let options = "PageSize=A4 EPIJ_Qual=303 printer-info='EPSON XP-55'"
|
||||||
|
XCTAssertEqual(CupsParsers.extractOption(
|
||||||
|
named: "PageSize", fromOptionsString: options), "A4")
|
||||||
|
// Case-insensitive key match.
|
||||||
|
XCTAssertEqual(CupsParsers.extractOption(
|
||||||
|
named: "epij_qual", fromOptionsString: options), "303")
|
||||||
|
// Quoted values come back unquoted.
|
||||||
|
XCTAssertEqual(CupsParsers.extractOption(
|
||||||
|
named: "printer-info", fromOptionsString: options), "EPSON XP-55")
|
||||||
|
XCTAssertNil(CupsParsers.extractOption(
|
||||||
|
named: "InputSlot", fromOptionsString: options))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testExtractQualityAndOrientation() {
|
||||||
|
let options = "orientation-requested=4 OutputMode=Gray EPIJ_Qual=305"
|
||||||
|
XCTAssertEqual(CupsParsers.extractQuality(
|
||||||
|
fromOptionsString: options), "305")
|
||||||
|
XCTAssertEqual(CupsParsers.extractOrientation(
|
||||||
|
fromOptionsString: options), "landscape")
|
||||||
|
XCTAssertNil(CupsParsers.extractQuality(
|
||||||
|
fromOptionsString: "PageSize=A4"))
|
||||||
|
XCTAssertNil(CupsParsers.extractOrientation(
|
||||||
|
fromOptionsString: "PageSize=A4"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - #186 capture-return
|
||||||
|
|
||||||
|
/// A captured `k=v` string maps to all four `PrintOptions`
|
||||||
|
/// fields — `PageSize`, the detected quality key,
|
||||||
|
/// `orientation-requested`, and a vendor media key (#186).
|
||||||
|
func testCapturedStringMapsAllFields() {
|
||||||
|
let captured =
|
||||||
|
"PageSize=A4 EPIJ_Qual=305 orientation-requested=4 CNIJMediaType=Photo"
|
||||||
|
XCTAssertEqual(CupsParsers.extractOption(
|
||||||
|
named: "PageSize", fromOptionsString: captured), "A4")
|
||||||
|
XCTAssertEqual(CupsParsers.extractQuality(
|
||||||
|
fromOptionsString: captured), "305")
|
||||||
|
XCTAssertEqual(CupsParsers.extractOrientation(
|
||||||
|
fromOptionsString: captured), "landscape")
|
||||||
|
XCTAssertEqual(CupsParsers.extractMediaType(
|
||||||
|
fromOptionsString: captured), "Photo")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vendor media keys beyond `MediaType`/`EPIJ_Medi` extract via
|
||||||
|
/// the detection roster — `CNIJMediaType`/`StpMediaType` included
|
||||||
|
/// (#186). `MediaType` still wins when present alongside them.
|
||||||
|
func testExtractMediaTypeRosterFallback() {
|
||||||
|
XCTAssertEqual(CupsParsers.extractMediaType(
|
||||||
|
fromOptionsString: "CNIJMediaType=PhotoPlus"), "PhotoPlus")
|
||||||
|
XCTAssertEqual(CupsParsers.extractMediaType(
|
||||||
|
fromOptionsString: "StpMediaType=Glossy"), "Glossy")
|
||||||
|
XCTAssertEqual(CupsParsers.extractMediaType(
|
||||||
|
fromOptionsString: "EPIJ_Medi=Photo"), "Photo")
|
||||||
|
// `MediaType` keeps first precedence (docs/11 §tests).
|
||||||
|
XCTAssertEqual(CupsParsers.extractMediaType(
|
||||||
|
fromOptionsString: "CNIJMediaType=PhotoPlus MediaType=Plain"),
|
||||||
|
"Plain")
|
||||||
|
XCTAssertNil(CupsParsers.extractMediaType(
|
||||||
|
fromOptionsString: "PageSize=A4"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDriverBypass() {
|
||||||
func pair(_ keys: Set<String>) -> String? {
|
func pair(_ keys: Set<String>) -> String? {
|
||||||
CupsParsers.detectDriverColorBypass(optionKeys: keys)
|
CupsParsers.detectDriverColorBypass(optionKeys: keys)
|
||||||
.map { "\($0.key)=\($0.value)" }
|
.map { "\($0.key)=\($0.value)" }
|
||||||
}
|
}
|
||||||
#expect(pair(["CNIJIntent2", "CNIJIntent"]) == "CNIJIntent2=4")
|
XCTAssertEqual(pair(["CNIJIntent2", "CNIJIntent"]), "CNIJIntent2=4")
|
||||||
#expect(pair(["CNIJIntent"]) == "CNIJIntent=4")
|
XCTAssertEqual(pair(["CNIJIntent"]), "CNIJIntent=4")
|
||||||
#expect(pair(["EPIJ_CCor", "EPIJ_CMat"]) == "EPIJ_CCor=0")
|
XCTAssertEqual(pair(["EPIJ_CCor", "EPIJ_CMat"]), "EPIJ_CCor=0")
|
||||||
#expect(pair(["EPIJ_CMat"]) == "EPIJ_CMat=3")
|
XCTAssertEqual(pair(["EPIJ_CMat"]), "EPIJ_CMat=3")
|
||||||
#expect(pair(["StpColorCorrection"]) == "StpColorCorrection=Uncorrected")
|
XCTAssertEqual(pair(["StpColorCorrection"]), "StpColorCorrection=Uncorrected")
|
||||||
#expect(pair(["ColorCorrection"]) == "ColorCorrection=Uncorrected")
|
XCTAssertEqual(pair(["ColorCorrection"]), "ColorCorrection=Uncorrected")
|
||||||
#expect(pair(["EpsonColorMode"]) == "EpsonColorMode=Off")
|
XCTAssertEqual(pair(["EpsonColorMode"]), "EpsonColorMode=Off")
|
||||||
#expect(pair(["PageSize"]) == nil)
|
XCTAssertNil(pair(["PageSize"]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - #181 Canon media locale precedence + PPD encoding
|
||||||
|
|
||||||
|
/// The 18 Canon Pro9500 media types named in the issue — the ids
|
||||||
|
/// are the numeric codes the driver enumerates via `lpoptions -l`.
|
||||||
|
private let canonMedia: [(id: String, label: String)] = [
|
||||||
|
("0", "Plain Paper"),
|
||||||
|
("1", "Photo Paper Plus Glossy II"),
|
||||||
|
("2", "Photo Paper Pro Platinum N"),
|
||||||
|
("3", "Photo Paper Pro Platinum"),
|
||||||
|
("4", "Photo Paper Pro Luster"),
|
||||||
|
("5", "Photo Paper Plus Semi-gloss"),
|
||||||
|
("6", "Matte Photo Paper"),
|
||||||
|
("7", "Fine Art \"Photo Rag\""),
|
||||||
|
("8", "Fine Art \"Museum Etching\""),
|
||||||
|
("9", "Photo Paper Pro Premium Matte"),
|
||||||
|
("10", "Fine Art Premium Matte"),
|
||||||
|
("11", "Other Fine Art Paper"),
|
||||||
|
("12", "Canvas"),
|
||||||
|
("13", "Board Paper"),
|
||||||
|
("14", "Ink Jet Hagaki"),
|
||||||
|
("15", "Hagaki"),
|
||||||
|
("16", "Printable disc"),
|
||||||
|
("17", "Printable disc (bleed-proof)"),
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Canon Pro9500-shaped fragment: the unqualified base block comes
|
||||||
|
/// early and the `th.` block trails at the end — the ordering that
|
||||||
|
/// let Thai overwrite English under last-write-wins (#181).
|
||||||
|
private var canonPPD: String {
|
||||||
|
var lines = [
|
||||||
|
"*OpenUI *CNIJMediaType/Media Type: PickOne",
|
||||||
|
"*DefaultCNIJMediaType: 0",
|
||||||
|
]
|
||||||
|
for media in canonMedia {
|
||||||
|
lines.append(
|
||||||
|
"*CNIJMediaType \(media.id)/\(media.label): \"\"")
|
||||||
|
}
|
||||||
|
lines.append("*CloseUI: *CNIJMediaType")
|
||||||
|
for media in canonMedia {
|
||||||
|
lines.append(
|
||||||
|
"*th.CNIJMediaType \(media.id)/กระดาษ\(media.id): \"\"")
|
||||||
|
}
|
||||||
|
return lines.joined(separator: "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPpdLabelsUnqualifiedSurvivesTrailingThai() {
|
||||||
|
let labels = CupsParsers.ppdChoiceLabels(
|
||||||
|
canonPPD, key: "CNIJMediaType")
|
||||||
|
XCTAssertEqual(labels["0"], "Plain Paper")
|
||||||
|
XCTAssertEqual(labels["17"], "Printable disc (bleed-proof)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPpdLabelsUnqualifiedWinsRegardlessOfOrder() {
|
||||||
|
// `th.` block first — precedence is deterministic, not
|
||||||
|
// positional (#181, E3).
|
||||||
|
let ppd = """
|
||||||
|
*th.CNIJMediaType 0/กระดาษธรรมดา: ""
|
||||||
|
*CNIJMediaType 0/Plain Paper: ""
|
||||||
|
"""
|
||||||
|
let labels = CupsParsers.ppdChoiceLabels(ppd, key: "CNIJMediaType")
|
||||||
|
XCTAssertEqual(labels["0"], "Plain Paper")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPpdLabelsQualifiedFallbackOrder() {
|
||||||
|
// en_US > en > first-qualified-seen (#181, E3).
|
||||||
|
let ppd = """
|
||||||
|
*en.CNIJMediaType 1/English Label: ""
|
||||||
|
*en_US.CNIJMediaType 1/US English Label: ""
|
||||||
|
*th.CNIJMediaType 1/กระดาษ: ""
|
||||||
|
*fr.CNIJMediaType 2/Français: ""
|
||||||
|
*de.CNIJMediaType 2/Deutsch: ""
|
||||||
|
"""
|
||||||
|
let labels = CupsParsers.ppdChoiceLabels(ppd, key: "CNIJMediaType")
|
||||||
|
XCTAssertEqual(labels["1"], "US English Label")
|
||||||
|
// A qualified-only id still gets its first-seen qualified
|
||||||
|
// label — never left unlabeled (R9).
|
||||||
|
XCTAssertEqual(labels["2"], "Français")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPpdLabelsHexEscapeDecoding() {
|
||||||
|
let ppd = """
|
||||||
|
*CNIJMediaType 3/Photo Paper Plus Glossy<2F>Matte: ""
|
||||||
|
*CNIJMediaType 4/Plain<20>Paper: ""
|
||||||
|
*CNIJMediaType 5/Bad<ZZ>Escape: ""
|
||||||
|
"""
|
||||||
|
let labels = CupsParsers.ppdChoiceLabels(ppd, key: "CNIJMediaType")
|
||||||
|
XCTAssertEqual(labels["3"], "Photo Paper Plus Glossy/Matte")
|
||||||
|
XCTAssertEqual(labels["4"], "Plain Paper")
|
||||||
|
XCTAssertEqual(labels["5"], "Bad<ZZ>Escape")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every `CNIJMediaType` choice enumerated by `lpoptions -l` gets a
|
||||||
|
/// non-Thai label (E1 — the true count is the hardware gate's, so
|
||||||
|
/// no count is hardcoded here); the 18 named AC labels are
|
||||||
|
/// spot-checked.
|
||||||
|
func testCapabilitiesCanonMediaAllNonThai() {
|
||||||
|
var choices = canonMedia.map(\.id)
|
||||||
|
choices[0] = "*\(choices[0])"
|
||||||
|
let listings = CupsParsers.lpoptionsList(
|
||||||
|
"CNIJMediaType/Media Type: \(choices.joined(separator: " "))\n")
|
||||||
|
let caps = CupsService().capabilities(from: listings, ppd: canonPPD)
|
||||||
|
|
||||||
|
XCTAssertEqual(caps.mediaTypes.count, canonMedia.count)
|
||||||
|
for type in caps.mediaTypes {
|
||||||
|
XCTAssertFalse(type.name.unicodeScalars.contains {
|
||||||
|
(0x0E00...0x0E7F).contains($0.value)
|
||||||
|
}, "Thai label leaked into \(type.id): \(type.name)")
|
||||||
|
}
|
||||||
|
for media in canonMedia {
|
||||||
|
XCTAssertEqual(
|
||||||
|
caps.mediaTypes.first { $0.id == media.id }?.name,
|
||||||
|
media.label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// UTF-8 PPD carrying Thai labels decodes intact — the English
|
||||||
|
/// base block wins precedence and no mojibake leaks through (#181,
|
||||||
|
/// R10). Exercises `loadPPD` through `capabilities(for:)`.
|
||||||
|
func testLoadPPDUtf8ThaiSurvivesDecode() async throws {
|
||||||
|
let (service, root) = try makeCupsService(
|
||||||
|
ppdData: Data(canonPPD.utf8),
|
||||||
|
listing: "CNIJMediaType/Media Type: *0 1")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
|
||||||
|
let caps = try await service.capabilities(for: "Canon_Test")
|
||||||
|
XCTAssertEqual(caps.mediaTypes.map(\.name),
|
||||||
|
["Plain Paper", "Photo Paper Plus Glossy II"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A PPD that is not valid UTF-8 (lone `0xE9` for `é`) falls back
|
||||||
|
/// to ISO-Latin-1 instead of yielding nil → raw ids (#181, R10).
|
||||||
|
func testLoadPPDLatin1Fallback() async throws {
|
||||||
|
let ppd = "*CNIJMediaType 0/Papier Couché: \"\"\n"
|
||||||
|
let (service, root) = try makeCupsService(
|
||||||
|
ppdData: ppd.data(using: .isoLatin1)!,
|
||||||
|
listing: "CNIJMediaType/Media Type: *0")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
|
||||||
|
let caps = try await service.capabilities(for: "Canon_Test")
|
||||||
|
XCTAssertEqual(caps.mediaTypes,
|
||||||
|
[PrinterMediaType(id: "0", name: "Papier Couché")])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fixture `lpoptions` + `ppdDir` so `capabilities(for:)` reaches
|
||||||
|
/// the private `loadPPD` — same mock style as
|
||||||
|
/// `MediaLibraryViewModelTests.installMockCups`.
|
||||||
|
private func makeCupsService(
|
||||||
|
ppdData: Data,
|
||||||
|
listing: String,
|
||||||
|
queue: String = "Canon_Test"
|
||||||
|
) throws -> (CupsService, URL) {
|
||||||
|
let root = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("iccery-ppd-\(UUID().uuidString)")
|
||||||
|
let bin = root.appendingPathComponent("bin")
|
||||||
|
let ppdDir = root.appendingPathComponent("ppd")
|
||||||
|
for dir in [bin, ppdDir] {
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: dir, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
let lpoptions = """
|
||||||
|
#!/bin/sh
|
||||||
|
printf '%s\\n' '\(listing)'
|
||||||
|
"""
|
||||||
|
let scriptURL = bin.appendingPathComponent("lpoptions")
|
||||||
|
try lpoptions.write(to: scriptURL, atomically: true, encoding: .utf8)
|
||||||
|
try FileManager.default.setAttributes(
|
||||||
|
[.posixPermissions: 0o755], ofItemAtPath: scriptURL.path)
|
||||||
|
try ppdData.write(to: ppdDir.appendingPathComponent("\(queue).ppd"))
|
||||||
|
return (CupsService(
|
||||||
|
processManager: ProcessManager(),
|
||||||
|
binaryDir: bin, ppdDir: ppdDir), root)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,65 +1,57 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import XCTest
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
@Suite("DriftAlert")
|
final class DriftAlertTests: XCTestCase {
|
||||||
struct DriftAlertTests {
|
|
||||||
|
|
||||||
@Test("No alert with fewer than two poor results")
|
func testNotEnough() {
|
||||||
func notEnough() {
|
|
||||||
let records = [
|
let records = [
|
||||||
record(avg: 4.0, at: 1000)
|
record(avg: 4.0, at: 1000)
|
||||||
]
|
]
|
||||||
#expect(DriftAlert.compute(from: records) == nil)
|
XCTAssertNil(DriftAlert.compute(from: records))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Alert on two poor results one hour apart")
|
func testOneHourApart() {
|
||||||
func oneHourApart() {
|
|
||||||
let records = [
|
let records = [
|
||||||
record(avg: 4.0, at: 1000),
|
record(avg: 4.0, at: 1000),
|
||||||
record(avg: 5.0, at: 4600)
|
record(avg: 5.0, at: 4600)
|
||||||
]
|
]
|
||||||
#expect(DriftAlert.compute(from: records) != nil)
|
XCTAssertNotNil(DriftAlert.compute(from: records))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("No alert if same day and under one hour")
|
func testSameDayUnderHour() {
|
||||||
func sameDayUnderHour() {
|
|
||||||
let records = [
|
let records = [
|
||||||
record(avg: 4.0, at: 1000),
|
record(avg: 4.0, at: 1000),
|
||||||
record(avg: 5.0, at: 2000)
|
record(avg: 5.0, at: 2000)
|
||||||
]
|
]
|
||||||
#expect(DriftAlert.compute(from: records) == nil)
|
XCTAssertNil(DriftAlert.compute(from: records))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Alert on distinct days")
|
func testDistinctDays() {
|
||||||
func distinctDays() {
|
|
||||||
let day1 = record(avg: 4.0, at: 0)
|
let day1 = record(avg: 4.0, at: 0)
|
||||||
let day2 = record(avg: 5.0, at: 86400 + 1000)
|
let day2 = record(avg: 5.0, at: 86400 + 1000)
|
||||||
#expect(DriftAlert.compute(from: [day1, day2]) != nil)
|
XCTAssertNotNil(DriftAlert.compute(from: [day1, day2]))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Non-poor records do not trigger")
|
func testNonPoor() {
|
||||||
func nonPoor() {
|
|
||||||
let records = [
|
let records = [
|
||||||
record(avg: 1.0, at: 0),
|
record(avg: 1.0, at: 0),
|
||||||
record(avg: 1.5, at: 86400)
|
record(avg: 1.5, at: 86400)
|
||||||
]
|
]
|
||||||
#expect(DriftAlert.compute(from: records) == nil)
|
XCTAssertNil(DriftAlert.compute(from: records))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Non-poor records break the consecutive poor run")
|
func testNonPoorBreaksRun() {
|
||||||
func nonPoorBreaksRun() {
|
|
||||||
let records = [
|
let records = [
|
||||||
record(avg: 4.0, at: 0), // poor
|
record(avg: 4.0, at: 0), // poor
|
||||||
record(avg: 4.5, at: 86400), // poor, far apart
|
record(avg: 4.5, at: 86400), // poor, far apart
|
||||||
record(avg: 1.0, at: 90000), // good — breaks the run
|
record(avg: 1.0, at: 90000), // good — breaks the run
|
||||||
record(avg: 4.0, at: 92000) // poor, recent but close to previous poor
|
record(avg: 4.0, at: 92000) // poor, recent but close to previous poor
|
||||||
]
|
]
|
||||||
#expect(DriftAlert.compute(from: records) == nil)
|
XCTAssertNil(DriftAlert.compute(from: records))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Only the final consecutive poor run is considered")
|
func testOnlySuffixRun() {
|
||||||
func onlySuffixRun() {
|
|
||||||
let records = [
|
let records = [
|
||||||
record(avg: 4.0, at: 0), // poor
|
record(avg: 4.0, at: 0), // poor
|
||||||
record(avg: 4.5, at: 18000), // poor, > 1h from first
|
record(avg: 4.5, at: 18000), // poor, > 1h from first
|
||||||
@@ -67,26 +59,24 @@ struct DriftAlertTests {
|
|||||||
record(avg: 4.0, at: 25000), // poor
|
record(avg: 4.0, at: 25000), // poor
|
||||||
record(avg: 4.5, at: 26000) // poor, < 1h and same day
|
record(avg: 4.5, at: 26000) // poor, < 1h and same day
|
||||||
]
|
]
|
||||||
#expect(DriftAlert.compute(from: records) == nil)
|
XCTAssertNil(DriftAlert.compute(from: records))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Final consecutive poor run alerts when far apart")
|
func testSuffixRunAlerts() {
|
||||||
func suffixRunAlerts() {
|
|
||||||
let records = [
|
let records = [
|
||||||
record(avg: 1.0, at: 0), // good
|
record(avg: 1.0, at: 0), // good
|
||||||
record(avg: 4.0, at: 1000), // poor
|
record(avg: 4.0, at: 1000), // poor
|
||||||
record(avg: 4.5, at: 4600) // poor, 1h after previous
|
record(avg: 4.5, at: 4600) // poor, 1h after previous
|
||||||
]
|
]
|
||||||
#expect(DriftAlert.compute(from: records) != nil)
|
XCTAssertNotNil(DriftAlert.compute(from: records))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A single final poor record after good records does not alert")
|
func testSingleFinalPoor() {
|
||||||
func singleFinalPoor() {
|
|
||||||
let records = [
|
let records = [
|
||||||
record(avg: 1.0, at: 0),
|
record(avg: 1.0, at: 0),
|
||||||
record(avg: 4.0, at: 86400)
|
record(avg: 4.0, at: 86400)
|
||||||
]
|
]
|
||||||
#expect(DriftAlert.compute(from: records) == nil)
|
XCTAssertNil(DriftAlert.compute(from: records))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func record(avg: Double, at offset: TimeInterval) -> VerificationRecord {
|
private func record(avg: Double, at offset: TimeInterval) -> VerificationRecord {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import Testing
|
import XCTest
|
||||||
import Foundation
|
import Foundation
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
@@ -13,91 +13,88 @@ private func touch(_ url: URL, _ contents: String = "x") throws {
|
|||||||
try contents.write(to: url, atomically: true, encoding: .utf8)
|
try contents.write(to: url, atomically: true, encoding: .utf8)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suite("PathSecurity")
|
final class PathSecurityTests: XCTestCase {
|
||||||
struct PathSecurityTests {
|
func testRejectsTraversalAndSeparators() {
|
||||||
@Test func rejectsTraversalAndSeparators() {
|
|
||||||
for bad in ["a/b", "a\\b", "..", "a/../b", "", "..x"] {
|
for bad in ["a/b", "a\\b", "..", "a/../b", "", "..x"] {
|
||||||
#expect(!PathSecurity.isValidBasename(bad))
|
XCTAssertFalse(PathSecurity.isValidBasename(bad))
|
||||||
#expect(throws: PathSecurity.Error.self) {
|
XCTAssertThrowsError(try PathSecurity.sanitizeBasename(bad)) { error in
|
||||||
try PathSecurity.sanitizeBasename(bad)
|
XCTAssertTrue(error is PathSecurity.Error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func acceptsNormalNames() {
|
func testAcceptsNormalNames() {
|
||||||
for good in ["target", "My Target 01", "écheneau-ümläut", "a.b"] {
|
for good in ["target", "My Target 01", "écheneau-ümläut", "a.b"] {
|
||||||
#expect(PathSecurity.isValidBasename(good))
|
XCTAssertTrue(PathSecurity.isValidBasename(good))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func resolveSafeCwdPrefersExplicit() throws {
|
func testResolveSafeCwdPrefersExplicit() throws {
|
||||||
let dir = try tempDir()
|
let dir = try tempDir()
|
||||||
#expect(PathSecurity.resolveSafeCwd(dir) == dir)
|
XCTAssertEqual(PathSecurity.resolveSafeCwd(dir), dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func resolveSafeCwdNeverReturnsNil() {
|
func testResolveSafeCwdNeverReturnsNil() {
|
||||||
let missing = URL(fileURLWithPath: "/nonexistent-\(UUID().uuidString)")
|
let missing = URL(fileURLWithPath: "/nonexistent-\(UUID().uuidString)")
|
||||||
let resolved = PathSecurity.resolveSafeCwd(missing)
|
let resolved = PathSecurity.resolveSafeCwd(missing)
|
||||||
#expect(FileManager.default.fileExists(atPath: resolved.path))
|
XCTAssertTrue(FileManager.default.fileExists(atPath: resolved.path))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suite("AtomicFileWriter")
|
final class AtomicFileWriterTests: XCTestCase {
|
||||||
struct AtomicFileWriterTests {
|
func testWritesAndLeavesNoTmp() throws {
|
||||||
@Test func writesAndLeavesNoTmp() throws {
|
|
||||||
let dir = try tempDir()
|
let dir = try tempDir()
|
||||||
let url = dir.appendingPathComponent("state.json")
|
let url = dir.appendingPathComponent("state.json")
|
||||||
try AtomicFileWriter.write(Data("{\"a\":1}".utf8), to: url)
|
try AtomicFileWriter.write(Data("{\"a\":1}".utf8), to: url)
|
||||||
#expect(try String(contentsOf: url, encoding: .utf8) == "{\"a\":1}")
|
XCTAssertEqual(try String(contentsOf: url, encoding: .utf8), "{\"a\":1}")
|
||||||
#expect(!FileManager.default.fileExists(atPath: url.appendingPathExtension("tmp").path))
|
XCTAssertFalse(FileManager.default.fileExists(atPath: url.appendingPathExtension("tmp").path))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func overwritesExistingAtomically() throws {
|
func testOverwritesExistingAtomically() throws {
|
||||||
let dir = try tempDir()
|
let dir = try tempDir()
|
||||||
let url = dir.appendingPathComponent("f.txt")
|
let url = dir.appendingPathComponent("f.txt")
|
||||||
try AtomicFileWriter.write("one", to: url)
|
try AtomicFileWriter.write("one", to: url)
|
||||||
try AtomicFileWriter.write("two-longer", to: url)
|
try AtomicFileWriter.write("two-longer", to: url)
|
||||||
#expect(try String(contentsOf: url, encoding: .utf8) == "two-longer")
|
XCTAssertEqual(try String(contentsOf: url, encoding: .utf8), "two-longer")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func createsParentDirs() throws {
|
func testCreatesParentDirs() throws {
|
||||||
let dir = try tempDir()
|
let dir = try tempDir()
|
||||||
let url = dir.appendingPathComponent("a/b/c/deep.json")
|
let url = dir.appendingPathComponent("a/b/c/deep.json")
|
||||||
try AtomicFileWriter.write("{}", to: url)
|
try AtomicFileWriter.write("{}", to: url)
|
||||||
#expect(FileManager.default.fileExists(atPath: url.path))
|
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suite("ArtefactProbe")
|
final class ArtefactProbeTests: XCTestCase {
|
||||||
struct ArtefactProbeTests {
|
func testVerifyProgression() throws {
|
||||||
@Test func verifyProgression() throws {
|
|
||||||
let dir = try tempDir()
|
let dir = try tempDir()
|
||||||
var v = ArtefactProbe.verify(basename: "t", cwd: dir)
|
var v = ArtefactProbe.verify(basename: "t", cwd: dir)
|
||||||
#expect(v == StageArtefacts())
|
XCTAssertEqual(v, StageArtefacts())
|
||||||
|
|
||||||
try touch(dir.appendingPathComponent("t.ti1"))
|
try touch(dir.appendingPathComponent("t.ti1"))
|
||||||
v = ArtefactProbe.verify(basename: "t", cwd: dir)
|
v = ArtefactProbe.verify(basename: "t", cwd: dir)
|
||||||
#expect(v.stage1Complete && !v.stage2Complete && !v.stage3Complete)
|
XCTAssertTrue(v.stage1Complete && !v.stage2Complete && !v.stage3Complete)
|
||||||
|
|
||||||
try touch(dir.appendingPathComponent("t.ti2"))
|
try touch(dir.appendingPathComponent("t.ti2"))
|
||||||
try touch(dir.appendingPathComponent("t.ti3"))
|
try touch(dir.appendingPathComponent("t.ti3"))
|
||||||
v = ArtefactProbe.verify(basename: "t", cwd: dir)
|
v = ArtefactProbe.verify(basename: "t", cwd: dir)
|
||||||
#expect(v.stage2Complete && v.stage3Complete && !v.stage4Complete)
|
XCTAssertTrue(v.stage2Complete && v.stage3Complete && !v.stage4Complete)
|
||||||
|
|
||||||
try touch(dir.appendingPathComponent("t.icc"))
|
try touch(dir.appendingPathComponent("t.icc"))
|
||||||
v = ArtefactProbe.verify(basename: "t", cwd: dir)
|
v = ArtefactProbe.verify(basename: "t", cwd: dir)
|
||||||
#expect(v.stage4Complete && v.profilePath?.pathExtension == "icc")
|
XCTAssertTrue(v.stage4Complete && v.profilePath?.pathExtension == "icc")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func icmWinsOverIcc() throws {
|
func testIcmWinsOverIcc() throws {
|
||||||
let dir = try tempDir()
|
let dir = try tempDir()
|
||||||
try touch(dir.appendingPathComponent("p.icc"))
|
try touch(dir.appendingPathComponent("p.icc"))
|
||||||
try touch(dir.appendingPathComponent("p.icm"))
|
try touch(dir.appendingPathComponent("p.icm"))
|
||||||
let profile = ArtefactProbe.resolveProfile(basename: "p", cwd: dir)
|
let profile = ArtefactProbe.resolveProfile(basename: "p", cwd: dir)
|
||||||
#expect(profile?.pathExtension == "icm")
|
XCTAssertEqual(profile?.pathExtension, "icm")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func enumeratesPassesPagesAndCAL() throws {
|
func testEnumeratesPassesPagesAndCAL() throws {
|
||||||
let dir = try tempDir()
|
let dir = try tempDir()
|
||||||
for name in [
|
for name in [
|
||||||
"t.ti1", "t.ti2", "t.tif", "t.2.tif", "t_03.tif",
|
"t.ti1", "t.ti2", "t.tif", "t.2.tif", "t_03.tif",
|
||||||
@@ -115,15 +112,15 @@ struct ArtefactProbeTests {
|
|||||||
"t.ti3", "t_pass1.ti3", "t_pass2.ti3",
|
"t.ti3", "t_pass1.ti3", "t_pass2.ti3",
|
||||||
"t.icc", "t.gam", "CAL_t.ti1", "CAL_t.cal",
|
"t.icc", "t.gam", "CAL_t.ti1", "CAL_t.cal",
|
||||||
] {
|
] {
|
||||||
#expect(names.contains(expected), "missing \(expected)")
|
XCTAssertTrue(names.contains(expected), "missing \(expected)")
|
||||||
}
|
}
|
||||||
#expect(!names.contains("other.ti1"))
|
XCTAssertFalse(names.contains("other.ti1"))
|
||||||
#expect(!names.contains("t.txt"))
|
XCTAssertFalse(names.contains("t.txt"))
|
||||||
#expect(!names.contains("CAL_other.ti1"))
|
XCTAssertFalse(names.contains("CAL_other.ti1"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func emptyDirReturnsEmpty() throws {
|
func testEmptyDirReturnsEmpty() throws {
|
||||||
let dir = try tempDir()
|
let dir = try tempDir()
|
||||||
#expect(ArtefactProbe.existingArtefacts(basename: "x", cwd: dir).isEmpty)
|
XCTAssertTrue(ArtefactProbe.existingArtefacts(basename: "x", cwd: dir).isEmpty)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import Foundation
|
||||||
|
import XCTest
|
||||||
|
@testable import ICCeryCore
|
||||||
|
|
||||||
|
/// ``GamutGeometry`` containment and volume goldens on the bundled
|
||||||
|
/// `sRGB.gam` reference mesh (issue #147). No GPU involved.
|
||||||
|
final class GamutContainmentTests: XCTestCase {
|
||||||
|
|
||||||
|
/// Returns the bundled real `sRGB.gam` in `Resources/Argyll/reference_gamuts`.
|
||||||
|
private var bundledSRGBGamURL: URL {
|
||||||
|
let bundle = Bundle.main
|
||||||
|
let resource = bundle.resourceURL ?? bundle.bundleURL
|
||||||
|
return resource.appendingPathComponent("Argyll/reference_gamuts/sRGB.gam")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadSRGB() throws -> GamutMesh {
|
||||||
|
try GamutMeshParser.parse(url: bundledSRGBGamURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNeutralMidGreyIsInsideSRGB() throws {
|
||||||
|
let mesh = try loadSRGB()
|
||||||
|
XCTAssertEqual(
|
||||||
|
GamutGeometry.containment(of: LabColor(l: 50, a: 0, b: 0), in: mesh),
|
||||||
|
.inside)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSaturatedColourIsOutsideSRGB() throws {
|
||||||
|
let mesh = try loadSRGB()
|
||||||
|
XCTAssertEqual(
|
||||||
|
GamutGeometry.containment(of: LabColor(l: 50, a: 80, b: 80), in: mesh),
|
||||||
|
.outside)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testVolumeIsFiniteAndPositiveOnBundledSRGB() throws {
|
||||||
|
let mesh = try loadSRGB()
|
||||||
|
let volume = GamutGeometry.volume(of: mesh)
|
||||||
|
XCTAssertTrue(volume.isFinite)
|
||||||
|
XCTAssertGreaterThan(volume, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testVertexOnlyMeshReportsUnknown() {
|
||||||
|
let mesh = GamutMesh(
|
||||||
|
vertices: [
|
||||||
|
GamutVertex(lab: LabColor(l: 50, a: 0, b: 0), rgb: DisplayRGB(r: 0.5, g: 0.5, b: 0.5)),
|
||||||
|
],
|
||||||
|
faces: [])
|
||||||
|
XCTAssertEqual(
|
||||||
|
GamutGeometry.containment(of: LabColor(l: 50, a: 0, b: 0), in: mesh),
|
||||||
|
.unknown)
|
||||||
|
XCTAssertEqual(GamutGeometry.volume(of: mesh), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testKnownCubeFixture() throws {
|
||||||
|
// Unit cube centred at Lab (50, 0, 0): a*,b* ∈ ±10, L* ∈ 40...60.
|
||||||
|
// Two triangles per face, outward winding.
|
||||||
|
let lab = { (l: Double, a: Double, b: Double) in
|
||||||
|
GamutVertex(lab: LabColor(l: l, a: a, b: b), rgb: DisplayRGB(r: 0, g: 0, b: 0))
|
||||||
|
}
|
||||||
|
// Corners in (a, L, b) space.
|
||||||
|
let c = [
|
||||||
|
lab(40, -10, -10), lab(40, 10, -10), lab(40, 10, 10), lab(40, -10, 10), // bottom
|
||||||
|
lab(60, -10, -10), lab(60, 10, -10), lab(60, 10, 10), lab(60, -10, 10), // top
|
||||||
|
]
|
||||||
|
let quad = { (a: UInt32, b: UInt32, c: UInt32, d: UInt32) in
|
||||||
|
[GamutTriangle(a: a, b: b, c: c), GamutTriangle(a: a, b: c, c: d)]
|
||||||
|
}
|
||||||
|
var faces: [GamutTriangle] = []
|
||||||
|
faces += quad(0, 3, 2, 1) // bottom (y=40)
|
||||||
|
faces += quad(4, 5, 6, 7) // top (y=60)
|
||||||
|
faces += quad(0, 1, 5, 4) // z=-10
|
||||||
|
faces += quad(3, 7, 6, 2) // z=+10
|
||||||
|
faces += quad(1, 2, 6, 5) // x=+10
|
||||||
|
faces += quad(0, 4, 7, 3) // x=-10
|
||||||
|
let mesh = GamutMesh(vertices: c, faces: faces)
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
GamutGeometry.containment(of: LabColor(l: 50, a: 0, b: 0), in: mesh),
|
||||||
|
.inside)
|
||||||
|
XCTAssertEqual(
|
||||||
|
GamutGeometry.containment(of: LabColor(l: 50, a: 20, b: 0), in: mesh),
|
||||||
|
.outside)
|
||||||
|
// 20 × 20 × 20 Lab-cube.
|
||||||
|
XCTAssertEqual(GamutGeometry.volume(of: mesh), 8000, accuracy: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,13 @@
|
|||||||
import Testing
|
import XCTest
|
||||||
import SceneKit
|
import SceneKit
|
||||||
import ICCeryCore
|
import ICCeryCore
|
||||||
@testable import ICCery
|
@testable import ICCery
|
||||||
|
|
||||||
/// ``GamutSceneGeometryBuilder`` edge-case tests.
|
/// ``GamutSceneGeometryBuilder`` edge-case tests.
|
||||||
@Suite("Gamut scene geometry builder")
|
|
||||||
@MainActor
|
@MainActor
|
||||||
struct GamutGeometryBuilderTests {
|
final class GamutGeometryBuilderTests: XCTestCase {
|
||||||
|
|
||||||
@Test("Drops out-of-bounds faces from the element without crashing")
|
func testDropsOutOfBoundsFaces() {
|
||||||
func dropsOutOfBoundsFaces() {
|
|
||||||
let white = GamutVertex(
|
let white = GamutVertex(
|
||||||
lab: LabColor(l: 100, a: 0, b: 0),
|
lab: LabColor(l: 100, a: 0, b: 0),
|
||||||
rgb: DisplayRGB(r: 1, g: 1, b: 1)
|
rgb: DisplayRGB(r: 1, g: 1, b: 1)
|
||||||
@@ -28,6 +26,6 @@ struct GamutGeometryBuilderTests {
|
|||||||
|
|
||||||
let (_, element) = GamutSceneGeometryBuilder.geometry(for: mesh)
|
let (_, element) = GamutSceneGeometryBuilder.geometry(for: mesh)
|
||||||
|
|
||||||
#expect(element.primitiveCount == 1, "Only the in-bounds face should be in the index buffer")
|
XCTAssertEqual(element.primitiveCount, 1, "Only the in-bounds face should be in the index buffer")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import XCTest
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
/// ``GamutMeshParser`` acceptance + edge-case tests.
|
/// ``GamutMeshParser`` acceptance + edge-case tests.
|
||||||
@Suite("Gamut mesh parser")
|
final class GamutMeshParserTests: XCTestCase {
|
||||||
struct GamutMeshParserTests {
|
|
||||||
|
|
||||||
/// Returns the bundled real `sRGB.gam` in `Resources/Argyll/reference_gamuts`.
|
/// Returns the bundled real `sRGB.gam` in `Resources/Argyll/reference_gamuts`.
|
||||||
private var bundledSRGBGamURL: URL {
|
private var bundledSRGBGamURL: URL {
|
||||||
@@ -13,16 +12,14 @@ struct GamutMeshParserTests {
|
|||||||
return resource.appendingPathComponent("Argyll/reference_gamuts/sRGB.gam")
|
return resource.appendingPathComponent("Argyll/reference_gamuts/sRGB.gam")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Parses bundled sRGB.gam")
|
func testParsesBundledSRGB() throws {
|
||||||
func parsesBundledSRGB() throws {
|
|
||||||
let mesh = try GamutMeshParser.parse(url: bundledSRGBGamURL)
|
let mesh = try GamutMeshParser.parse(url: bundledSRGBGamURL)
|
||||||
|
|
||||||
#expect(mesh.vertices.count == 448, "sRGB.gam has 448 vertices")
|
XCTAssertEqual(mesh.vertices.count, 448, "sRGB.gam has 448 vertices")
|
||||||
#expect(mesh.faces.count == 892, "sRGB.gam has 892 faces")
|
XCTAssertEqual(mesh.faces.count, 892, "sRGB.gam has 892 faces")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Discards VERTEX_NO and uses push-order indices")
|
func testDiscardsVertexNo() throws {
|
||||||
func discardsVertexNo() throws {
|
|
||||||
let text = """
|
let text = """
|
||||||
GAMUT
|
GAMUT
|
||||||
NUMBER_OF_FIELDS 4
|
NUMBER_OF_FIELDS 4
|
||||||
@@ -49,14 +46,13 @@ struct GamutMeshParserTests {
|
|||||||
|
|
||||||
let mesh = try GamutMeshParser.parse(text: text)
|
let mesh = try GamutMeshParser.parse(text: text)
|
||||||
|
|
||||||
#expect(mesh.vertices.count == 4)
|
XCTAssertEqual(mesh.vertices.count, 4)
|
||||||
#expect(mesh.faces.count == 2)
|
XCTAssertEqual(mesh.faces.count, 2)
|
||||||
#expect(mesh.vertices[0].lab == LabColor(l: 10, a: 20, b: 30))
|
XCTAssertEqual(mesh.vertices[0].lab, LabColor(l: 10, a: 20, b: 30))
|
||||||
#expect(mesh.vertices[3].lab == LabColor(l: 40, a: 50, b: 60))
|
XCTAssertEqual(mesh.vertices[3].lab, LabColor(l: 40, a: 50, b: 60))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Ignores comments and blank lines")
|
func testIgnoresComments() throws {
|
||||||
func ignoresComments() throws {
|
|
||||||
let text = """
|
let text = """
|
||||||
# Header comment
|
# Header comment
|
||||||
NUMBER_OF_FIELDS 4
|
NUMBER_OF_FIELDS 4
|
||||||
@@ -81,12 +77,11 @@ struct GamutMeshParserTests {
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
let mesh = try GamutMeshParser.parse(text: text)
|
let mesh = try GamutMeshParser.parse(text: text)
|
||||||
#expect(mesh.vertices.count == 2)
|
XCTAssertEqual(mesh.vertices.count, 2)
|
||||||
#expect(mesh.faces.count == 1)
|
XCTAssertEqual(mesh.faces.count, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Remaps coordinates to x=a*, y=L*, z=b*")
|
func testRemapsCoordinates() throws {
|
||||||
func remapsCoordinates() throws {
|
|
||||||
let text = """
|
let text = """
|
||||||
NUMBER_OF_FIELDS 4
|
NUMBER_OF_FIELDS 4
|
||||||
BEGIN_DATA_FORMAT
|
BEGIN_DATA_FORMAT
|
||||||
@@ -99,11 +94,10 @@ struct GamutMeshParserTests {
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
let mesh = try GamutMeshParser.parse(text: text)
|
let mesh = try GamutMeshParser.parse(text: text)
|
||||||
#expect(mesh.vertices.first?.position == SIMD3<Float>(-20, 50, 80))
|
XCTAssertEqual(mesh.vertices.first?.position, SIMD3<Float>(-20, 50, 80))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Computes per-vertex sRGB colour")
|
func testComputesVertexColor() throws {
|
||||||
func computesVertexColor() throws {
|
|
||||||
let text = """
|
let text = """
|
||||||
NUMBER_OF_FIELDS 4
|
NUMBER_OF_FIELDS 4
|
||||||
BEGIN_DATA_FORMAT
|
BEGIN_DATA_FORMAT
|
||||||
@@ -116,14 +110,13 @@ struct GamutMeshParserTests {
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
let mesh = try GamutMeshParser.parse(text: text)
|
let mesh = try GamutMeshParser.parse(text: text)
|
||||||
let white = try #require(mesh.vertices.first).rgb
|
let white = try XCTUnwrap(mesh.vertices.first).rgb
|
||||||
#expect(white.r > 0.95)
|
XCTAssertTrue(white.r > 0.95)
|
||||||
#expect(white.g > 0.95)
|
XCTAssertTrue(white.g > 0.95)
|
||||||
#expect(white.b > 0.95)
|
XCTAssertTrue(white.b > 0.95)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Drops out-of-bounds face indices")
|
func testDropsOutOfBoundsFaces() throws {
|
||||||
func dropsOutOfBoundsFaces() throws {
|
|
||||||
let text = """
|
let text = """
|
||||||
NUMBER_OF_FIELDS 4
|
NUMBER_OF_FIELDS 4
|
||||||
BEGIN_DATA_FORMAT
|
BEGIN_DATA_FORMAT
|
||||||
@@ -146,21 +139,23 @@ struct GamutMeshParserTests {
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
let mesh = try GamutMeshParser.parse(text: text)
|
let mesh = try GamutMeshParser.parse(text: text)
|
||||||
#expect(mesh.faces.count == 1)
|
XCTAssertEqual(mesh.faces.count, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Throws on empty file")
|
func testThrowsOnEmptyFile() {
|
||||||
func throwsOnEmptyFile() {
|
XCTAssertThrowsError(try GamutMeshParser.parse(text: "")) { error in
|
||||||
#expect(throws: GamutMeshParseError.noDataBlock) {
|
guard case GamutMeshParseError.noDataBlock = error else {
|
||||||
_ = try GamutMeshParser.parse(text: "")
|
return XCTFail("Expected GamutMeshParseError.noDataBlock, got \(error)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Throws when file is missing")
|
func testThrowsWhenMissing() {
|
||||||
func throwsWhenMissing() {
|
|
||||||
let url = URL(fileURLWithPath: "/nonexistent/path/to/mesh.gam")
|
let url = URL(fileURLWithPath: "/nonexistent/path/to/mesh.gam")
|
||||||
#expect(throws: GamutMeshParseError.missingFile) {
|
XCTAssertThrowsError(try GamutMeshParser.parse(url: url)) { error in
|
||||||
_ = try GamutMeshParser.parse(url: url)
|
guard case GamutMeshParseError.missingFile = error else {
|
||||||
|
return XCTFail("Expected GamutMeshParseError.missingFile, got \(error)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,14 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import XCTest
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
@Suite("IccgamutArgs")
|
final class IccgamutArgsTests: XCTestCase {
|
||||||
struct IccgamutArgsTests {
|
|
||||||
|
|
||||||
@Test("Density is 10 and not a directory")
|
func testDensityNotDirectory() throws {
|
||||||
func densityNotDirectory() throws {
|
|
||||||
let config = IccgamutConfig(
|
let config = IccgamutConfig(
|
||||||
profileURL: URL(fileURLWithPath: "/tmp/MyProfile.icc")
|
profileURL: URL(fileURLWithPath: "/tmp/MyProfile.icc")
|
||||||
)
|
)
|
||||||
let args = try IccgamutArgs.build(config: config)
|
let args = try IccgamutArgs.build(config: config)
|
||||||
#expect(args == ["-v", "-d", "10", "/tmp/MyProfile.icc"])
|
XCTAssertEqual(args, ["-v", "-d", "10", "/tmp/MyProfile.icc"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import XCTest
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
@Suite("JSONFileStore")
|
final class JSONFileStoreTests: XCTestCase {
|
||||||
struct JSONFileStoreTests {
|
|
||||||
private func tempURL() -> URL {
|
private func tempURL() -> URL {
|
||||||
FileManager.default.temporaryDirectory
|
FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("json-store-\(UUID().uuidString).json")
|
.appendingPathComponent("json-store-\(UUID().uuidString).json")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Corrupt file with replaceWithDefault returns default")
|
func testMissingFileDefaults() throws {
|
||||||
func corruptDefaults() throws {
|
let store = JSONFileStore<AppSettings>(
|
||||||
|
fileURL: tempURL(),
|
||||||
|
corrupt: .throwCorrupt,
|
||||||
|
defaultValue: { .default }
|
||||||
|
)
|
||||||
|
XCTAssertEqual(try store.load(), .default)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCorruptDefaults() throws {
|
||||||
let url = tempURL()
|
let url = tempURL()
|
||||||
try "{ not json".write(to: url, atomically: true, encoding: .utf8)
|
try "{ not json".write(to: url, atomically: true, encoding: .utf8)
|
||||||
let store = JSONFileStore<AppSettings>(
|
let store = JSONFileStore<AppSettings>(
|
||||||
@@ -18,12 +25,12 @@ struct JSONFileStoreTests {
|
|||||||
corrupt: .replaceWithDefault,
|
corrupt: .replaceWithDefault,
|
||||||
defaultValue: { .default }
|
defaultValue: { .default }
|
||||||
)
|
)
|
||||||
#expect(try store.load() == .default)
|
XCTAssertEqual(try store.load(), .default)
|
||||||
#expect(FileManager.default.fileExists(atPath: url.path))
|
let kept = try String(contentsOf: url, encoding: .utf8)
|
||||||
|
XCTAssertEqual(kept, "{ not json")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Corrupt file with throwCorrupt throws and leaves bytes")
|
func testCorruptThrows() throws {
|
||||||
func corruptThrows() throws {
|
|
||||||
let url = tempURL()
|
let url = tempURL()
|
||||||
try "not json".write(to: url, atomically: true, encoding: .utf8)
|
try "not json".write(to: url, atomically: true, encoding: .utf8)
|
||||||
let store = JSONFileStore<[Int]>(
|
let store = JSONFileStore<[Int]>(
|
||||||
@@ -31,15 +38,12 @@ struct JSONFileStoreTests {
|
|||||||
corrupt: .throwCorrupt,
|
corrupt: .throwCorrupt,
|
||||||
defaultValue: { [] }
|
defaultValue: { [] }
|
||||||
)
|
)
|
||||||
#expect(throws: DecodingError.self) {
|
XCTAssertThrowsError(try store.load()) { error in XCTAssertTrue(error is DecodingError) }
|
||||||
_ = try store.load()
|
|
||||||
}
|
|
||||||
let kept = try String(contentsOf: url, encoding: .utf8)
|
let kept = try String(contentsOf: url, encoding: .utf8)
|
||||||
#expect(kept == "not json")
|
XCTAssertEqual(kept, "not json")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Pretty sorted keys")
|
func testPrettySorted() throws {
|
||||||
func prettySorted() throws {
|
|
||||||
let url = tempURL()
|
let url = tempURL()
|
||||||
let store = JSONFileStore<AppSettings>(
|
let store = JSONFileStore<AppSettings>(
|
||||||
fileURL: url,
|
fileURL: url,
|
||||||
@@ -48,45 +52,26 @@ struct JSONFileStoreTests {
|
|||||||
)
|
)
|
||||||
try store.save(.default)
|
try store.save(.default)
|
||||||
let text = try String(contentsOf: url, encoding: .utf8)
|
let text = try String(contentsOf: url, encoding: .utf8)
|
||||||
#expect(text.contains("\n"))
|
XCTAssertTrue(text.contains("\n"))
|
||||||
#expect(text.contains("\"delta_e_good_max\""))
|
XCTAssertTrue(text.contains("\"delta_e_good_max\""))
|
||||||
}
|
// Lexical key sorting: ascending order of top-level keys.
|
||||||
}
|
let keys = [
|
||||||
|
"ask_before_overwrite_profile",
|
||||||
@Suite("CalibrationIdentity")
|
"calibration_stale_days",
|
||||||
struct CalibrationIdentityTests {
|
"custom_presets",
|
||||||
@Test("live foo, no persisted")
|
"default_install_location",
|
||||||
func livePlain() {
|
"delta_e_good_max",
|
||||||
let id = CalibrationIdentity.parse(liveBasename: "foo", persistedOriginal: "")
|
"delta_e_warning_max",
|
||||||
#expect(id.originalBasename == "foo")
|
"enable_i1pro2_leds",
|
||||||
#expect(id.calibrationBasename == "CAL_foo")
|
"open_color_panel_after_install",
|
||||||
}
|
]
|
||||||
|
var lastIndex = text.startIndex
|
||||||
@Test("live CAL_foo, persisted foo")
|
for key in keys {
|
||||||
func liveCalPersisted() {
|
guard let range = text.range(of: "\"\(key)\"", range: lastIndex..<text.endIndex) else {
|
||||||
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "foo")
|
XCTFail("missing or out-of-order key \(key)")
|
||||||
#expect(id.originalBasename == "foo")
|
return
|
||||||
#expect(id.calibrationBasename == "CAL_foo")
|
}
|
||||||
}
|
lastIndex = range.upperBound
|
||||||
|
}
|
||||||
@Test("live CAL_foo, empty persisted strips prefix")
|
|
||||||
func liveCalNoPersist() {
|
|
||||||
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "")
|
|
||||||
#expect(id.originalBasename == "foo")
|
|
||||||
#expect(id.calibrationBasename == "CAL_foo")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("persisted original wins")
|
|
||||||
func persistedWins() {
|
|
||||||
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "bar")
|
|
||||||
#expect(id.originalBasename == "bar")
|
|
||||||
#expect(id.calibrationBasename == "CAL_bar")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("empty live does not invent a name")
|
|
||||||
func emptyLive() {
|
|
||||||
let id = CalibrationIdentity.parse(liveBasename: "", persistedOriginal: "")
|
|
||||||
#expect(id.originalBasename.isEmpty)
|
|
||||||
#expect(id.calibrationBasename.isEmpty)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import Testing
|
import XCTest
|
||||||
import Foundation
|
import Foundation
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
/// Issue 15 — `lp` argv goldens (docs/11 `build_lp_args`).
|
/// Issue 15 — `lp` argv goldens (docs/11 `build_lp_args`).
|
||||||
/// `-d`/`options`/`-t` handling is in `CupsService`; these tests cover
|
/// `-d`/`options`/`-t` handling is in `CupsService`; these tests cover
|
||||||
/// flag order, captured-option precedence, and sanitisation.
|
/// flag order, captured-option precedence, and sanitisation.
|
||||||
@Suite("LpArgs")
|
final class LpArgsTests: XCTestCase {
|
||||||
struct LpArgsTests {
|
|
||||||
|
|
||||||
private let tiff = "/tmp/work/target_001.tif"
|
private let tiff = "/tmp/work/target_001.tif"
|
||||||
private let queue = "EPSON_XP_55_Series"
|
private let queue = "EPSON_XP_55_Series"
|
||||||
@@ -20,112 +19,155 @@ struct LpArgsTests {
|
|||||||
options: options, optionKeys: optionKeys)
|
options: options, optionKeys: optionKeys)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Header: -d queue -t title, both AP_* first, TIFF last")
|
func testHeader() throws {
|
||||||
func header() throws {
|
|
||||||
let argv = try build()
|
let argv = try build()
|
||||||
#expect(Array(argv[0...1]) == ["-d", queue])
|
XCTAssertEqual(Array(argv[0...1]), ["-d", queue])
|
||||||
#expect(Array(argv[2...3]) == ["-t", "ICCery Target - target_001.tif"])
|
XCTAssertEqual(Array(argv[2...3]), ["-t", "ICCery Target - target_001.tif"])
|
||||||
#expect(Array(argv[4...5])
|
XCTAssertEqual(Array(argv[4...5]), ["-o", "AP_ColorMatchingMode=AP_ApplicationColorMatching"])
|
||||||
== ["-o", "AP_ColorMatchingMode=AP_ApplicationColorMatching"])
|
XCTAssertEqual(Array(argv[6...7]), ["-o", "AP.ColorMatchingMode=AP_ApplicationColorMatching"])
|
||||||
#expect(Array(argv[6...7])
|
XCTAssertEqual(argv.last, tiff)
|
||||||
== ["-o", "AP.ColorMatchingMode=AP_ApplicationColorMatching"])
|
XCTAssertFalse(argv.contains { $0 == "raw" || $0 == "-o raw" })
|
||||||
#expect(argv.last == tiff)
|
|
||||||
#expect(!argv.contains { $0 == "raw" || $0 == "-o raw" })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Never emits -o raw; captured raw= is dropped")
|
func testNeverRaw() throws {
|
||||||
func neverRaw() throws {
|
|
||||||
let argv = try build(options: PrintOptions(
|
let argv = try build(options: PrintOptions(
|
||||||
cupsOptions: "raw=true MediaType=Photo"))
|
cupsOptions: "raw=true MediaType=Photo"))
|
||||||
for (i, arg) in argv.enumerated() where arg == "-o" {
|
for (i, arg) in argv.enumerated() where arg == "-o" {
|
||||||
#expect(argv[i + 1] != "raw")
|
XCTAssertNotEqual(argv[i + 1], "raw")
|
||||||
#expect(argv[i + 1] != "raw=true")
|
XCTAssertNotEqual(argv[i + 1], "raw=true")
|
||||||
}
|
}
|
||||||
#expect(!argv.contains { $0.hasPrefix("raw=") })
|
XCTAssertFalse(argv.contains { $0.hasPrefix("raw=") })
|
||||||
#expect(argv.contains("MediaType=Photo"))
|
XCTAssertTrue(argv.contains("MediaType=Photo"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Captured options replayed after AP_* headers")
|
func testCapturedReplay() throws {
|
||||||
func capturedReplay() throws {
|
|
||||||
let argv = try build(options: PrintOptions(
|
let argv = try build(options: PrintOptions(
|
||||||
cupsOptions: "InputSlot=Rear MediaType=Photo"))
|
cupsOptions: "InputSlot=Rear MediaType=Photo"))
|
||||||
let rear = argv.firstIndex(of: "InputSlot=Rear")!
|
let rear = argv.firstIndex(of: "InputSlot=Rear")!
|
||||||
let apFirst = argv.firstIndex(of:
|
let apFirst = argv.firstIndex(of:
|
||||||
"AP_ColorMatchingMode=AP_ApplicationColorMatching")!
|
"AP_ColorMatchingMode=AP_ApplicationColorMatching")!
|
||||||
#expect(rear > apFirst)
|
XCTAssertTrue(rear > apFirst)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Captured wins: media key present → derived media skipped")
|
func testCapturedWinsMedia() throws {
|
||||||
func capturedWinsMedia() throws {
|
|
||||||
let argv = try build(
|
let argv = try build(
|
||||||
options: PrintOptions(
|
options: PrintOptions(
|
||||||
mediaType: "Plain",
|
mediaType: "Plain",
|
||||||
cupsOptions: "MediaType=Glossy"),
|
cupsOptions: "MediaType=Glossy"),
|
||||||
optionKeys: ["MediaType"])
|
optionKeys: ["MediaType"])
|
||||||
#expect(argv.contains("MediaType=Glossy"))
|
XCTAssertTrue(argv.contains("MediaType=Glossy"))
|
||||||
#expect(!argv.contains("MediaType=Plain"))
|
XCTAssertFalse(argv.contains("MediaType=Plain"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Media emitted via detected key when not captured")
|
func testMediaDerived() throws {
|
||||||
func mediaDerived() throws {
|
|
||||||
let argv = try build(
|
let argv = try build(
|
||||||
options: PrintOptions(mediaType: "SemiGloss"),
|
options: PrintOptions(mediaType: "SemiGloss"),
|
||||||
optionKeys: ["CNIJMediaType", "MediaType"])
|
optionKeys: ["CNIJMediaType", "MediaType"])
|
||||||
// CNIJMediaType wins over MediaType in detection order.
|
// CNIJMediaType wins over MediaType in detection order.
|
||||||
#expect(argv.contains("CNIJMediaType=SemiGloss"))
|
XCTAssertTrue(argv.contains("CNIJMediaType=SemiGloss"))
|
||||||
#expect(!argv.contains("MediaType=SemiGloss"))
|
XCTAssertFalse(argv.contains("MediaType=SemiGloss"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Driver bypass emitted when absent, skipped when captured")
|
func testBypassRules() throws {
|
||||||
func bypassRules() throws {
|
|
||||||
let withBypass = try build(
|
let withBypass = try build(
|
||||||
optionKeys: ["EPIJ_CMat"])
|
optionKeys: ["EPIJ_CMat"])
|
||||||
#expect(withBypass.contains("EPIJ_CMat=3"))
|
XCTAssertTrue(withBypass.contains("EPIJ_CMat=3"))
|
||||||
|
|
||||||
let captured = try build(
|
let captured = try build(
|
||||||
options: PrintOptions(cupsOptions: "EPIJ_CMat=1"),
|
options: PrintOptions(cupsOptions: "EPIJ_CMat=1"),
|
||||||
optionKeys: ["EPIJ_CMat"])
|
optionKeys: ["EPIJ_CMat"])
|
||||||
// Captured value kept, detection not re-applied.
|
// Captured value kept, detection not re-applied.
|
||||||
#expect(captured.filter { $0.hasPrefix("EPIJ_CMat") }
|
XCTAssertEqual(captured.filter { $0.hasPrefix("EPIJ_CMat") }, ["EPIJ_CMat=1"])
|
||||||
== ["EPIJ_CMat=1"])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Orientation: portrait=3 landscape=4; captured wins")
|
func testOrientation() throws {
|
||||||
func orientation() throws {
|
XCTAssertTrue(try build(options: PrintOptions(orientation: "portrait"))
|
||||||
#expect(try build(options: PrintOptions(orientation: "portrait"))
|
|
||||||
.contains("orientation-requested=3"))
|
.contains("orientation-requested=3"))
|
||||||
#expect(try build(options: PrintOptions(orientation: "landscape"))
|
XCTAssertTrue(try build(options: PrintOptions(orientation: "landscape"))
|
||||||
.contains("orientation-requested=4"))
|
.contains("orientation-requested=4"))
|
||||||
let capturedOrients = try build(options: PrintOptions(
|
let capturedOrients = try build(options: PrintOptions(
|
||||||
orientation: "landscape",
|
orientation: "landscape",
|
||||||
cupsOptions: "orientation-requested=5"))
|
cupsOptions: "orientation-requested=5"))
|
||||||
#expect(!capturedOrients.contains("orientation-requested=4"))
|
XCTAssertFalse(capturedOrients.contains("orientation-requested=4"))
|
||||||
#expect(capturedOrients.contains("orientation-requested=5"))
|
XCTAssertTrue(capturedOrients.contains("orientation-requested=5"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("PageSize emitted unless captured")
|
func testPageSize() throws {
|
||||||
func pageSize() throws {
|
XCTAssertTrue(try build(options: PrintOptions(paperSize: "A4"))
|
||||||
#expect(try build(options: PrintOptions(paperSize: "A4"))
|
|
||||||
.contains("PageSize=A4"))
|
.contains("PageSize=A4"))
|
||||||
let capturedSize = try build(options: PrintOptions(
|
let capturedSize = try build(options: PrintOptions(
|
||||||
paperSize: "A4", cupsOptions: "PageSize=Letter"))
|
paperSize: "A4", cupsOptions: "PageSize=Letter"))
|
||||||
#expect(!capturedSize.contains("PageSize=A4"))
|
XCTAssertFalse(capturedSize.contains("PageSize=A4"))
|
||||||
#expect(capturedSize.contains("PageSize=Letter"))
|
XCTAssertTrue(capturedSize.contains("PageSize=Letter"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Sanitise rejects `;`, newline, and shell metachars")
|
// MARK: - #183 print quality
|
||||||
func sanitise() throws {
|
|
||||||
#expect(throws: LpArgsError.self) {
|
func testQualityDerived() throws {
|
||||||
_ = try build(options: PrintOptions(
|
let argv = try build(
|
||||||
cupsOptions: "InputSlot=Rear;rm -rf /"))
|
options: PrintOptions(
|
||||||
|
orientation: "portrait", mediaType: "Photo", quality: "305"),
|
||||||
|
optionKeys: ["EPIJ_Qual", "MediaType"])
|
||||||
|
XCTAssertTrue(argv.contains("EPIJ_Qual=305"))
|
||||||
|
// Emit order: after the media option, before orientation.
|
||||||
|
let media = argv.firstIndex(of: "MediaType=Photo")!
|
||||||
|
let quality = argv.firstIndex(of: "EPIJ_Qual=305")!
|
||||||
|
let orient = argv.firstIndex(of: "orientation-requested=3")!
|
||||||
|
XCTAssertTrue(media < quality && quality < orient)
|
||||||
}
|
}
|
||||||
#expect(throws: LpArgsError.self) {
|
|
||||||
_ = try build(options: PrintOptions(
|
func testQualityDetectedKey() throws {
|
||||||
cupsOptions: "InputSlot=Rear\nMediaType=Photo"))
|
// The detected queue key is used, not a hardcoded one.
|
||||||
|
let argv = try build(
|
||||||
|
options: PrintOptions(quality: "High"),
|
||||||
|
optionKeys: ["cupsPrintQuality"])
|
||||||
|
XCTAssertTrue(argv.contains("cupsPrintQuality=High"))
|
||||||
}
|
}
|
||||||
#expect(throws: LpArgsError.self) {
|
|
||||||
_ = try build(options: PrintOptions(
|
func testCapturedWinsQuality() throws {
|
||||||
cupsOptions: "InputSlot=$(whoami)"))
|
let argv = try build(
|
||||||
|
options: PrintOptions(
|
||||||
|
quality: "303",
|
||||||
|
cupsOptions: "EPIJ_Qual=308"),
|
||||||
|
optionKeys: ["EPIJ_Qual"])
|
||||||
|
XCTAssertTrue(argv.contains("EPIJ_Qual=308"))
|
||||||
|
XCTAssertFalse(argv.contains("EPIJ_Qual=303"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCapturedQualityCaseInsensitiveDedup() throws {
|
||||||
|
let argv = try build(
|
||||||
|
options: PrintOptions(
|
||||||
|
quality: "303",
|
||||||
|
cupsOptions: "epij_qual=308"),
|
||||||
|
optionKeys: ["EPIJ_Qual"])
|
||||||
|
XCTAssertFalse(argv.contains("EPIJ_Qual=303"))
|
||||||
|
XCTAssertTrue(argv.contains("epij_qual=308"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testQualityNilNoEmit() throws {
|
||||||
|
let argv = try build(
|
||||||
|
options: PrintOptions(mediaType: "Photo"),
|
||||||
|
optionKeys: ["EPIJ_Qual", "MediaType"])
|
||||||
|
XCTAssertFalse(argv.contains { $0.hasPrefix("EPIJ_Qual=") })
|
||||||
|
// No quality key on the queue → no emit either.
|
||||||
|
let noKey = try build(
|
||||||
|
options: PrintOptions(quality: "303"),
|
||||||
|
optionKeys: ["MediaType"])
|
||||||
|
XCTAssertFalse(noKey.contains { $0.hasPrefix("EPIJ_Qual=") })
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import XCTest
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
@Suite("InstrumentParser")
|
final class InstrumentParserTests: XCTestCase {
|
||||||
struct InstrumentParserTests {
|
|
||||||
|
|
||||||
@Test("Parses pretty-printed instlist JSON")
|
func testJson() throws {
|
||||||
func json() throws {
|
|
||||||
let json = """
|
let json = """
|
||||||
{
|
{
|
||||||
"event": "instruments",
|
"event": "instruments",
|
||||||
@@ -18,134 +16,118 @@ struct InstrumentParserTests {
|
|||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
let devices = try InstrumentParser.parse(json)
|
let devices = try InstrumentParser.parse(json)
|
||||||
#expect(devices.count == 3)
|
XCTAssertEqual(devices.count, 3)
|
||||||
#expect(devices[0].port == 1)
|
XCTAssertEqual(devices[0].port, 1)
|
||||||
#expect(devices[0].name == "X-Rite i1Pro")
|
XCTAssertEqual(devices[0].name, "X-Rite i1Pro")
|
||||||
#expect(devices[2].port == 3)
|
XCTAssertEqual(devices[2].port, 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Falls back to regex for legacy instlist text")
|
func testRegexFallback() throws {
|
||||||
func regexFallback() throws {
|
|
||||||
let text = """
|
let text = """
|
||||||
1: 'X-Rite i1Pro' on usb
|
1: 'X-Rite i1Pro' on usb
|
||||||
2: 'ColorMunki Smile'
|
2: 'ColorMunki Smile'
|
||||||
""" + "\n"
|
""" + "\n"
|
||||||
let devices = try InstrumentParser.parse(text)
|
let devices = try InstrumentParser.parse(text)
|
||||||
#expect(devices.count == 2)
|
XCTAssertEqual(devices.count, 2)
|
||||||
#expect(devices[0].port == 1)
|
XCTAssertEqual(devices[0].port, 1)
|
||||||
#expect(devices[1].name == "ColorMunki Smile")
|
XCTAssertEqual(devices[1].name, "ColorMunki Smile")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Empty output returns no devices")
|
func testEmpty() throws {
|
||||||
func empty() throws {
|
XCTAssertTrue(try InstrumentParser.parse("").isEmpty)
|
||||||
#expect(try InstrumentParser.parse("").isEmpty)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suite("ChartreadArgs")
|
final class ChartreadArgsTests: XCTestCase {
|
||||||
struct ChartreadArgsTests {
|
|
||||||
|
|
||||||
@Test("Baseline argv and port 1 omits -c")
|
func testBaseline() throws {
|
||||||
func baseline() throws {
|
|
||||||
let config = ChartreadConfig(basename: "target", selectedPort: 1)
|
let config = ChartreadConfig(basename: "target", selectedPort: 1)
|
||||||
let args = try ChartreadArgs.build(config: config)
|
let args = try ChartreadArgs.build(config: config)
|
||||||
#expect(args == ["-v", "-u", "target"])
|
XCTAssertEqual(args, ["-v", "-u", "target"])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Port > 1 emits -c")
|
func testPortArgument() throws {
|
||||||
func portArgument() throws {
|
|
||||||
let config = ChartreadConfig(basename: "target", selectedPort: 3)
|
let config = ChartreadConfig(basename: "target", selectedPort: 3)
|
||||||
let args = try ChartreadArgs.build(config: config)
|
let args = try ChartreadArgs.build(config: config)
|
||||||
#expect(args == ["-v", "-u", "-c", "3", "target"])
|
XCTAssertEqual(args, ["-v", "-u", "-c", "3", "target"])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("LEDs emit -Y l")
|
func testLeds() throws {
|
||||||
func leds() throws {
|
|
||||||
let config = ChartreadConfig(
|
let config = ChartreadConfig(
|
||||||
basename: "target",
|
basename: "target",
|
||||||
selectedPort: 2,
|
selectedPort: 2,
|
||||||
enableLEDs: true
|
enableLEDs: true
|
||||||
)
|
)
|
||||||
let args = try ChartreadArgs.build(config: config)
|
let args = try ChartreadArgs.build(config: config)
|
||||||
#expect(args.contains("-Y"))
|
XCTAssertTrue(args.contains("-Y"))
|
||||||
#expect(args.contains("l"))
|
XCTAssertTrue(args.contains("l"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Auto omits -c")
|
func testAutoPort() throws {
|
||||||
func autoPort() throws {
|
|
||||||
let config = ChartreadConfig(basename: "target")
|
let config = ChartreadConfig(basename: "target")
|
||||||
let args = try ChartreadArgs.build(config: config)
|
let args = try ChartreadArgs.build(config: config)
|
||||||
#expect(!args.contains("-c"))
|
XCTAssertFalse(args.contains("-c"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suite("ChartreadClassifier")
|
final class ChartreadClassifierTests: XCTestCase {
|
||||||
struct ChartreadClassifierTests {
|
|
||||||
|
|
||||||
@Test("Calibration prompt")
|
func testCalibration() {
|
||||||
func calibration() {
|
|
||||||
let r = ChartreadClassifier.classify(
|
let r = ChartreadClassifier.classify(
|
||||||
line: "Place instrument on calibration tile and hit [Space] to calibrate.",
|
line: "Place instrument on calibration tile and hit [Space] to calibrate.",
|
||||||
previousState: .idle
|
previousState: .idle
|
||||||
)
|
)
|
||||||
#expect(r.state == .calibrating)
|
XCTAssertEqual(r.state, .calibrating)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Strip awaiting")
|
func testAwaitingStrip() {
|
||||||
func awaitingStrip() {
|
|
||||||
let r = ChartreadClassifier.classify(
|
let r = ChartreadClassifier.classify(
|
||||||
line: "Hit [Space] to read strip A",
|
line: "Hit [Space] to read strip A",
|
||||||
previousState: .calibrating
|
previousState: .calibrating
|
||||||
)
|
)
|
||||||
#expect(r.state == .awaitingStrip)
|
XCTAssertEqual(r.state, .awaitingStrip)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Done prompt")
|
func testDone() {
|
||||||
func done() {
|
|
||||||
let r = ChartreadClassifier.classify(
|
let r = ChartreadClassifier.classify(
|
||||||
line: "'d' if/when done",
|
line: "'d' if/when done",
|
||||||
previousState: .awaitingStrip
|
previousState: .awaitingStrip
|
||||||
)
|
)
|
||||||
#expect(r.state == .allStripsRead)
|
XCTAssertEqual(r.state, .allStripsRead)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("XY place sheet")
|
func testPlaceSheet() {
|
||||||
func placeSheet() {
|
|
||||||
let r = ChartreadClassifier.classify(
|
let r = ChartreadClassifier.classify(
|
||||||
line: "Please place sheet 1 of 2 on the table",
|
line: "Please place sheet 1 of 2 on the table",
|
||||||
previousState: .idle
|
previousState: .idle
|
||||||
)
|
)
|
||||||
#expect(r.state == .tablePlaceSheet)
|
XCTAssertEqual(r.state, .tablePlaceSheet)
|
||||||
#expect(r.sheetNumber == 1)
|
XCTAssertEqual(r.sheetNumber, 1)
|
||||||
#expect(r.sheetTotal == 2)
|
XCTAssertEqual(r.sheetTotal, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("XY locate patch")
|
func testLocatePatch() {
|
||||||
func locatePatch() {
|
|
||||||
let r = ChartreadClassifier.classify(
|
let r = ChartreadClassifier.classify(
|
||||||
line: "locate patch A1 with the sight,",
|
line: "locate patch A1 with the sight,",
|
||||||
previousState: .tablePlaceSheet
|
previousState: .tablePlaceSheet
|
||||||
)
|
)
|
||||||
#expect(r.state == .tableAlign)
|
XCTAssertEqual(r.state, .tableAlign)
|
||||||
#expect(r.alignmentPatch == "A1")
|
XCTAssertEqual(r.alignmentPatch, "A1")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Remove sheet notice preserves state")
|
func testRemoveNotice() {
|
||||||
func removeNotice() {
|
|
||||||
let r = ChartreadClassifier.classify(
|
let r = ChartreadClassifier.classify(
|
||||||
line: "Please remove last sheet from table",
|
line: "Please remove last sheet from table",
|
||||||
previousState: .tablePlaceSheet
|
previousState: .tablePlaceSheet
|
||||||
)
|
)
|
||||||
#expect(r.state == .tablePlaceSheet)
|
XCTAssertEqual(r.state, .tablePlaceSheet)
|
||||||
#expect(r.isRemoveSheetNotice == true)
|
XCTAssertEqual(r.isRemoveSheetNotice, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suite("ChartreadRow")
|
final class ChartreadRowTests: XCTestCase {
|
||||||
struct ChartreadRowTests {
|
|
||||||
|
|
||||||
@Test("Decodes row JSON")
|
func testDecode() throws {
|
||||||
func decode() throws {
|
|
||||||
let json = """
|
let json = """
|
||||||
{"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2,
|
{"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2,
|
||||||
"patch_count": 1, "patches": [
|
"patch_count": 1, "patches": [
|
||||||
@@ -155,59 +137,93 @@ struct ChartreadRowTests {
|
|||||||
]}
|
]}
|
||||||
"""
|
"""
|
||||||
let row = try JSONDecoder().decode(ChartreadRow.self, from: Data(json.utf8))
|
let row = try JSONDecoder().decode(ChartreadRow.self, from: Data(json.utf8))
|
||||||
#expect(row.rowId == "A")
|
XCTAssertEqual(row.rowId, "A")
|
||||||
#expect(row.patchCount == 1)
|
XCTAssertEqual(row.patchCount, 1)
|
||||||
#expect(row.patches[0].measured.lab?.l == 51)
|
XCTAssertEqual(row.patches[0].measured.lab?.l, 51)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDecodeXYZAndLab() throws {
|
||||||
|
let json = """
|
||||||
|
{"event": "row_complete", "row_id": "B", "row_index": 1, "total_rows": 2,
|
||||||
|
"patch_count": 1, "patches": [
|
||||||
|
{"id": "7", "loc": "B7", "is_pad": false, "device": [10, 20, 30, 40],
|
||||||
|
"measured": {"XYZ": [30.5, 32.1, 25.9], "Lab": [63.4, 2.5, -8.2]}}
|
||||||
|
]}
|
||||||
|
"""
|
||||||
|
let row = try JSONDecoder().decode(ChartreadRow.self, from: Data(json.utf8))
|
||||||
|
let measured = row.patches[0].measured
|
||||||
|
XCTAssertEqual(measured.xyz, CIEXYZ(x: 30.5, y: 32.1, z: 25.9))
|
||||||
|
XCTAssertEqual(measured.lab, CIELab(l: 63.4, a: 2.5, b: -8.2))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testXyzWireEncoding() throws {
|
||||||
|
for color in [XYZColor(x: 1.5, y: 2.5, z: 3.5), CIEXYZ(x: 1.5, y: 2.5, z: 3.5)] {
|
||||||
|
let value = try JSONSerialization.jsonObject(
|
||||||
|
with: JSONEncoder().encode(color))
|
||||||
|
XCTAssertEqual(value as? [Double], [1.5, 2.5, 3.5])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLabWireEncoding() throws {
|
||||||
|
for color in [LabColor(l: 50, a: -1, b: 2), CIELab(l: 50, a: -1, b: 2)] {
|
||||||
|
let value = try JSONSerialization.jsonObject(
|
||||||
|
with: JSONEncoder().encode(color))
|
||||||
|
XCTAssertEqual(value as? [Double], [50, -1, 2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPatchColorKeys() throws {
|
||||||
|
let color = PatchColor(
|
||||||
|
xyz: CIEXYZ(x: 10, y: 20, z: 30),
|
||||||
|
lab: CIELab(l: 55, a: 1, b: -2))
|
||||||
|
let object = try JSONSerialization.jsonObject(
|
||||||
|
with: JSONEncoder().encode(color)) as? [String: Any]
|
||||||
|
XCTAssertEqual(object?["XYZ"] as? [Double], [10, 20, 30])
|
||||||
|
XCTAssertEqual(object?["Lab"] as? [Double], [55, 1, -2])
|
||||||
|
XCTAssertNil(object?["spectral"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suite("ColourMath")
|
final class ColourMathTests: XCTestCase {
|
||||||
struct ColourMathTests {
|
|
||||||
|
|
||||||
@Test("White XYZ to Lab")
|
func testWhiteLab() {
|
||||||
func whiteLab() {
|
|
||||||
let white = XYZColor(x: 96.4212, y: 100.0, z: 82.5188)
|
let white = XYZColor(x: 96.4212, y: 100.0, z: 82.5188)
|
||||||
let lab = LabColorMath.xyzToLab(white)
|
let lab = LabColorMath.xyzToLab(white)
|
||||||
#expect(abs(lab.l - 100) < 0.5)
|
XCTAssertTrue(abs(lab.l - 100) < 0.5)
|
||||||
#expect(abs(lab.a) < 0.5)
|
XCTAssertTrue(abs(lab.a) < 0.5)
|
||||||
#expect(abs(lab.b) < 0.5)
|
XCTAssertTrue(abs(lab.b) < 0.5)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Lab to sRGB roundtrip is clamped")
|
func testLabToSRGB() {
|
||||||
func labToSRGB() {
|
|
||||||
let red = LabColor(l: 55, a: 80, b: 70)
|
let red = LabColor(l: 55, a: 80, b: 70)
|
||||||
let rgb = LabColorMath.labToSRGB(red)
|
let rgb = LabColorMath.labToSRGB(red)
|
||||||
#expect(rgb.r > 0.8)
|
XCTAssertTrue(rgb.r > 0.8)
|
||||||
#expect(rgb.g < 0.2)
|
XCTAssertTrue(rgb.g < 0.2)
|
||||||
#expect(rgb.b < 0.2)
|
XCTAssertTrue(rgb.b < 0.2)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Pad white returns DisplayRGB")
|
func testPadWhite() {
|
||||||
func padWhite() {
|
|
||||||
let white = LabColor(l: 95, a: 0, b: 0)
|
let white = LabColor(l: 95, a: 0, b: 0)
|
||||||
let rgb = LabColorMath.labToSRGB(white)
|
let rgb = LabColorMath.labToSRGB(white)
|
||||||
#expect(rgb.r > 0.9)
|
XCTAssertTrue(rgb.r > 0.9)
|
||||||
#expect(rgb.g > 0.9)
|
XCTAssertTrue(rgb.g > 0.9)
|
||||||
#expect(rgb.b > 0.9)
|
XCTAssertTrue(rgb.b > 0.9)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Standard CIEDE2000 vector (Sharma)")
|
func testCiede2000() {
|
||||||
func ciede2000() {
|
|
||||||
let a = LabColor(l: 50, a: -1.3802, b: -84.2814)
|
let a = LabColor(l: 50, a: -1.3802, b: -84.2814)
|
||||||
let b = LabColor(l: 50, a: 0.0000, b: -82.7485)
|
let b = LabColor(l: 50, a: 0.0000, b: -82.7485)
|
||||||
#expect(abs(ColorDifference.deltaE00(a, b) - 1.00) < 0.001)
|
XCTAssertTrue(abs(ColorDifference.deltaE00(a, b) - 1.00) < 0.001)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Classification respects thresholds")
|
func testClassify() {
|
||||||
func classify() {
|
XCTAssertEqual(ColorDifference.classify(deltaE: 0.5, goodMax: 2.0, warningMax: 5.0), .good)
|
||||||
#expect(ColorDifference.classify(deltaE: 0.5, goodMax: 2.0, warningMax: 5.0) == .good)
|
XCTAssertEqual(ColorDifference.classify(deltaE: 3.0, goodMax: 2.0, warningMax: 5.0), .warning)
|
||||||
#expect(ColorDifference.classify(deltaE: 3.0, goodMax: 2.0, warningMax: 5.0) == .warning)
|
XCTAssertEqual(ColorDifference.classify(deltaE: 6.0, goodMax: 2.0, warningMax: 5.0), .bad)
|
||||||
#expect(ColorDifference.classify(deltaE: 6.0, goodMax: 2.0, warningMax: 5.0) == .bad)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suite("MeasurementArtefacts")
|
final class MeasurementArtefactTests: XCTestCase {
|
||||||
struct MeasurementArtefactTests {
|
|
||||||
|
|
||||||
private func makeCwd() throws -> URL {
|
private func makeCwd() throws -> URL {
|
||||||
let url = FileManager.default.temporaryDirectory
|
let url = FileManager.default.temporaryDirectory
|
||||||
@@ -216,8 +232,7 @@ struct MeasurementArtefactTests {
|
|||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Discovers passes in order")
|
func testDiscovery() throws {
|
||||||
func discovery() throws {
|
|
||||||
let cwd = try makeCwd()
|
let cwd = try makeCwd()
|
||||||
defer { try? FileManager.default.removeItem(at: cwd) }
|
defer { try? FileManager.default.removeItem(at: cwd) }
|
||||||
|
|
||||||
@@ -226,11 +241,10 @@ struct MeasurementArtefactTests {
|
|||||||
try "C".write(to: cwd.appendingPathComponent("target_pass10.ti3"), atomically: true, encoding: .utf8)
|
try "C".write(to: cwd.appendingPathComponent("target_pass10.ti3"), atomically: true, encoding: .utf8)
|
||||||
|
|
||||||
let passes = MeasurementArtefacts.passSnapshots(basename: "target", cwd: cwd)
|
let passes = MeasurementArtefacts.passSnapshots(basename: "target", cwd: cwd)
|
||||||
#expect(passes.map(\.lastPathComponent) == ["target_pass1.ti3", "target_pass3.ti3", "target_pass10.ti3"])
|
XCTAssertEqual(passes.map(\.lastPathComponent), ["target_pass1.ti3", "target_pass3.ti3", "target_pass10.ti3"])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Snapshot and promote are atomic")
|
func testSnapshotPromote() throws {
|
||||||
func snapshotPromote() throws {
|
|
||||||
let cwd = try makeCwd()
|
let cwd = try makeCwd()
|
||||||
defer { try? FileManager.default.removeItem(at: cwd) }
|
defer { try? FileManager.default.removeItem(at: cwd) }
|
||||||
|
|
||||||
@@ -238,16 +252,15 @@ struct MeasurementArtefactTests {
|
|||||||
try "canonical".write(to: canonical, atomically: true, encoding: .utf8)
|
try "canonical".write(to: canonical, atomically: true, encoding: .utf8)
|
||||||
|
|
||||||
let pass = try MeasurementArtefacts.snapshotPass(basename: "target", cwd: cwd)
|
let pass = try MeasurementArtefacts.snapshotPass(basename: "target", cwd: cwd)
|
||||||
#expect(pass.lastPathComponent == "target_pass1.ti3")
|
XCTAssertEqual(pass.lastPathComponent, "target_pass1.ti3")
|
||||||
#expect(!FileManager.default.fileExists(atPath: canonical.path))
|
XCTAssertFalse(FileManager.default.fileExists(atPath: canonical.path))
|
||||||
|
|
||||||
let promoted = try MeasurementArtefacts.promotePass(pass: pass, basename: "target", cwd: cwd)
|
let promoted = try MeasurementArtefacts.promotePass(pass: pass, basename: "target", cwd: cwd)
|
||||||
#expect(promoted.lastPathComponent == "target.ti3")
|
XCTAssertEqual(promoted.lastPathComponent, "target.ti3")
|
||||||
#expect(FileManager.default.fileExists(atPath: promoted.path))
|
XCTAssertTrue(FileManager.default.fileExists(atPath: promoted.path))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Pass collisions handled")
|
func testCollision() throws {
|
||||||
func collision() throws {
|
|
||||||
let cwd = try makeCwd()
|
let cwd = try makeCwd()
|
||||||
defer { try? FileManager.default.removeItem(at: cwd) }
|
defer { try? FileManager.default.removeItem(at: cwd) }
|
||||||
|
|
||||||
@@ -257,28 +270,25 @@ struct MeasurementArtefactTests {
|
|||||||
|
|
||||||
try "v2".write(to: canonical, atomically: true, encoding: .utf8)
|
try "v2".write(to: canonical, atomically: true, encoding: .utf8)
|
||||||
let pass2 = try MeasurementArtefacts.snapshotPass(basename: "target", cwd: cwd)
|
let pass2 = try MeasurementArtefacts.snapshotPass(basename: "target", cwd: cwd)
|
||||||
#expect(pass2.lastPathComponent == "target_pass2.ti3")
|
XCTAssertEqual(pass2.lastPathComponent, "target_pass2.ti3")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suite("AverageArgs")
|
final class AverageArgsTests: XCTestCase {
|
||||||
struct AverageArgsTests {
|
|
||||||
|
|
||||||
@Test("Requires at least two pass files")
|
func testPassCount() {
|
||||||
func passCount() {
|
|
||||||
let cwd = URL(fileURLWithPath: "/tmp")
|
let cwd = URL(fileURLWithPath: "/tmp")
|
||||||
let config = AverageConfig(
|
let config = AverageConfig(
|
||||||
workingDirectory: cwd,
|
workingDirectory: cwd,
|
||||||
basename: "target",
|
basename: "target",
|
||||||
passFiles: [URL(fileURLWithPath: "target_pass1.ti3")]
|
passFiles: [URL(fileURLWithPath: "target_pass1.ti3")]
|
||||||
)
|
)
|
||||||
#expect(throws: AverageArgError.self) {
|
XCTAssertThrowsError(try AverageArgs.build(config: config)) { error in
|
||||||
_ = try AverageArgs.build(config: config)
|
XCTAssertTrue(error is AverageArgError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Output is last and inputs are relative")
|
func testOrdering() throws {
|
||||||
func ordering() throws {
|
|
||||||
let cwd = URL(fileURLWithPath: "/tmp")
|
let cwd = URL(fileURLWithPath: "/tmp")
|
||||||
let config = AverageConfig(
|
let config = AverageConfig(
|
||||||
workingDirectory: cwd,
|
workingDirectory: cwd,
|
||||||
@@ -289,8 +299,8 @@ struct AverageArgsTests {
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
let args = try AverageArgs.build(config: config)
|
let args = try AverageArgs.build(config: config)
|
||||||
#expect(args.first == "-v")
|
XCTAssertEqual(args.first, "-v")
|
||||||
#expect(args.last == "target.ti3")
|
XCTAssertEqual(args.last, "target.ti3")
|
||||||
#expect(args == ["-v", "target_pass1.ti3", "target_pass2.ti3", "target.ti3"])
|
XCTAssertEqual(args, ["-v", "target_pass1.ti3", "target_pass2.ti3", "target.ti3"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user