Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d056c26bf | ||
|
|
c4f037535a | ||
|
|
191bcb0280 | ||
|
|
9c625ee21f | ||
|
|
40cc4ad701 | ||
|
|
658d9813bd | ||
|
|
368ae56cf3 | ||
|
|
bd539df009 | ||
|
|
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 | ||
|
|
d37dcedfd9 |
@@ -30,19 +30,26 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
echo "$line"
|
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
|
# Homebrew's xcodegen formula requires Xcode 15.3, which cannot be
|
||||||
# installed on macOS 12 (#109). The script installs a pinned
|
# installed on macOS 12 (#109). The script installs a pinned
|
||||||
# prebuilt release instead.
|
# prebuilt release instead. dmgbuild is not installed here — the
|
||||||
|
# test job does not package (#95).
|
||||||
- name: Ensure host tools
|
- name: Ensure host tools
|
||||||
|
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||||
run: scripts/ensure-host-tools.sh
|
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
|
||||||
|
|
||||||
# Tests only ever run on the runner's own architecture; build
|
# Tests only ever run on the runner's own architecture; build
|
||||||
# just that slice. Packaging (scripts/package-release.sh) still
|
# just that slice. Packaging (scripts/package-release.sh) still
|
||||||
# produces the universal Release binary.
|
# produces the universal Release binary.
|
||||||
- name: Build for testing (host arch)
|
- name: Build for testing (host arch)
|
||||||
|
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||||
run: |
|
run: |
|
||||||
xcodebuild build-for-testing \
|
xcodebuild build-for-testing \
|
||||||
-scheme ICCery \
|
-scheme ICCery \
|
||||||
@@ -60,6 +67,7 @@ jobs:
|
|||||||
# launch (run 31992) — so sign every embedded copy once the build is
|
# launch (run 31992) — so sign every embedded copy once the build is
|
||||||
# done (embed steps run after any build script phase) (#119).
|
# done (embed steps run after any build script phase) (#119).
|
||||||
- name: Sign package product frameworks
|
- name: Sign package product frameworks
|
||||||
|
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||||
run: |
|
run: |
|
||||||
find "$DERIVED/Build/Products/Debug" -depth -name '*_PackageProduct.framework' -print0 \
|
find "$DERIVED/Build/Products/Debug" -depth -name '*_PackageProduct.framework' -print0 \
|
||||||
| while IFS= read -r -d '' fw; do
|
| while IFS= read -r -d '' fw; do
|
||||||
@@ -68,6 +76,7 @@ jobs:
|
|||||||
done
|
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
|
||||||
@@ -90,6 +99,7 @@ jobs:
|
|||||||
# tag packaging can proceed. Real XCTest assertion failures
|
# tag packaging can proceed. Real XCTest assertion failures
|
||||||
# still fail the job.
|
# 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)"
|
||||||
@@ -154,7 +164,7 @@ jobs:
|
|||||||
# xcresult on failure — upload it so UI failures can be triaged
|
# xcresult on failure — upload it so UI failures can be triaged
|
||||||
# without access to the runner (#126).
|
# without access to the runner (#126).
|
||||||
- name: Prepare Node CA bundle (failure path)
|
- name: Prepare Node CA bundle (failure path)
|
||||||
if: failure()
|
if: "failure() && !(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||||
run: |
|
run: |
|
||||||
NODE_CA_FILE="/tmp/macos-ca-bundle.pem"
|
NODE_CA_FILE="/tmp/macos-ca-bundle.pem"
|
||||||
security find-certificate -a -p \
|
security find-certificate -a -p \
|
||||||
@@ -166,7 +176,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Upload UI test xcresult
|
- name: Upload UI test xcresult
|
||||||
if: failure()
|
if: "failure() && !(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
env:
|
env:
|
||||||
NODE_EXTRA_CA_CERTS: /tmp/macos-ca-bundle.pem
|
NODE_EXTRA_CA_CERTS: /tmp/macos-ca-bundle.pem
|
||||||
@@ -184,8 +194,10 @@ jobs:
|
|||||||
|
|
||||||
# scripts/package-release.sh runs `xcodegen generate` and dmgbuild;
|
# scripts/package-release.sh runs `xcodegen generate` and dmgbuild;
|
||||||
# see build-and-test for why brew is not used on macOS 12 (#109).
|
# 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
|
- name: Ensure host tools
|
||||||
run: scripts/ensure-host-tools.sh
|
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
|
||||||
|
|||||||
@@ -1,45 +1,75 @@
|
|||||||
# 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 | Pre-UAT hardening & baseline consolidation — shipped on `develop` |
|
| M7 | Pre-UAT hardening — shipped |
|
||||||
| M8 | Deduplication/consolidation contracts & UAT-ready hardening (#79–#86) — shipped on `develop` |
|
| M8 | Deduplication contracts & UAT-ready hardening (#79–#86) — shipped |
|
||||||
| M9 | macOS 12 / Xcode 14.2 retarget — shipped on `develop` (PR #145) |
|
| M9 | macOS 12 / Xcode 14.2 retarget (PR #145) — shipped |
|
||||||
| M10 | Studio workflow (#146–#149) — in flight on `milestone/m10-studio` |
|
| M10 | Studio workflow: gamut compare (#147), Spot Read (#148), project files (#149) shipped on `develop`; media library (#146) is in the tree, issue still open |
|
||||||
| Licence | Proprietary source in [`LICENCE.md`](LICENCE.md); bundled Argyll sidecars remain AGPLv3 |
|
| 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
|
||||||
|
|
||||||
@@ -49,7 +79,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
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -62,9 +92,14 @@ xcodebuild test -scheme ICCery \
|
|||||||
ARCHS="$(uname -m)"
|
ARCHS="$(uname -m)"
|
||||||
```
|
```
|
||||||
|
|
||||||
`project.yml` sets `ARCHS: "$(ARCHS_STANDARD)"`, so a plain `xcodebuild test` (and `make test`) builds universal; the `ARCHS="$(uname -m)"` override narrows it to the host slice.
|
`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.
|
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
|
||||||
@@ -74,9 +109,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
|
||||||
|
|
||||||
@@ -84,11 +121,20 @@ 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 `ICCery-${VERSION}-${BUILD_NUM}.dmg` with `dmgbuild==1.6.7`.
|
||||||
|
|
||||||
|
Sidecars stay ad-hoc signed inside the bundle — the app is never
|
||||||
|
`codesign --deep`ed.
|
||||||
|
|
||||||
|
`dmgbuild` is **not** a test-job dependency. The package job sets
|
||||||
|
`INSTALL_DMGBUILD=1` so `scripts/ensure-host-tools.sh` creates
|
||||||
|
`build/.venv-dmgbuild`. On the Monterey runner (Python 3.9) that install uses
|
||||||
|
`PIP_IGNORE_REQUIRES_PYTHON=1` and pins `pip>=24.3,<26.1` (pip 26.1+ needs
|
||||||
|
3.10). Missing background art is a hard fail (#95).
|
||||||
|
|
||||||
Environment variables read by the pipeline:
|
Environment variables read by the pipeline:
|
||||||
|
|
||||||
@@ -110,30 +156,47 @@ 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 (host arch)
|
# full suite (host arch) — same as CI
|
||||||
xcodebuild test -scheme ICCery \
|
xcodebuild test -scheme ICCery \
|
||||||
-destination 'platform=macOS' \
|
-destination 'platform=macOS' \
|
||||||
ARCHS="$(uname -m)"
|
ARCHS="$(uname -m)"
|
||||||
|
|
||||||
# to compile-check both slices instead:
|
# fat compile-check (not the default test path):
|
||||||
# ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
|
# ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
|
||||||
|
|
||||||
# examples
|
# examples
|
||||||
@@ -143,9 +206,20 @@ 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`) 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**.
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
@@ -156,11 +230,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 |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -168,29 +249,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/m10-studio # M10 integration branch
|
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. Completion PRs for issues #146–#149 target `milestone/m10-studio`; `milestone/m10-studio` merges into `develop` once all milestone gates pass. Do not open umbrella "bugfix" branches that mix tickets.
|
Open feature/fix PRs against **`develop`**. A `milestone/m…` integration
|
||||||
|
branch is used only while that milestone is assembling; `milestone/m10-studio`
|
||||||
|
has been merged and deleted. Do not open umbrella “bugfix” branches that mix
|
||||||
|
tickets.
|
||||||
|
|
||||||
|
`main` is push-protected and requires status check
|
||||||
|
`macOS CI / build-and-test (push)`. Protected **file** patterns on `main`
|
||||||
|
block PR merges that touch matching paths — do not set that field to `*`.
|
||||||
|
|
||||||
## Licence
|
## 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)
|
||||||
|
|||||||
@@ -97,6 +97,12 @@ enum UITestHooks {
|
|||||||
static var projectSaveURL: URL? { url("ICCERY_TEST_PROJECT_SAVE") }
|
static var projectSaveURL: URL? { url("ICCERY_TEST_PROJECT_SAVE") }
|
||||||
/// Relocate-folder result when a project's `cwd` is missing (#149).
|
/// Relocate-folder result when a project's `cwd` is missing (#149).
|
||||||
static var projectRelocateURL: URL? { url("ICCERY_TEST_PROJECT_RELOCATE") }
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -7,102 +7,135 @@ struct CalibrationView: View {
|
|||||||
@ObservedObject var wizard: WizardViewModel
|
@ObservedObject var wizard: WizardViewModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Text("Calibrate Printer")
|
ScrollView {
|
||||||
.font(.title2.bold())
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
.padding(.horizontal, 16)
|
Text("Calibrate Printer")
|
||||||
.padding(.top, 16)
|
.font(.title2.bold())
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
Form {
|
wedgeSection
|
||||||
Section("Wedge Settings") {
|
workflowSection
|
||||||
Picker("Colour Space", selection: $model.colourSpace) {
|
if !model.calibrationLog.isEmpty { logSection }
|
||||||
Text("RGB").tag(ColourSpace.rgb)
|
|
||||||
Text("CMYK").tag(ColourSpace.cmyk)
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack {
|
|
||||||
Text("Steps per channel")
|
|
||||||
Spacer()
|
|
||||||
TextField("", value: $model.steps, format: .number)
|
|
||||||
.frame(width: 60)
|
|
||||||
.accessibilityIdentifier("calSteps")
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack {
|
|
||||||
Text("White patches")
|
|
||||||
Spacer()
|
|
||||||
TextField("", value: $model.whitePatches, format: .number)
|
|
||||||
.frame(width: 60)
|
|
||||||
}
|
|
||||||
|
|
||||||
if model.colourSpace == .cmyk {
|
|
||||||
HStack {
|
|
||||||
Text("Ink-limit exploration")
|
|
||||||
Spacer()
|
|
||||||
TextField("", text: $model.inkLimit)
|
|
||||||
.frame(width: 60)
|
|
||||||
.accessibilityIdentifier("calInkExplore")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Toggle("Neutral emphasis", isOn: $model.includeNeutralEmphasis)
|
|
||||||
}
|
|
||||||
|
|
||||||
Section("Workflow") {
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
Button("Generate Target") { model.generateTarget() }
|
|
||||||
.accessibilityIdentifier("btnCalGenerate")
|
|
||||||
.disabled(wizard.basename.isEmpty
|
|
||||||
|| wizard.effectiveWorkingDirectory == nil
|
|
||||||
|| model.isGenerating)
|
|
||||||
|
|
||||||
Button("Create Layout & Print") { model.createLayout() }
|
|
||||||
.accessibilityIdentifier("btnCalLayout")
|
|
||||||
.disabled(wizard.basename.isEmpty
|
|
||||||
|| wizard.effectiveWorkingDirectory == nil
|
|
||||||
|| model.isGenerating)
|
|
||||||
|
|
||||||
Button("Measure") { model.measureChart() }
|
|
||||||
.accessibilityIdentifier("btnCalMeasure")
|
|
||||||
.disabled(model.calibrationTi3URL == nil)
|
|
||||||
|
|
||||||
Button("Compute Curves") { model.computeCurves() }
|
|
||||||
.accessibilityIdentifier("btnCalCompute")
|
|
||||||
.disabled(!model.canCompute)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let url = model.computedCalURL {
|
|
||||||
Toggle("Apply calibration to next profile", isOn: $model.applyToProfile)
|
|
||||||
.onChange(of: model.applyToProfile) { _ in model.updateApplyToProfile() }
|
|
||||||
.accessibilityIdentifier("calApplyToggle")
|
|
||||||
|
|
||||||
Text("Loaded: \(url.lastPathComponent)")
|
|
||||||
.font(.caption)
|
|
||||||
.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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.padding(20)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
.background(Theme.background)
|
||||||
|
|
||||||
|
Divider().overlay(Theme.border)
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
Button("Return to Profiling") { model.returnToProfiling() }
|
Button("Return to Profiling", role: .cancel) { model.returnToProfiling() }
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
.accessibilityIdentifier("btnCalReturn")
|
.accessibilityIdentifier("btnCalReturn")
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(16)
|
||||||
}
|
}
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityIdentifier("stage-cal")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Wedge settings
|
||||||
|
|
||||||
|
private var wedgeSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Wedge Settings").font(.headline).foregroundStyle(Theme.text)
|
||||||
|
Picker("Colour Space", selection: $model.colourSpace) {
|
||||||
|
Text("RGB").tag(ColourSpace.rgb)
|
||||||
|
Text("CMYK").tag(ColourSpace.cmyk)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.frame(maxWidth: 220)
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Text("Steps per channel")
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.frame(width: 140, alignment: .leading)
|
||||||
|
TextField("", value: $model.steps, format: .number)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: 70)
|
||||||
|
.accessibilityIdentifier("calSteps")
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Text("White patches")
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.frame(width: 140, alignment: .leading)
|
||||||
|
TextField("", value: $model.whitePatches, format: .number)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: 70)
|
||||||
|
}
|
||||||
|
|
||||||
|
if model.colourSpace == .cmyk {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Text("Ink-limit exploration")
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.frame(width: 140, alignment: .leading)
|
||||||
|
TextField("", text: $model.inkLimit)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: 70)
|
||||||
|
.accessibilityIdentifier("calInkExplore")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Toggle("Neutral emphasis", isOn: $model.includeNeutralEmphasis)
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.accessibilityIdentifier("calNeutralEmphasis")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Workflow
|
||||||
|
|
||||||
|
private var workflowSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Workflow").font(.headline).foregroundStyle(Theme.text)
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button("Generate Target") { model.generateTarget() }
|
||||||
|
.accessibilityIdentifier("btnCalGenerate")
|
||||||
|
.disabled(wizard.basename.isEmpty
|
||||||
|
|| wizard.effectiveWorkingDirectory == nil
|
||||||
|
|| model.isGenerating)
|
||||||
|
|
||||||
|
Button("Create Layout & Print") { model.createLayout() }
|
||||||
|
.accessibilityIdentifier("btnCalLayout")
|
||||||
|
.disabled(wizard.basename.isEmpty
|
||||||
|
|| wizard.effectiveWorkingDirectory == nil
|
||||||
|
|| model.isGenerating)
|
||||||
|
|
||||||
|
Button("Measure") { model.measureChart() }
|
||||||
|
.accessibilityIdentifier("btnCalMeasure")
|
||||||
|
.disabled(model.calibrationTi3URL == nil)
|
||||||
|
|
||||||
|
Button("Compute Curves") { model.computeCurves() }
|
||||||
|
.accessibilityIdentifier("btnCalCompute")
|
||||||
|
.disabled(!model.canCompute)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let url = model.computedCalURL {
|
||||||
|
Toggle("Apply calibration to next profile", isOn: $model.applyToProfile)
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.onChange(of: model.applyToProfile) { _ in model.updateApplyToProfile() }
|
||||||
|
.accessibilityIdentifier("calApplyToggle")
|
||||||
|
|
||||||
|
Text("Loaded: \(url.lastPathComponent)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Log
|
||||||
|
|
||||||
|
private var logSection: some View {
|
||||||
|
ProcessLogView(
|
||||||
|
lines: model.calibrationLog,
|
||||||
|
minHeight: 80,
|
||||||
|
maxHeight: 120,
|
||||||
|
containerId: "calLogContainer",
|
||||||
|
logId: "calLog"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ struct GamutView: View {
|
|||||||
@StateObject 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
|
@Binding var showingAllHelp: Bool
|
||||||
|
|
||||||
init(
|
init(
|
||||||
@@ -94,6 +95,8 @@ struct GamutView: View {
|
|||||||
Divider().overlay(Theme.border)
|
Divider().overlay(Theme.border)
|
||||||
statusLine
|
statusLine
|
||||||
inspectPanel
|
inspectPanel
|
||||||
|
Divider().overlay(Theme.border)
|
||||||
|
footer
|
||||||
}
|
}
|
||||||
.frame(minWidth: 720, minHeight: 520)
|
.frame(minWidth: 720, minHeight: 520)
|
||||||
.background(Theme.background)
|
.background(Theme.background)
|
||||||
@@ -142,8 +145,12 @@ struct GamutView: View {
|
|||||||
Text(layer?.displayName ?? fallback)
|
Text(layer?.displayName ?? fallback)
|
||||||
}
|
}
|
||||||
.toggleStyle(.checkbox)
|
.toggleStyle(.checkbox)
|
||||||
.disabled(layer == nil || viewModel.viewerUnavailable)
|
.disabled(layer == nil)
|
||||||
.help(layer.map { $0.sourceURL.lastPathComponent } ?? "No profile .gam loaded")
|
.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)")
|
.accessibilityIdentifier("gamutLayer-\(id)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,6 +325,21 @@ struct GamutView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// MARK: - TIFF sample sheet
|
||||||
|
|
||||||
private var tiffPreviewSheet: some View {
|
private var tiffPreviewSheet: some View {
|
||||||
@@ -377,7 +399,9 @@ private struct GamutSceneView: NSViewRepresentable {
|
|||||||
context.coordinator.installKeyMonitor()
|
context.coordinator.installKeyMonitor()
|
||||||
context.coordinator.installClickGesture()
|
context.coordinator.installClickGesture()
|
||||||
|
|
||||||
// No GPU → the docs/18 fallback; never respawn the view in a loop.
|
// 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 {
|
if MTLCreateSystemDefaultDevice() == nil {
|
||||||
DispatchQueue.main.async { onUnavailable() }
|
DispatchQueue.main.async { onUnavailable() }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,21 @@
|
|||||||
import Combine
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import ICCeryCore
|
import ICCeryCore
|
||||||
|
import Metal
|
||||||
import simd
|
import simd
|
||||||
|
|
||||||
|
/// Whether the SceneKit gamut scene can render on this host (#147).
|
||||||
|
///
|
||||||
|
/// Checked **before** `GamutSceneView` is mounted — constructing an
|
||||||
|
/// `SCNView` on a Metal-less machine can wedge the main thread, which
|
||||||
|
/// also stalls app quit behind the open sheet.
|
||||||
|
enum GamutSceneAvailability {
|
||||||
|
static var isAvailable: Bool {
|
||||||
|
if UITestHooks.skipSceneKit { return false }
|
||||||
|
return MTLCreateSystemDefaultDevice() != nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// View model for the native SceneKit gamut viewer (issues #28, #147).
|
/// View model for the native SceneKit gamut viewer (issues #28, #147).
|
||||||
///
|
///
|
||||||
/// Loads the bundled `sRGB.gam` reference immediately, the workflow's own
|
/// Loads the bundled `sRGB.gam` reference immediately, the workflow's own
|
||||||
@@ -73,6 +86,8 @@ final class GamutViewModel: ObservableObject {
|
|||||||
init(environment: AppEnvironment, profileGamURL: URL? = nil) {
|
init(environment: AppEnvironment, profileGamURL: URL? = nil) {
|
||||||
self.environment = environment
|
self.environment = environment
|
||||||
self.profileGamURL = profileGamURL
|
self.profileGamURL = profileGamURL
|
||||||
|
// Never let the view mount an SCNView without Metal (#147).
|
||||||
|
viewerUnavailable = !GamutSceneAvailability.isAvailable
|
||||||
loadTask = Task { await load() }
|
loadTask = Task { await load() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,9 +46,11 @@ struct SaveMediaRecipeDialog: View {
|
|||||||
|| media.captureColourSpaceMismatch
|
|| media.captureColourSpaceMismatch
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
// Swift 5.7 (Xcode 14.2 CI runner) caps a ViewBuilder body at 10
|
||||||
VStack(alignment: .leading, spacing: 14) {
|
// children (#146); Group blocks are layout-transparent, so field
|
||||||
Text("Save Media Recipe").font(.title3).foregroundStyle(Theme.text)
|
// order and every docs/21 id are unchanged.
|
||||||
|
private var fields: some View {
|
||||||
|
Group {
|
||||||
TextField("Name", text: $media.saveMediaName)
|
TextField("Name", text: $media.saveMediaName)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
.accessibilityIdentifier("saveMediaName")
|
.accessibilityIdentifier("saveMediaName")
|
||||||
@@ -61,7 +63,11 @@ struct SaveMediaRecipeDialog: View {
|
|||||||
TextField("Ink set", text: $media.saveMediaInk)
|
TextField("Ink set", text: $media.saveMediaInk)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
.accessibilityIdentifier("saveMediaInk")
|
.accessibilityIdentifier("saveMediaInk")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var readOnlyRows: some View {
|
||||||
|
Group {
|
||||||
captureRow("Printer", value: printerCaption,
|
captureRow("Printer", value: printerCaption,
|
||||||
identifier: "saveMediaPrinter")
|
identifier: "saveMediaPrinter")
|
||||||
captureRow("Preset",
|
captureRow("Preset",
|
||||||
@@ -74,6 +80,14 @@ struct SaveMediaRecipeDialog: View {
|
|||||||
value: workflow.profile.calibrationFile.isEmpty
|
value: workflow.profile.calibrationFile.isEmpty
|
||||||
? "None" : workflow.profile.calibrationFile,
|
? "None" : workflow.profile.calibrationFile,
|
||||||
identifier: "saveMediaCal")
|
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",
|
Toggle("Apply calibration to profile",
|
||||||
isOn: $media.saveMediaApplyCal)
|
isOn: $media.saveMediaApplyCal)
|
||||||
.disabled(!media.calApplyable)
|
.disabled(!media.calApplyable)
|
||||||
@@ -206,6 +220,15 @@ struct ManageMediaDialog: View {
|
|||||||
.accessibilityIdentifier("mediaLibraryList")
|
.accessibilityIdentifier("mediaLibraryList")
|
||||||
.frame(minHeight: 260)
|
.frame(minHeight: 260)
|
||||||
|
|
||||||
|
if let notice = media.manageApplyNotice {
|
||||||
|
Text(notice)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.accessibilityIdentifier("manageMediaNotice")
|
||||||
|
.accessibilityValue(notice)
|
||||||
|
}
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Button("Apply selected") {
|
Button("Apply selected") {
|
||||||
if let id = selection,
|
if let id = selection,
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ final class MediaLibraryViewModel: ObservableObject {
|
|||||||
@Published var saveMediaApplyCal = false
|
@Published var saveMediaApplyCal = false
|
||||||
/// Inline caption inside the capture sheet (no a11y id — roster complete).
|
/// Inline caption inside the capture sheet (no a11y id — roster complete).
|
||||||
@Published var saveMediaError: String?
|
@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
|
/// Pure flow flag — the manage sheet's "Capture current…" asks the
|
||||||
/// sheet's `onDismiss` to open the capture sheet, avoiding a
|
/// sheet's `onDismiss` to open the capture sheet, avoiding a
|
||||||
@@ -120,24 +123,21 @@ final class MediaLibraryViewModel: ObservableObject {
|
|||||||
/// with warning; the refusal is permanent so re-clicking can't help).
|
/// with warning; the refusal is permanent so re-clicking can't help).
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func apply(_ recipe: MediaRecipe) async -> Bool {
|
func apply(_ recipe: MediaRecipe) async -> Bool {
|
||||||
|
manageApplyNotice = nil
|
||||||
guard let r = try? recipe.validated() else {
|
guard let r = try? recipe.validated() else {
|
||||||
workflow.wizard.showNotice(
|
return failApply("Media recipe is invalid — not applied.", kind: .error)
|
||||||
"Media recipe is invalid — not applied.", kind: .error)
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
guard let preset = environment.presetStore.all()
|
guard let preset = environment.presetStore.all()
|
||||||
.first(where: { $0.id == r.presetID })
|
.first(where: { $0.id == r.presetID })
|
||||||
else {
|
else {
|
||||||
workflow.wizard.showNotice(
|
return failApply(
|
||||||
"Preset \(r.presetID) no longer exists — recipe not applied.",
|
"Preset \(r.presetID) no longer exists — recipe not applied.",
|
||||||
kind: .error)
|
kind: .error)
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
guard preset.colourSpace.lowercased() == r.colourSpace.lowercased() else {
|
guard preset.colourSpace.lowercased() == r.colourSpace.lowercased() else {
|
||||||
workflow.wizard.showNotice(
|
return failApply(
|
||||||
"Recipe colour space does not match its preset — not applied.",
|
"Recipe colour space does not match its preset — not applied.",
|
||||||
kind: .error)
|
kind: .error)
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Existing #82 mapping: presetSelect jumps, Stage 1/2/4 fields.
|
// Existing #82 mapping: presetSelect jumps, Stage 1/2/4 fields.
|
||||||
@@ -145,8 +145,6 @@ final class MediaLibraryViewModel: ObservableObject {
|
|||||||
// Literal per issue: displayName, not the queue id.
|
// Literal per issue: displayName, not the queue id.
|
||||||
workflow.wizard.printerName = r.printerDisplayName
|
workflow.wizard.printerName = r.printerDisplayName
|
||||||
|
|
||||||
var succeeded = true
|
|
||||||
|
|
||||||
// Queue: enumerate fresh via the session's serialized path —
|
// Queue: enumerate fresh via the session's serialized path —
|
||||||
// listPrinters uses fixed process ids, so an overlapping
|
// listPrinters uses fixed process ids, so an overlapping
|
||||||
// enumeration would throw duplicateID. An empty result is a
|
// enumeration would throw duplicateID. An empty result is a
|
||||||
@@ -156,16 +154,14 @@ final class MediaLibraryViewModel: ObservableObject {
|
|||||||
workflow.print.selectedPrinter = r.printerID
|
workflow.print.selectedPrinter = r.printerID
|
||||||
await workflow.print.reloadSelectedCapabilities()
|
await workflow.print.reloadSelectedCapabilities()
|
||||||
} else {
|
} else {
|
||||||
workflow.wizard.showNotice(
|
return failApply(
|
||||||
"Printer \(r.printerDisplayName) is not installed.",
|
"Printer \(r.printerDisplayName) is not installed.",
|
||||||
kind: .warning)
|
kind: .warning)
|
||||||
succeeded = false
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
workflow.wizard.showNotice(
|
return failApply(
|
||||||
"Could not enumerate printers — queue left unchanged.",
|
"Could not enumerate printers — queue left unchanged.",
|
||||||
kind: .warning)
|
kind: .warning)
|
||||||
succeeded = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calibration — the recipe is authoritative and runs after
|
// Calibration — the recipe is authoritative and runs after
|
||||||
@@ -195,10 +191,8 @@ final class MediaLibraryViewModel: ObservableObject {
|
|||||||
guard FileManager.default.fileExists(atPath: calPath) else {
|
guard FileManager.default.fileExists(atPath: calPath) else {
|
||||||
workflow.profile.applyCalibration = false
|
workflow.profile.applyCalibration = false
|
||||||
workflow.profile.calibrationFile = calPath
|
workflow.profile.calibrationFile = calPath
|
||||||
workflow.wizard.showNotice(
|
return failApply(
|
||||||
"Calibration file is missing: \(calPath)", kind: .error)
|
"Calibration file is missing: \(calPath)", kind: .error)
|
||||||
refreshStaleness()
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
let staleDays = environment.settingsStore.load().calibrationStaleDays
|
let staleDays = environment.settingsStore.load().calibrationStaleDays
|
||||||
@@ -215,23 +209,26 @@ final class MediaLibraryViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
workflow.profile.applyCalibration = false
|
workflow.profile.applyCalibration = false
|
||||||
workflow.wizard.showNotice(
|
return failApply(
|
||||||
"Could not load calibration: \(error.localizedDescription)",
|
"Could not load calibration: \(error.localizedDescription)",
|
||||||
kind: .error)
|
kind: .error)
|
||||||
refreshStaleness()
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
workflow.profile.applyCalibration = false
|
workflow.profile.applyCalibration = false
|
||||||
workflow.profile.calibrationFile = calPath
|
workflow.profile.calibrationFile = calPath
|
||||||
}
|
}
|
||||||
|
|
||||||
if succeeded {
|
selectedRecipeID = r.id
|
||||||
selectedRecipeID = r.id
|
workflow.wizard.showNotice("Applied \(r.name)")
|
||||||
workflow.wizard.showNotice("Applied \(r.name)")
|
|
||||||
}
|
|
||||||
refreshStaleness()
|
refreshStaleness()
|
||||||
return succeeded
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func failApply(_ text: String, kind: Notice.Kind) -> Bool {
|
||||||
|
manageApplyNotice = text
|
||||||
|
workflow.wizard.showNotice(text, kind: kind)
|
||||||
|
refreshStaleness()
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Capture
|
// MARK: - Capture
|
||||||
|
|||||||
@@ -39,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)
|
||||||
|
|||||||
@@ -69,22 +69,24 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Section("Verification") {
|
Section("Verification") {
|
||||||
HStack {
|
TextField(
|
||||||
Text("Good ΔE ≤")
|
"Good ΔE ≤",
|
||||||
TextField(
|
value: $model.settings.deltaEGoodMax,
|
||||||
"2.0",
|
format: .number
|
||||||
value: $model.settings.deltaEGoodMax,
|
)
|
||||||
format: .number
|
.accessibilityIdentifier("settingsDeltaEGood")
|
||||||
)
|
|
||||||
.frame(width: 60)
|
TextField(
|
||||||
Text("Warning ΔE ≤")
|
"Warning ΔE ≤",
|
||||||
TextField(
|
value: $model.settings.deltaEWarningMax,
|
||||||
"5.0",
|
format: .number
|
||||||
value: $model.settings.deltaEWarningMax,
|
)
|
||||||
format: .number
|
.accessibilityIdentifier("settingsDeltaEWarning")
|
||||||
)
|
|
||||||
.frame(width: 60)
|
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 {
|
TextField(
|
||||||
Text("Stale after")
|
"Stale after (days)",
|
||||||
TextField(
|
value: $model.settings.calibrationStaleDays,
|
||||||
"30",
|
format: .number
|
||||||
value: $model.settings.calibrationStaleDays,
|
)
|
||||||
format: .number
|
.accessibilityIdentifier("settingsCalStaleDays")
|
||||||
)
|
|
||||||
.frame(width: 60)
|
|
||||||
Text("days")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Profile install") {
|
Section("Profile install") {
|
||||||
@@ -143,6 +141,7 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding(.leading, 45)
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,21 @@ struct SidebarView: View {
|
|||||||
|
|
||||||
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()
|
||||||
@@ -65,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(
|
||||||
@@ -97,7 +116,11 @@ 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
|
// Media library (`#mediaSelect`) — issue #146. Selection
|
||||||
// applies the recipe immediately, like presets; names render
|
// applies the recipe immediately, like presets; names render
|
||||||
// via Text only (#114). Never reuses `presetSelect` (#137).
|
// via Text only (#114). Never reuses `presetSelect` (#137).
|
||||||
@@ -147,7 +170,11 @@ struct SidebarView: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.bottom, 8)
|
.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")
|
||||||
@@ -187,7 +214,11 @@ struct SidebarView: View {
|
|||||||
showing: $showingAllHelp)
|
showing: $showingAllHelp)
|
||||||
.accessibilityIdentifier("btnSpotRead")
|
.accessibilityIdentifier("btnSpotRead")
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var stepperAndProject: some View {
|
||||||
|
Group {
|
||||||
Divider().overlay(Theme.border)
|
Divider().overlay(Theme.border)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
|
|
||||||
@@ -214,8 +245,6 @@ struct SidebarView: View {
|
|||||||
ProjectChip(project: project, showingAllHelp: $showingAllHelp)
|
ProjectChip(project: project, showingAllHelp: $showingAllHelp)
|
||||||
.padding(8)
|
.padding(8)
|
||||||
}
|
}
|
||||||
.frame(width: Theme.Metrics.sidebarWidth)
|
|
||||||
.background(Theme.panel)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import Metal
|
||||||
import XCTest
|
import XCTest
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
@testable import ICCery
|
@testable import ICCery
|
||||||
@@ -36,6 +37,15 @@ final class GamutViewModelTests: XCTestCase {
|
|||||||
XCTAssertTrue(vm.status.contains("faces"), "status: \(vm.status)")
|
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 {
|
func testMissingCompareGamLeavesSRGBAndSetsNotice() async throws {
|
||||||
let vm = try makeViewModel()
|
let vm = try makeViewModel()
|
||||||
await vm.awaitInitialLoad()
|
await vm.awaitInitialLoad()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import Metal
|
||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
/// Milestone 10 — Issue #147 gamut compare chrome tests.
|
/// Milestone 10 — Issue #147 gamut compare chrome tests.
|
||||||
@@ -9,6 +10,11 @@ import XCTest
|
|||||||
@MainActor
|
@MainActor
|
||||||
final class Milestone10GamutCompareUITests: XCTestCase {
|
final class Milestone10GamutCompareUITests: XCTestCase {
|
||||||
|
|
||||||
|
/// Metal on the test host — the app under test runs on the same
|
||||||
|
/// machine, so this predicts whether the sheet mounts SceneKit.
|
||||||
|
/// GPU-less runners still get the banner/Close assertions (#147).
|
||||||
|
private var hasGPU: Bool { MTLCreateSystemDefaultDevice() != nil }
|
||||||
|
|
||||||
private var app: XCUIApplication!
|
private var app: XCUIApplication!
|
||||||
private var testRoot: URL!
|
private var testRoot: URL!
|
||||||
private var binDir: URL!
|
private var binDir: URL!
|
||||||
@@ -46,6 +52,10 @@ final class Milestone10GamutCompareUITests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override func tearDown() async throws {
|
override func tearDown() async throws {
|
||||||
|
// Never leave the gamut sheet up for `terminate()` (#147).
|
||||||
|
if app != nil, element("btnCloseGamut").exists {
|
||||||
|
element("btnCloseGamut").click()
|
||||||
|
}
|
||||||
app?.terminate()
|
app?.terminate()
|
||||||
app = nil
|
app = nil
|
||||||
if let testRoot {
|
if let testRoot {
|
||||||
@@ -75,6 +85,22 @@ final class Milestone10GamutCompareUITests: XCTestCase {
|
|||||||
return el
|
return el
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Exists **and** `isEnabled`. Layer toggles render as disabled
|
||||||
|
/// placeholders until the async layer load lands — on the macOS 12
|
||||||
|
/// runner `waitFor` alone wins the race against `parse`.
|
||||||
|
private func waitUntilEnabled(_ id: String, timeout: TimeInterval = 15) -> XCUIElement {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
let el = element(id)
|
||||||
|
if el.exists && el.isEnabled { return el }
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
|
}
|
||||||
|
let el = element(id)
|
||||||
|
XCTAssertTrue(
|
||||||
|
el.exists && el.isEnabled, "Expected enabled element \(id)")
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
|
||||||
private func launchApp() {
|
private func launchApp() {
|
||||||
app.launch()
|
app.launch()
|
||||||
if !app.wait(for: .runningForeground, timeout: 10) {
|
if !app.wait(for: .runningForeground, timeout: 10) {
|
||||||
@@ -88,18 +114,41 @@ final class Milestone10GamutCompareUITests: XCTestCase {
|
|||||||
_ = waitFor("gamutView")
|
_ = waitFor("gamutView")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Inverse of `waitFor` — polls until the element leaves the tree.
|
||||||
|
private func waitForGone(_ id: String, timeout: TimeInterval = 10) {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
if !element(id).exists { return }
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
|
}
|
||||||
|
XCTAssertFalse(element(id).exists, "Expected element \(id) to disappear")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `btnCloseGamut` dismisses the sheet so `tearDown`'s `terminate()`
|
||||||
|
/// is not stuck behind a key sheet (#147). No-op when already closed.
|
||||||
|
private func closeGamutSheet() {
|
||||||
|
let close = element("btnCloseGamut")
|
||||||
|
guard close.waitForExistence(timeout: 5) else { return }
|
||||||
|
close.click()
|
||||||
|
waitForGone("gamutView")
|
||||||
|
}
|
||||||
|
|
||||||
func testLayerTogglesExistWithSRGB() throws {
|
func testLayerTogglesExistWithSRGB() throws {
|
||||||
openGamutSheet()
|
openGamutSheet()
|
||||||
|
|
||||||
let srgb = waitFor("gamutLayer-sRGB")
|
let srgb = waitUntilEnabled("gamutLayer-sRGB")
|
||||||
XCTAssertTrue(srgb.exists)
|
XCTAssertTrue(srgb.exists)
|
||||||
XCTAssertTrue(srgb.isEnabled)
|
|
||||||
// NSButton checkbox value is 1 when checked.
|
// NSButton checkbox value is 1 when checked.
|
||||||
XCTAssertEqual(srgb.value as? Int, 1, "sRGB layer should be on")
|
XCTAssertEqual(srgb.value as? Int, 1, "sRGB layer should be on")
|
||||||
|
|
||||||
let compare = waitFor("gamutLayer-compare")
|
let compare = waitFor("gamutLayer-compare")
|
||||||
XCTAssertTrue(compare.exists)
|
XCTAssertTrue(compare.exists)
|
||||||
XCTAssertFalse(compare.isEnabled, "Compare toggle must be disabled before a load")
|
XCTAssertFalse(compare.isEnabled, "Compare toggle must be disabled before a load")
|
||||||
|
|
||||||
|
let status = waitFor("gamutStatusText")
|
||||||
|
let statusValue = status.value as? String ?? ""
|
||||||
|
XCTAssertTrue(statusValue.contains("sRGB"), "Status should list the sRGB layer, got: \(statusValue)")
|
||||||
|
closeGamutSheet()
|
||||||
}
|
}
|
||||||
|
|
||||||
func testAddCompareButtonExists() throws {
|
func testAddCompareButtonExists() throws {
|
||||||
@@ -113,6 +162,7 @@ final class Milestone10GamutCompareUITests: XCTestCase {
|
|||||||
let status = waitFor("gamutStatusText")
|
let status = waitFor("gamutStatusText")
|
||||||
let value = status.value as? String ?? ""
|
let value = status.value as? String ?? ""
|
||||||
XCTAssertTrue(value.contains("sRGB"), "Status should keep the sRGB clause, got: \(value)")
|
XCTAssertTrue(value.contains("sRGB"), "Status should keep the sRGB clause, got: \(value)")
|
||||||
|
closeGamutSheet()
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCompareGamLoadEnablesToggle() throws {
|
func testCompareGamLoadEnablesToggle() throws {
|
||||||
@@ -124,8 +174,10 @@ final class Milestone10GamutCompareUITests: XCTestCase {
|
|||||||
waitFor("btnGamutAddCompare").click()
|
waitFor("btnGamutAddCompare").click()
|
||||||
waitFor("btnGamutOpenGam").click()
|
waitFor("btnGamutOpenGam").click()
|
||||||
|
|
||||||
let compare = waitFor("gamutLayer-compare")
|
// The pre-load placeholder also exists — wait for enabled.
|
||||||
|
let compare = waitUntilEnabled("gamutLayer-compare")
|
||||||
XCTAssertTrue(compare.isEnabled, "Compare toggle should enable after load")
|
XCTAssertTrue(compare.isEnabled, "Compare toggle should enable after load")
|
||||||
|
XCTAssertEqual(compare.value as? Int, 1, "Compare layer should be on after load")
|
||||||
|
|
||||||
let status = waitFor("gamutStatusText")
|
let status = waitFor("gamutStatusText")
|
||||||
let value = status.value as? String ?? ""
|
let value = status.value as? String ?? ""
|
||||||
@@ -133,6 +185,7 @@ final class Milestone10GamutCompareUITests: XCTestCase {
|
|||||||
|
|
||||||
let remove = waitFor("btnGamutRemoveCompare")
|
let remove = waitFor("btnGamutRemoveCompare")
|
||||||
XCTAssertTrue(remove.isEnabled)
|
XCTAssertTrue(remove.isEnabled)
|
||||||
|
closeGamutSheet()
|
||||||
}
|
}
|
||||||
|
|
||||||
func testOpenProfileRunsIccgamutForCompare() throws {
|
func testOpenProfileRunsIccgamutForCompare() throws {
|
||||||
@@ -146,8 +199,15 @@ final class Milestone10GamutCompareUITests: XCTestCase {
|
|||||||
waitFor("btnGamutAddCompare").click()
|
waitFor("btnGamutAddCompare").click()
|
||||||
waitFor("btnGamutOpenProfile").click()
|
waitFor("btnGamutOpenProfile").click()
|
||||||
|
|
||||||
let compare = waitFor("gamutLayer-compare")
|
let compare = waitUntilEnabled("gamutLayer-compare")
|
||||||
XCTAssertTrue(compare.isEnabled, "Compare toggle should enable after iccgamut")
|
XCTAssertTrue(compare.isEnabled, "Compare toggle should enable after iccgamut")
|
||||||
|
XCTAssertEqual(compare.value as? Int, 1, "Compare layer should be on after iccgamut")
|
||||||
|
|
||||||
|
// The compare slot's display name is the .gam stem ("myprinter").
|
||||||
|
let status = waitFor("gamutStatusText")
|
||||||
|
let statusValue = status.value as? String ?? ""
|
||||||
|
XCTAssertTrue(statusValue.contains("myprinter"), "Status should list the compare layer, got: \(statusValue)")
|
||||||
|
closeGamutSheet()
|
||||||
}
|
}
|
||||||
|
|
||||||
func testInspectPanelIdleStableHeight() throws {
|
func testInspectPanelIdleStableHeight() throws {
|
||||||
@@ -157,6 +217,7 @@ final class Milestone10GamutCompareUITests: XCTestCase {
|
|||||||
XCTAssertTrue(panel.exists)
|
XCTAssertTrue(panel.exists)
|
||||||
XCTAssertTrue(element("gamutInspectIdle").exists)
|
XCTAssertTrue(element("gamutInspectIdle").exists)
|
||||||
XCTAssertTrue(element("gamutStatusText").exists)
|
XCTAssertTrue(element("gamutStatusText").exists)
|
||||||
|
closeGamutSheet()
|
||||||
}
|
}
|
||||||
|
|
||||||
func testManualLabInspectShowsContainment() throws {
|
func testManualLabInspectShowsContainment() throws {
|
||||||
@@ -176,11 +237,55 @@ final class Milestone10GamutCompareUITests: XCTestCase {
|
|||||||
XCTAssertTrue(value.contains("in"), "Lab(50,0,0) should be inside sRGB, got: \(value)")
|
XCTAssertTrue(value.contains("in"), "Lab(50,0,0) should be inside sRGB, got: \(value)")
|
||||||
XCTAssertTrue(element("gamutInspectL").exists)
|
XCTAssertTrue(element("gamutInspectL").exists)
|
||||||
XCTAssertTrue(element("gamutInspectSwatch").exists)
|
XCTAssertTrue(element("gamutInspectSwatch").exists)
|
||||||
|
closeGamutSheet()
|
||||||
}
|
}
|
||||||
|
|
||||||
func testResetIdentifierUnchanged() throws {
|
func testResetIdentifierUnchanged() throws {
|
||||||
openGamutSheet()
|
openGamutSheet()
|
||||||
let reset = waitFor("btnResetGamutCamera")
|
let reset = waitFor("btnResetGamutCamera")
|
||||||
XCTAssertTrue(reset.isEnabled)
|
XCTAssertTrue(reset.isEnabled)
|
||||||
|
closeGamutSheet()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `btnCloseGamut` is always enabled — including on the fallback
|
||||||
|
/// banner — and dismisses the sheet (#147).
|
||||||
|
func testCloseButtonDismissesSheet() throws {
|
||||||
|
openGamutSheet()
|
||||||
|
|
||||||
|
let close = waitFor("btnCloseGamut")
|
||||||
|
XCTAssertTrue(close.isEnabled)
|
||||||
|
close.click()
|
||||||
|
waitForGone("gamutView")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fallback banner exists exactly when the host lacks Metal —
|
||||||
|
/// no `SCNView` is mounted on a GPU-less runner, and none may be
|
||||||
|
/// reported unavailable on a GPU host.
|
||||||
|
func testFallbackBannerMatchesGPUAvailability() throws {
|
||||||
|
openGamutSheet()
|
||||||
|
|
||||||
|
if hasGPU {
|
||||||
|
XCTAssertFalse(
|
||||||
|
element("gamutViewerUnavailable").exists,
|
||||||
|
"GPU host must mount the SceneKit view, not the fallback")
|
||||||
|
} else {
|
||||||
|
_ = waitFor("gamutViewerUnavailable")
|
||||||
|
}
|
||||||
|
closeGamutSheet()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `ICCERY_TEST_SKIP_SCENEKIT=1` forces the fallback even on a GPU
|
||||||
|
/// host — banner plus a working Close, no `SCNView` mounted (#147).
|
||||||
|
/// The env is set for this test only; the default launch env must
|
||||||
|
/// not carry it, or CI's future GPU run would skip SceneKit too.
|
||||||
|
func testForcedSceneKitSkipShowsBannerAndClose() throws {
|
||||||
|
app.launchEnvironment["ICCERY_TEST_SKIP_SCENEKIT"] = "1"
|
||||||
|
openGamutSheet()
|
||||||
|
|
||||||
|
_ = waitFor("gamutViewerUnavailable")
|
||||||
|
let close = waitFor("btnCloseGamut")
|
||||||
|
XCTAssertTrue(close.isEnabled)
|
||||||
|
close.click()
|
||||||
|
waitForGone("gamutView")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,7 +148,9 @@ final class Milestone10MediaLibraryUITests: XCTestCase {
|
|||||||
XCTAssertTrue(apply.waitForExistence(timeout: 10))
|
XCTAssertTrue(apply.waitForExistence(timeout: 10))
|
||||||
apply.click()
|
apply.click()
|
||||||
|
|
||||||
let notice = waitFor("noticeText")
|
// The window banner (`noticeText`) sits behind this sheet on
|
||||||
|
// Monterey (#170). Assert the in-sheet copy instead.
|
||||||
|
let notice = waitFor("manageMediaNotice", timeout: 15)
|
||||||
let text = (notice.value as? String) ?? notice.label
|
let text = (notice.value as? String) ?? notice.label
|
||||||
XCTAssertTrue(
|
XCTAssertTrue(
|
||||||
text.contains("is not installed"),
|
text.contains("is not installed"),
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import XCTest
|
|||||||
/// Milestone 10 UI tests — issue #149 project file. Panels are never
|
/// Milestone 10 UI tests — issue #149 project file. Panels are never
|
||||||
/// real: `ICCERY_TEST_PROJECT_OPEN` / `ICCERY_TEST_PROJECT_SAVE`
|
/// real: `ICCERY_TEST_PROJECT_OPEN` / `ICCERY_TEST_PROJECT_SAVE`
|
||||||
/// inject fixture paths through `UITestHooks`. Menu commands are driven
|
/// inject fixture paths through `UITestHooks`. Menu commands are driven
|
||||||
/// by their keyboard shortcuts (⌘N) or the sidebar chip so the tests do
|
/// through the File menu when it is in the AX tree, else by their
|
||||||
/// not depend on menu AX exposure (R19). All queries by identifier.
|
/// keyboard shortcuts (⌘N) — the tests do not depend on menu AX
|
||||||
|
/// exposure (R19). All queries by identifier.
|
||||||
@MainActor
|
@MainActor
|
||||||
final class Milestone10ProjectUITests: XCTestCase {
|
final class Milestone10ProjectUITests: XCTestCase {
|
||||||
|
|
||||||
@@ -77,6 +78,39 @@ final class Milestone10ProjectUITests: XCTestCase {
|
|||||||
return (el.value as? String) ?? el.label
|
return (el.value as? String) ?? el.label
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Alert/sheet button by visible title, falling back to the a11y
|
||||||
|
/// id; nil when neither matches. macOS 12 SwiftUI alerts often
|
||||||
|
/// drop `accessibilityIdentifier` on their buttons, so the title
|
||||||
|
/// is the reliable handle there.
|
||||||
|
private func alertButton(title: String, id: String) -> XCUIElement? {
|
||||||
|
let inDialog = app.dialogs.firstMatch.buttons[title].firstMatch
|
||||||
|
if inDialog.exists { return inDialog }
|
||||||
|
let inSheet = app.sheets.firstMatch.buttons[title].firstMatch
|
||||||
|
if inSheet.exists { return inSheet }
|
||||||
|
let byId = element(id)
|
||||||
|
return byId.exists ? byId : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fires File ▸ New Project via the menu when it is in the AX
|
||||||
|
/// tree, else ⌘N. On macOS 12 `typeKey` may not reach the
|
||||||
|
/// `CommandGroup`, and menu item ids are unreliable — the menu
|
||||||
|
/// item is matched by its "New Project" label first.
|
||||||
|
private func triggerNewProject() {
|
||||||
|
let fileMenu = app.menuBarItems["File"]
|
||||||
|
if fileMenu.waitForExistence(timeout: 5) {
|
||||||
|
fileMenu.click()
|
||||||
|
let byTitle = app.menuItems["New Project"].firstMatch
|
||||||
|
let byId = app.menuItems["menuProjectNew"].firstMatch
|
||||||
|
let item = byTitle.exists ? byTitle : byId
|
||||||
|
if item.waitForExistence(timeout: 5) {
|
||||||
|
item.click()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
app.typeKey(XCUIKeyboardKey.escape, modifierFlags: [])
|
||||||
|
}
|
||||||
|
app.typeKey("n", modifierFlags: .command)
|
||||||
|
}
|
||||||
|
|
||||||
/// Writes a `.icceryproj` fixture under `testRoot` and points the
|
/// Writes a `.icceryproj` fixture under `testRoot` and points the
|
||||||
/// open-picker hook at it.
|
/// open-picker hook at it.
|
||||||
private func stageProjectFixture(
|
private func stageProjectFixture(
|
||||||
@@ -133,23 +167,26 @@ final class Milestone10ProjectUITests: XCTestCase {
|
|||||||
XCTAssertTrue(basenameField.waitForExistence(timeout: 10))
|
XCTAssertTrue(basenameField.waitForExistence(timeout: 10))
|
||||||
XCTAssertEqual(basenameField.value as? String, "ui149job")
|
XCTAssertEqual(basenameField.value as? String, "ui149job")
|
||||||
|
|
||||||
// ⌘N fires the File-menu New command even when the menu is not
|
// File ▸ New Project when the menu is in the AX tree, else
|
||||||
// in the AX tree. Mock CUPS may have enumerated a queue that the
|
// ⌘N. Mock CUPS may have enumerated a queue that the fixture
|
||||||
// fixture does not record, making the session dirty — in that
|
// does not record, making the session dirty — in that case
|
||||||
// case the dirty alert gates New first.
|
// the dirty alert gates New first. macOS 12 alerts often lack
|
||||||
app.typeKey("n", modifierFlags: .command)
|
// button identifiers, so confirm by title with id fallback.
|
||||||
|
triggerNewProject()
|
||||||
let deadline = Date().addingTimeInterval(10)
|
let deadline = Date().addingTimeInterval(10)
|
||||||
var confirmed = false
|
var confirmed = false
|
||||||
while Date() < deadline {
|
while Date() < deadline {
|
||||||
// Dirty sessions show the dirty alert first; discarding it
|
// Dirty sessions show the dirty alert first; discarding it
|
||||||
// runs the New reset directly (no second confirm).
|
// runs the New reset directly (no second confirm).
|
||||||
if element("btnProjectDirtyDiscard").exists {
|
if let discard = alertButton(
|
||||||
element("btnProjectDirtyDiscard").click()
|
title: "Don't Save", id: "btnProjectDirtyDiscard") {
|
||||||
|
discard.click()
|
||||||
confirmed = true
|
confirmed = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if element("btnProjectNewConfirm").exists {
|
if let start = alertButton(
|
||||||
element("btnProjectNewConfirm").click()
|
title: "Start", id: "btnProjectNewConfirm") {
|
||||||
|
start.click()
|
||||||
confirmed = true
|
confirmed = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,31 @@ final class Milestone2UITests: XCTestCase {
|
|||||||
return el
|
return el
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Exists **and** `isEnabled` — guards clicks against buttons that
|
||||||
|
/// appear a beat before their `.disabled` condition clears.
|
||||||
|
private func waitUntilEnabled(_ id: String, timeout: TimeInterval = 10) -> XCUIElement {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
let el = element(id)
|
||||||
|
if el.exists && el.isEnabled { return el }
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
|
}
|
||||||
|
let el = element(id)
|
||||||
|
XCTAssertTrue(
|
||||||
|
el.exists && el.isEnabled, "Expected enabled element \(id)")
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Non-asserting existence poll for the retry-or-fail pattern.
|
||||||
|
private func existsAfter(_ id: String, timeout: TimeInterval) -> Bool {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
if element(id).exists { return true }
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
|
}
|
||||||
|
return element(id).exists
|
||||||
|
}
|
||||||
|
|
||||||
/// Assert an element stays absent after a short dwell — unlike
|
/// Assert an element stays absent after a short dwell — unlike
|
||||||
/// `waitForExistence`, which always burns its full timeout on the
|
/// `waitForExistence`, which always burns its full timeout on the
|
||||||
/// negative path.
|
/// negative path.
|
||||||
@@ -181,8 +206,15 @@ final class Milestone2UITests: XCTestCase {
|
|||||||
XCTAssertTrue(element("tiffDpi").exists)
|
XCTAssertTrue(element("tiffDpi").exists)
|
||||||
XCTAssertTrue(element("targetLabelPreview").exists)
|
XCTAssertTrue(element("targetLabelPreview").exists)
|
||||||
|
|
||||||
app.buttons["btnCreateLayout"].click()
|
// On the slow macOS 12 runner a synthesized click can land
|
||||||
XCTAssertTrue(waitFor("galleryPage-0", timeout: 20).exists)
|
// while the button is still rebuilding — retry once if the
|
||||||
|
// gallery never materialises, then allow a generous window
|
||||||
|
// for the fixture printtarg + PNG render.
|
||||||
|
waitUntilEnabled("btnCreateLayout").click()
|
||||||
|
if !existsAfter("galleryPage-0", timeout: 15) {
|
||||||
|
waitUntilEnabled("btnCreateLayout").click()
|
||||||
|
}
|
||||||
|
XCTAssertTrue(waitFor("galleryPage-0", timeout: 30).exists)
|
||||||
XCTAssertTrue(FileManager.default.fileExists(
|
XCTAssertTrue(FileManager.default.fileExists(
|
||||||
atPath: workDir.appendingPathComponent("mytarget.ti2").path))
|
atPath: workDir.appendingPathComponent("mytarget.ti2").path))
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,59 @@ final class Milestone3UITests: XCTestCase {
|
|||||||
return recordedLpArgv()
|
return recordedLpArgv()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drags `#galleryPage-0`'s TIFF upward so `identifier`'s button
|
||||||
|
/// moves up, clear of the Dock collision zone at the window's
|
||||||
|
/// bottom edge (#132).
|
||||||
|
///
|
||||||
|
/// macOS overlay scrollbars are not in the AX tree — never use
|
||||||
|
/// `app.scrollBars` — and a synthesized scroll wheel is inert on
|
||||||
|
/// this LazyVGrid, so the scroll is a real drag on the gallery
|
||||||
|
/// cell's content. A stale/off-screen AX frame resolves to a screen
|
||||||
|
/// point that can be a Dock icon — a coordinate click there once
|
||||||
|
/// opened Calendar instead of Print. Callers must click only when
|
||||||
|
/// the returned element `isHittable`; never coordinate-click a
|
||||||
|
/// stale frame.
|
||||||
|
@discardableResult
|
||||||
|
private func scrollStage2UntilHittable(
|
||||||
|
_ identifier: String,
|
||||||
|
timeout: TimeInterval = 20
|
||||||
|
) -> XCUIElement {
|
||||||
|
var button = app.buttons[identifier]
|
||||||
|
let cell = app.descendants(matching: .any)["galleryPage-0"].firstMatch
|
||||||
|
XCTAssertTrue(cell.waitForExistence(timeout: 10), "galleryPage-0")
|
||||||
|
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
let windowBottom = app.windows.firstMatch.frame.maxY
|
||||||
|
if button.exists, button.isHittable,
|
||||||
|
button.frame.maxY < windowBottom - 80 {
|
||||||
|
return button
|
||||||
|
}
|
||||||
|
// Grab the upper half of the cell (the TIFF, not the Print
|
||||||
|
// button / Dock) and drag toward the top of the window.
|
||||||
|
// Mouse moves UP ⇒ gallery content moves UP ⇒ Print leaves
|
||||||
|
// the Dock zone.
|
||||||
|
if cell.isHittable {
|
||||||
|
let start = cell.coordinate(withNormalizedOffset:
|
||||||
|
CGVector(dx: 0.5, dy: 0.25))
|
||||||
|
let end = start.withOffset(CGVector(dx: 0, dy: -280))
|
||||||
|
start.press(forDuration: 0.15, thenDragTo: end)
|
||||||
|
} else {
|
||||||
|
// Cell not hit-testable: drag the stage-2 content
|
||||||
|
// directly — still content, still never scrollBars.
|
||||||
|
let scrollView = app.scrollViews["stage-2"]
|
||||||
|
scrollView.coordinate(withNormalizedOffset:
|
||||||
|
CGVector(dx: 0.5, dy: 0.55))
|
||||||
|
.press(forDuration: 0.15, thenDragTo:
|
||||||
|
scrollView.coordinate(withNormalizedOffset:
|
||||||
|
CGVector(dx: 0.5, dy: 0.15)))
|
||||||
|
}
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.4))
|
||||||
|
button = app.buttons[identifier]
|
||||||
|
}
|
||||||
|
return button
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Tests
|
// MARK: - Tests
|
||||||
|
|
||||||
/// Panel appears after the manifest; refresh populates the printer
|
/// Panel appears after the manifest; refresh populates the printer
|
||||||
@@ -199,36 +252,16 @@ final class Milestone3UITests: XCTestCase {
|
|||||||
XCTAssertTrue(app.buttons["btnPrintAll"].isEnabled)
|
XCTAssertTrue(app.buttons["btnPrintAll"].isEnabled)
|
||||||
|
|
||||||
// The gallery cell's Print button sits at the window's bottom
|
// The gallery cell's Print button sits at the window's bottom
|
||||||
// edge where synthesized scroll-wheel events are inert on the
|
// edge; scroll until it is genuinely hittable (#132). Never
|
||||||
// LazyVGrid (#132). Drag the NSScrollView's vertical AXScrollBar
|
// coordinate-click a stale frame — that point can be the Dock.
|
||||||
// thumb instead — a real scroll that re-renders the cell onscreen.
|
let printPage = scrollStage2UntilHittable("btnPrintPage-0")
|
||||||
var printPage = app.buttons["btnPrintPage-0"]
|
guard printPage.isHittable else {
|
||||||
let scrollDeadline = Date().addingTimeInterval(15)
|
|
||||||
while !printPage.isHittable, Date() < scrollDeadline {
|
|
||||||
let scroller = app.scrollBars.allElementsBoundByIndex
|
|
||||||
.first { $0.frame.height > $0.frame.width }
|
|
||||||
if let scroller {
|
|
||||||
scroller.coordinate(withNormalizedOffset:
|
|
||||||
CGVector(dx: 0.5, dy: 0.1))
|
|
||||||
.press(forDuration: 0.1, thenDragTo:
|
|
||||||
scroller.coordinate(withNormalizedOffset:
|
|
||||||
CGVector(dx: 0.5, dy: 0.6)))
|
|
||||||
} else {
|
|
||||||
app.scrollViews["stage-2"].scroll(byDeltaX: 0, deltaY: -1)
|
|
||||||
}
|
|
||||||
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
|
|
||||||
printPage = app.buttons["btnPrintPage-0"]
|
|
||||||
}
|
|
||||||
if printPage.isHittable {
|
|
||||||
printPage.click()
|
|
||||||
} else {
|
|
||||||
// LazyVGrid cells can report a stale a11y frame — click the
|
|
||||||
// point directly; the lp argv assert below still verifies.
|
|
||||||
print("AXTREE-BEGIN frame=\(printPage.frame)\n" +
|
print("AXTREE-BEGIN frame=\(printPage.frame)\n" +
|
||||||
"\(app.debugDescription)\nAXTREE-END")
|
"\(app.debugDescription)\nAXTREE-END")
|
||||||
printPage.coordinate(withNormalizedOffset:
|
XCTFail("btnPrintPage-0 never became hittable; frame=\(printPage.frame)")
|
||||||
CGVector(dx: 0.5, dy: 0.5)).click()
|
return
|
||||||
}
|
}
|
||||||
|
printPage.click()
|
||||||
let argv = waitForLpLine()
|
let argv = waitForLpLine()
|
||||||
XCTAssertTrue(argv.contains("AP_ColorMatchingMode"), argv)
|
XCTAssertTrue(argv.contains("AP_ColorMatchingMode"), argv)
|
||||||
XCTAssertTrue(argv.contains("page1.tif"), argv)
|
XCTAssertTrue(argv.contains("page1.tif"), argv)
|
||||||
|
|||||||
@@ -86,6 +86,60 @@ final class Milestone6CalibrationUITests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stage 0 must not push the sidebar off-screen: the macOS `Form`
|
||||||
|
/// rows with expanding spacers once gave the stage an unbounded ideal
|
||||||
|
/// width, and window centering shifted the 270 pt sidebar into
|
||||||
|
/// negative X (issue #163). AX-tree existence checks cannot see that,
|
||||||
|
/// so assert real frame geometry.
|
||||||
|
func testCalibrationViewDoesNotOverflowWindow() throws {
|
||||||
|
let calButton = app.buttons["btnCalibratePrinter"]
|
||||||
|
XCTAssertTrue(calButton.waitForExistence(timeout: 10))
|
||||||
|
calButton.tap()
|
||||||
|
|
||||||
|
XCTAssertTrue(app.staticTexts["Calibrate Printer"].waitForExistence(timeout: 5))
|
||||||
|
|
||||||
|
let window = app.windows.firstMatch
|
||||||
|
XCTAssertTrue(window.exists)
|
||||||
|
XCTAssertGreaterThanOrEqual(calButton.frame.minX, 0)
|
||||||
|
XCTAssertLessThanOrEqual(calButton.frame.maxX, window.frame.maxX)
|
||||||
|
let ret = app.buttons["btnCalReturn"]
|
||||||
|
XCTAssertTrue(ret.waitForExistence(timeout: 5))
|
||||||
|
XCTAssertTrue(ret.isHittable)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Return to Profiling" is the single Stage 0 exit and carries the
|
||||||
|
/// cancel-action shortcut, so Escape must dismiss the dashboard too
|
||||||
|
/// (issue #163). `typeKey` delivery is unreliable on the macOS 12 CI
|
||||||
|
/// runner (m10 phase-08), so the Escape check falls back to the
|
||||||
|
/// deterministic button tap.
|
||||||
|
func testCalibrationReturnButtonAndEscapeDismiss() throws {
|
||||||
|
let calButton = app.buttons["btnCalibratePrinter"]
|
||||||
|
XCTAssertTrue(calButton.waitForExistence(timeout: 10))
|
||||||
|
calButton.tap()
|
||||||
|
XCTAssertTrue(app.staticTexts["Calibrate Printer"].waitForExistence(timeout: 5))
|
||||||
|
|
||||||
|
let returnButton = app.buttons["btnCalReturn"]
|
||||||
|
XCTAssertTrue(returnButton.waitForExistence(timeout: 5))
|
||||||
|
XCTAssertTrue(returnButton.isHittable)
|
||||||
|
returnButton.tap()
|
||||||
|
|
||||||
|
let stage1 = app.descendants(matching: .any)["stage-1"]
|
||||||
|
XCTAssertTrue(stage1.waitForExistence(timeout: 5))
|
||||||
|
|
||||||
|
// Re-enter and try Escape; fall back to the button where the
|
||||||
|
// runtime does not deliver typeKey.
|
||||||
|
XCTAssertTrue(calButton.waitForExistence(timeout: 5))
|
||||||
|
calButton.tap()
|
||||||
|
XCTAssertTrue(app.staticTexts["Calibrate Printer"].waitForExistence(timeout: 5))
|
||||||
|
|
||||||
|
app.typeKey(XCUIKeyboardKey.escape, modifierFlags: [])
|
||||||
|
if !stage1.waitForExistence(timeout: 4) {
|
||||||
|
XCTAssertTrue(returnButton.waitForExistence(timeout: 5))
|
||||||
|
returnButton.tap()
|
||||||
|
XCTAssertTrue(stage1.waitForExistence(timeout: 5))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A failing calibration targen surfaces the error through the
|
/// A failing calibration targen surfaces the error through the
|
||||||
/// wizard notice and restores the original basename (issue #80).
|
/// wizard notice and restores the original basename (issue #80).
|
||||||
func testCalibrationTargenFailureRestoresBasename() throws {
|
func testCalibrationTargenFailureRestoresBasename() throws {
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import Metal
|
||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
/// Milestone 6 — Issue #28 native SceneKit gamut viewer acceptance tests.
|
/// Milestone 6 — Issue #28 native SceneKit gamut viewer acceptance tests.
|
||||||
@MainActor
|
@MainActor
|
||||||
final class Milestone6GamutUITests: XCTestCase {
|
final class Milestone6GamutUITests: XCTestCase {
|
||||||
|
|
||||||
|
/// Metal on the test host — the app under test runs on the same
|
||||||
|
/// machine, so this predicts whether the sheet mounts SceneKit.
|
||||||
|
private var hasGPU: Bool { MTLCreateSystemDefaultDevice() != nil }
|
||||||
|
|
||||||
private var app: XCUIApplication!
|
private var app: XCUIApplication!
|
||||||
private var testRoot: URL!
|
private var testRoot: URL!
|
||||||
private var binDir: URL!
|
private var binDir: URL!
|
||||||
@@ -64,6 +69,10 @@ final class Milestone6GamutUITests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override func tearDown() async throws {
|
override func tearDown() async throws {
|
||||||
|
// Never leave the gamut sheet up for `terminate()` (#147).
|
||||||
|
if app != nil, element("btnCloseGamut").exists {
|
||||||
|
element("btnCloseGamut").click()
|
||||||
|
}
|
||||||
app?.terminate()
|
app?.terminate()
|
||||||
app = nil
|
app = nil
|
||||||
if let testRoot {
|
if let testRoot {
|
||||||
@@ -90,10 +99,29 @@ final class Milestone6GamutUITests: XCTestCase {
|
|||||||
return el
|
return el
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Inverse of `waitFor` — polls until the element leaves the tree.
|
||||||
|
private func waitForGone(_ id: String, timeout: TimeInterval = 10) {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
if !element(id).exists { return }
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
|
}
|
||||||
|
XCTAssertFalse(element(id).exists, "Expected element \(id) to disappear")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `btnCloseGamut` dismisses the sheet so `tearDown`'s `terminate()`
|
||||||
|
/// is not stuck behind a key sheet (#147). No-op when already closed.
|
||||||
|
private func closeGamutSheet() {
|
||||||
|
let close = element("btnCloseGamut")
|
||||||
|
guard close.waitForExistence(timeout: 5) else { return }
|
||||||
|
close.click()
|
||||||
|
waitForGone("gamutView")
|
||||||
|
}
|
||||||
|
|
||||||
/// Build and verify the mock profile, then open the native gamut viewer.
|
/// Build and verify the mock profile, then open the native gamut viewer.
|
||||||
/// The viewer should load both the reference sRGB mesh and the profile
|
/// The viewer should load both the reference sRGB mesh and the profile
|
||||||
/// gamut copied from that reference.
|
/// gamut copied from that reference.
|
||||||
func testViewGamutOpensSceneKitSheet() throws {
|
private func openGamutSheet() {
|
||||||
app.launch()
|
app.launch()
|
||||||
if !app.wait(for: .runningForeground, timeout: 10) {
|
if !app.wait(for: .runningForeground, timeout: 10) {
|
||||||
app.activate()
|
app.activate()
|
||||||
@@ -105,6 +133,12 @@ final class Milestone6GamutUITests: XCTestCase {
|
|||||||
|
|
||||||
waitFor("btnViewGamut").click()
|
waitFor("btnViewGamut").click()
|
||||||
|
|
||||||
|
_ = waitFor("gamutView")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testViewGamutOpensSceneKitSheet() throws {
|
||||||
|
openGamutSheet()
|
||||||
|
|
||||||
let gamutView = waitFor("gamutView")
|
let gamutView = waitFor("gamutView")
|
||||||
XCTAssertTrue(gamutView.exists)
|
XCTAssertTrue(gamutView.exists)
|
||||||
|
|
||||||
@@ -112,9 +146,33 @@ final class Milestone6GamutUITests: XCTestCase {
|
|||||||
let value = status.value as? String ?? ""
|
let value = status.value as? String ?? ""
|
||||||
XCTAssertTrue(value.contains("faces"), "Gamut status should report mesh faces, got: \(value)")
|
XCTAssertTrue(value.contains("faces"), "Gamut status should report mesh faces, got: \(value)")
|
||||||
|
|
||||||
|
// The fallback banner appears exactly when the host lacks Metal
|
||||||
|
// — no SCNView is constructed without a GPU (#147).
|
||||||
|
if hasGPU {
|
||||||
|
XCTAssertFalse(
|
||||||
|
element("gamutViewerUnavailable").exists,
|
||||||
|
"GPU host must mount the SceneKit view, not the fallback")
|
||||||
|
} else {
|
||||||
|
_ = waitFor("gamutViewerUnavailable")
|
||||||
|
}
|
||||||
|
|
||||||
// The reset button demonstrates that the viewer is interactive.
|
// The reset button demonstrates that the viewer is interactive.
|
||||||
let reset = waitFor("btnResetGamutCamera")
|
let reset = waitFor("btnResetGamutCamera")
|
||||||
XCTAssertTrue(reset.isEnabled)
|
XCTAssertTrue(reset.isEnabled)
|
||||||
|
|
||||||
|
closeGamutSheet()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clicking Reset drives the live `SCNView` — runs only on Metal
|
||||||
|
/// hosts, skipped on GPU-less runners so the same suite exercises
|
||||||
|
/// 3D once CI has a GPU (#147).
|
||||||
|
func testResetCameraInteractsWithScene() throws {
|
||||||
|
guard hasGPU else { throw XCTSkip("No Metal") }
|
||||||
|
openGamutSheet()
|
||||||
|
|
||||||
|
let reset = waitFor("btnResetGamutCamera")
|
||||||
reset.click()
|
reset.click()
|
||||||
|
|
||||||
|
closeGamutSheet()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import Foundation
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
/// Settings sheet UI tests (issue #165).
|
||||||
|
///
|
||||||
|
/// The Verification thresholds once shared one non-wrapping `HStack` and
|
||||||
|
/// drew past the sheet's right clip on the macOS grouped `Form`. AX
|
||||||
|
/// existence cannot see clipping (#163), so containment is asserted on
|
||||||
|
/// real frame geometry against the sheet's bounds.
|
||||||
|
///
|
||||||
|
/// All three numeric fields also passed their default value as the
|
||||||
|
/// `TextField` label; inside an `HStack` row that label renders inline —
|
||||||
|
/// it is not a placeholder — producing "Stale after 30 [30] days". The
|
||||||
|
/// fields are now direct `Form` children, so the descriptive label
|
||||||
|
/// renders once in the label column and the box fills the control
|
||||||
|
/// column; `testNumericFieldsCarryLabelsNotDuplicatedValues` pins it.
|
||||||
|
@MainActor
|
||||||
|
final class SettingsUITests: XCTestCase {
|
||||||
|
|
||||||
|
private var app: XCUIApplication!
|
||||||
|
|
||||||
|
override func setUp() async throws {
|
||||||
|
continueAfterFailure = false
|
||||||
|
app = XCUIApplication()
|
||||||
|
app.launchEnvironment = ["ICCERY_UI_TESTING": "1"]
|
||||||
|
app.launch()
|
||||||
|
app.activate()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() async throws {
|
||||||
|
app?.terminate()
|
||||||
|
app = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sheet content lives under `app.sheets`, outside the main window's
|
||||||
|
/// a11y tree (Milestone2 pattern).
|
||||||
|
private var sheet: XCUIElement {
|
||||||
|
app.sheets.firstMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openSettings() {
|
||||||
|
let gear = app.buttons["openSettingsBtn"]
|
||||||
|
XCTAssertTrue(gear.waitForExistence(timeout: 10))
|
||||||
|
gear.click()
|
||||||
|
XCTAssertTrue(sheet.waitForExistence(timeout: 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func thresholdField(_ fieldID: String) -> XCUIElement {
|
||||||
|
let field = sheet.textFields[fieldID]
|
||||||
|
XCTAssertTrue(field.waitForExistence(timeout: 10), "missing \(fieldID)")
|
||||||
|
return field
|
||||||
|
}
|
||||||
|
|
||||||
|
private func replaceFieldValue(_ field: XCUIElement, with text: String) {
|
||||||
|
field.click()
|
||||||
|
app.typeKey("a", modifierFlags: .command)
|
||||||
|
field.typeText(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitForSheetDismiss(timeout: TimeInterval = 10) {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
if !sheet.exists { return }
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
|
}
|
||||||
|
XCTAssertFalse(sheet.exists, "Expected sheet to disappear")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Both ΔE rows must render fully inside the 560×620 sheet: the
|
||||||
|
/// label-column `StaticText`s and the control-column fields all sit
|
||||||
|
/// within the sheet bounds, the two fields share the Form's control
|
||||||
|
/// column margin, and the Warning row sits below the Good row so the
|
||||||
|
/// two cannot overlap on one clipped line.
|
||||||
|
///
|
||||||
|
/// The numeric fields are direct `Form` children, so macOS lifts
|
||||||
|
/// each `TextField` label into the right-aligned label column and
|
||||||
|
/// the editable box fills the control column — the same layout the
|
||||||
|
/// Pickers use. The control column ends only ~3.5 pt inside the
|
||||||
|
/// sheet (PopUpButtons reach it too), so the right-edge assertion is
|
||||||
|
/// "inside the sheet", not the older 12 pt compact-field inset.
|
||||||
|
func testVerificationRowsStayInsideSheet() throws {
|
||||||
|
openSettings()
|
||||||
|
|
||||||
|
let goodField = thresholdField("settingsDeltaEGood")
|
||||||
|
let warningField = thresholdField("settingsDeltaEWarning")
|
||||||
|
|
||||||
|
// Labels render as sibling staticTexts in the label column.
|
||||||
|
let goodLabel = sheet.staticTexts["Good ΔE ≤"]
|
||||||
|
let warningLabel = sheet.staticTexts["Warning ΔE ≤"]
|
||||||
|
XCTAssertTrue(goodLabel.waitForExistence(timeout: 5))
|
||||||
|
XCTAssertTrue(warningLabel.waitForExistence(timeout: 5))
|
||||||
|
|
||||||
|
// Left boundary: labels must be inside the sheet with >=12 pt inset
|
||||||
|
XCTAssertTrue(goodLabel.frame.minX >= sheet.frame.minX + 12.0)
|
||||||
|
XCTAssertTrue(warningLabel.frame.minX >= sheet.frame.minX + 12.0)
|
||||||
|
|
||||||
|
// Right boundary: fields must not draw past the sheet's clip
|
||||||
|
XCTAssertTrue(
|
||||||
|
goodField.frame.maxX <= sheet.frame.maxX,
|
||||||
|
"Good ΔE field clips the sheet's right edge")
|
||||||
|
XCTAssertTrue(
|
||||||
|
warningField.frame.maxX <= sheet.frame.maxX,
|
||||||
|
"Warning ΔE field clips the sheet's right edge")
|
||||||
|
|
||||||
|
// Vertical separation
|
||||||
|
XCTAssertTrue(
|
||||||
|
warningField.frame.minY > goodField.frame.minY,
|
||||||
|
"thresholds must be two separate rows")
|
||||||
|
|
||||||
|
// Both threshold fields align at the same control column margin
|
||||||
|
XCTAssertTrue(
|
||||||
|
abs(goodField.frame.minX - warningField.frame.minX) <= 1.0,
|
||||||
|
"Good and Warning ΔE fields should align at the same column margin")
|
||||||
|
|
||||||
|
// Other labels must not overflow the left boundary
|
||||||
|
let defaultInstLabel = sheet.staticTexts["Default instrument"]
|
||||||
|
XCTAssertTrue(defaultInstLabel.waitForExistence(timeout: 5))
|
||||||
|
XCTAssertTrue(
|
||||||
|
defaultInstLabel.frame.minX >= sheet.frame.minX + 12.0,
|
||||||
|
"Default instrument label must not overflow left edge")
|
||||||
|
|
||||||
|
let bundledSidecarsLabel = sheet.staticTexts["Bundled sidecars"]
|
||||||
|
XCTAssertTrue(bundledSidecarsLabel.waitForExistence(timeout: 5))
|
||||||
|
XCTAssertTrue(
|
||||||
|
bundledSidecarsLabel.frame.minX >= sheet.frame.minX + 12.0,
|
||||||
|
"Bundled sidecars label must not overflow left edge")
|
||||||
|
|
||||||
|
sheet.buttons["Cancel"].click()
|
||||||
|
waitForSheetDismiss(timeout: 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `warning <= good` fails `AppSettings.validate()` and keeps the
|
||||||
|
/// sheet open with the contract error text; restoring valid values
|
||||||
|
/// lets Save dismiss (issue #165 acceptance, strings are the #5
|
||||||
|
/// contract).
|
||||||
|
func testDeltaEValidationBlocksSaveThenValidSaveDismisses() throws {
|
||||||
|
openSettings()
|
||||||
|
|
||||||
|
replaceFieldValue(thresholdField("settingsDeltaEWarning"), with: "1")
|
||||||
|
sheet.buttons["Save"].click()
|
||||||
|
|
||||||
|
let error = sheet.staticTexts[
|
||||||
|
"Good ΔE threshold must be strictly less than the warning threshold."
|
||||||
|
]
|
||||||
|
XCTAssertTrue(error.waitForExistence(timeout: 10))
|
||||||
|
XCTAssertTrue(sheet.exists, "invalid ΔE must not dismiss the sheet")
|
||||||
|
|
||||||
|
replaceFieldValue(thresholdField("settingsDeltaEWarning"), with: "5")
|
||||||
|
sheet.buttons["Save"].click()
|
||||||
|
waitForSheetDismiss(timeout: 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// macOS renders a `TextField`'s first argument as a label, not a
|
||||||
|
/// placeholder — inside the old `HStack` rows it drew inline, so the
|
||||||
|
/// sheet read "Stale after 30 [30] days" / "Good ΔE ≤ 2.0 [2.0]".
|
||||||
|
/// As direct `Form` children each label now renders exactly once, in
|
||||||
|
/// the label column; no `staticText` may echo the field's value.
|
||||||
|
func testNumericFieldsCarryLabelsNotDuplicatedValues() throws {
|
||||||
|
openSettings()
|
||||||
|
|
||||||
|
// (identifier, label-column text, rendered default value, the
|
||||||
|
// literal that used to double-render as the field's label)
|
||||||
|
// `value:` shows the formatted number — 2.0 renders as "2".
|
||||||
|
let rows: [(id: String, label: String, value: String, dup: String)] = [
|
||||||
|
("settingsDeltaEGood", "Good ΔE ≤", "2", "2.0"),
|
||||||
|
("settingsDeltaEWarning", "Warning ΔE ≤", "5", "5.0"),
|
||||||
|
("settingsCalStaleDays", "Stale after (days)", "30", "30"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for spec in rows {
|
||||||
|
let field = sheet.textFields[spec.id]
|
||||||
|
XCTAssertTrue(field.waitForExistence(timeout: 10), "missing \(spec.id)")
|
||||||
|
XCTAssertEqual(
|
||||||
|
field.value as? String, spec.value,
|
||||||
|
"\(spec.id) default value changed unexpectedly")
|
||||||
|
XCTAssertTrue(
|
||||||
|
sheet.staticTexts[spec.label].waitForExistence(timeout: 5),
|
||||||
|
"\(spec.id) must render \"\(spec.label)\" once in the label column")
|
||||||
|
for ghost in Set([spec.value, spec.dup]) {
|
||||||
|
XCTAssertFalse(
|
||||||
|
sheet.staticTexts[ghost].exists,
|
||||||
|
"\(spec.id) must not render \"\(ghost)\" as a second label")
|
||||||
|
}
|
||||||
|
XCTAssertTrue(
|
||||||
|
field.frame.maxX <= sheet.frame.maxX,
|
||||||
|
"\(spec.id) field clips the sheet's right edge")
|
||||||
|
}
|
||||||
|
|
||||||
|
sheet.buttons["Cancel"].click()
|
||||||
|
waitForSheetDismiss(timeout: 5)
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -7,7 +7,7 @@ Persisted at `{app_data}/settings.json` via `load_settings` / `save_settings`. I
|
|||||||
| Field | Default | Notes |
|
| Field | Default | Notes |
|
||||||
|-------|---------|-------|
|
|-------|---------|-------|
|
||||||
| `argyll_binary_dir` | `null` | Overrides bundled sidecars. `resolve_binary` checks this first. |
|
| `argyll_binary_dir` | `null` | Overrides bundled sidecars. `resolve_binary` checks this first. |
|
||||||
| `default_instrument` | `null` | **Stored but not applied to argv.** Stage 2 `#instrumentSelect` is the source of truth. Do not start honouring this without an explicit product decision. |
|
| `default_instrument` | `null` | Stored; seeds the Spot Read instrument picker when that instrument is present (#148). **Never** written into `printtarg -i` or `targen` argv. Stage 2 `#instrumentSelect` remains the printtarg instrument. |
|
||||||
| `log_level` | `null` | `error` / `warn` / `info` / `debug` / `trace`. `null` → Debug in debug builds, Info in release. Applied at startup **and** on save (#158). |
|
| `log_level` | `null` | `error` / `warn` / `info` / `debug` / `trace`. `null` → Debug in debug builds, Info in release. Applied at startup **and** on save (#158). |
|
||||||
| `delta_e_good_max` | `2.0` | Stage 3 swatch traffic-light "Good". Must be ≥ 0. |
|
| `delta_e_good_max` | `2.0` | Stage 3 swatch traffic-light "Good". Must be ≥ 0. |
|
||||||
| `delta_e_warning_max` | `5.0` | Stage 3 "Warning" band. Must be **strictly greater** than good. |
|
| `delta_e_warning_max` | `5.0` | Stage 3 "Warning" band. Must be **strictly greater** than good. |
|
||||||
|
|||||||
+1
-1
@@ -25,7 +25,7 @@ Cone-only mark for window/taskbar. Raster set:
|
|||||||
|
|
||||||
| File | Use |
|
| File | Use |
|
||||||
|------|-----|
|
|------|-----|
|
||||||
| `icons/dmg-background.png` (+ `@2x`, `.svg`) | macOS DMG window (ice cream / wordmark scene). Headless `dmgbuild` after #189 |
|
| `Resources/dmg-background.png` (+ `@2x`; source `brand/dmg-background.svg`) | macOS DMG window. `scripts/package-release.sh` builds a HiDPI TIFF and passes it to `dmgbuild==1.6.7` from `build/.venv-dmgbuild` (created by `INSTALL_DMGBUILD=1 scripts/ensure-host-tools.sh`). Monterey Python 3.9 needs `PIP_IGNORE_REQUIRES_PYTHON=1` or pip will keep 1.6.5. Missing art is a hard fail (#95, #189). |
|
||||||
| `icons/wix-banner.bmp`, `wix-dialog.bmp` | MSI |
|
| `icons/wix-banner.bmp`, `wix-dialog.bmp` | MSI |
|
||||||
| `icons/nsis-header.bmp`, `nsis-sidebar.bmp` | NSIS |
|
| `icons/nsis-header.bmp`, `nsis-sidebar.bmp` | NSIS |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
@startuml ICCery-UI-sheets
|
||||||
|
title Sheets Settings, presets, media, Spot Read, Gamut, project
|
||||||
|
!theme plain
|
||||||
|
skinparam backgroundColor white
|
||||||
|
skinparam defaultFontColor black
|
||||||
|
skinparam defaultFontSize 13
|
||||||
|
skinparam mapBackgroundColor white
|
||||||
|
skinparam mapBorderColor #222222
|
||||||
|
skinparam mapFontColor black
|
||||||
|
skinparam arrowColor #222222
|
||||||
|
skinparam shadowing false
|
||||||
|
hide circle
|
||||||
|
top to bottom direction
|
||||||
|
|
||||||
|
map "SettingsView.swift" as set {
|
||||||
|
Argyll dir => TextField+Browse : empty = bundled
|
||||||
|
Default instrument => Picker : seeds Stage 3 and Spot Read
|
||||||
|
Enable i1Pro 2 LEDs => Toggle
|
||||||
|
settingsDeltaEGood => TextField
|
||||||
|
settingsDeltaEWarning => TextField : must be greater than Good
|
||||||
|
settingsCalStaleDays => TextField
|
||||||
|
Install location => Picker User or System
|
||||||
|
Ask before overwriting => Toggle
|
||||||
|
Open ColorSync after install => Toggle
|
||||||
|
Log level => Picker
|
||||||
|
Open log folder => Button
|
||||||
|
Cancel / Save => Save stays if validation fails
|
||||||
|
}
|
||||||
|
|
||||||
|
map "Presets PresetDialogs.swift" as pre {
|
||||||
|
savePresetName => TextField
|
||||||
|
savePresetDesc => TextField
|
||||||
|
btnCloseSavePresetDialog => Button
|
||||||
|
btnConfirmSavePreset => Button : disabled if name empty
|
||||||
|
presetRow-id => Row : built-in cannot delete
|
||||||
|
btnImportPreset => Button
|
||||||
|
btnExportActivePreset => Button : custom selected
|
||||||
|
btnCloseManagePresetsDialog => Button
|
||||||
|
}
|
||||||
|
|
||||||
|
map "Media MediaLibraryDialogs.swift" as media {
|
||||||
|
saveMediaName/Paper/Ink => TextField
|
||||||
|
saveMediaApplyCal => Toggle : disabled if CAL_ or missing file
|
||||||
|
btnConfirmSaveMedia => Button : name+paper+ink required
|
||||||
|
mediaLibraryList => List
|
||||||
|
btnMediaLibraryApply-id => Button per row
|
||||||
|
manageMediaNotice => Caption : failed Apply in-sheet
|
||||||
|
btnMediaLibraryApply => Button : disabled if no selection
|
||||||
|
btnCloseManageMediaDialog => Button
|
||||||
|
}
|
||||||
|
|
||||||
|
map "spotReadView SpotReadView.swift" as spot {
|
||||||
|
btnSpotDetectInstruments => Button
|
||||||
|
spotInstrumentSelect => Picker : disabled while running
|
||||||
|
spotSetDefault => Toggle
|
||||||
|
btnSpotStart => Button : needs sidecar, cwd, not chartread
|
||||||
|
btnSpotCalibrate => Button : calibrating
|
||||||
|
btnSpotTrigger => Button : Read, awaitingStrip
|
||||||
|
btnSpotStop => Button : while running
|
||||||
|
btnSpotCopyLab => Button : disabled if no sample
|
||||||
|
btnSpotExportCsv => Button : disabled if no history
|
||||||
|
btnCloseSpotRead => Button Esc : dismiss = Stop
|
||||||
|
}
|
||||||
|
|
||||||
|
map "gamutView GamutView.swift" as gam {
|
||||||
|
gamutLayer-srgb => Toggle : can hide, cannot remove
|
||||||
|
gamutLayer-profile => Toggle : disabled if no .gam
|
||||||
|
gamutLayer-compare => Toggle : disabled if no compare
|
||||||
|
btnGamutAddCompare => Menu
|
||||||
|
btnGamutRemoveCompare => Button
|
||||||
|
btnGamutSampleTiff => Button
|
||||||
|
btnResetGamutCamera => Button R
|
||||||
|
gamutLabEntryL/A/B => TextField
|
||||||
|
btnGamutInspectLab => Button
|
||||||
|
btnCloseGamut => Button Esc
|
||||||
|
}
|
||||||
|
|
||||||
|
map "Project alerts" as proj {
|
||||||
|
projectNewAlert => Alert : New Cancel / Confirm
|
||||||
|
Dirty save => Alert : Save / Don't Save / Cancel
|
||||||
|
projectRelocateSheet => Sheet : cwd missing on Open
|
||||||
|
}
|
||||||
|
|
||||||
|
set -[hidden]down- pre
|
||||||
|
pre -[hidden]down- media
|
||||||
|
media -[hidden]down- spot
|
||||||
|
spot -[hidden]down- gam
|
||||||
|
gam -[hidden]down- proj
|
||||||
|
@enduml
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
@startuml ICCery-UI-shell
|
||||||
|
title Shell, sidebar, stepper, File menu
|
||||||
|
!theme plain
|
||||||
|
skinparam backgroundColor white
|
||||||
|
skinparam defaultFontColor black
|
||||||
|
skinparam defaultFontSize 13
|
||||||
|
skinparam mapBackgroundColor white
|
||||||
|
skinparam mapBorderColor #222222
|
||||||
|
skinparam mapFontColor black
|
||||||
|
skinparam arrowColor #222222
|
||||||
|
skinparam shadowing false
|
||||||
|
hide circle
|
||||||
|
top to bottom direction
|
||||||
|
|
||||||
|
map "Window" as win {
|
||||||
|
WindowGroup => ICCeryApp.swift : 1280x800 min 1100x700
|
||||||
|
RootView => RootView.swift : hosts every sheet
|
||||||
|
noticeText => NoticeBanner : shown if wizard.notice set; auto-hide 6s
|
||||||
|
}
|
||||||
|
|
||||||
|
map "Sidebar header" as hdr {
|
||||||
|
openSettingsBtn => Button : always / Settings sheet
|
||||||
|
openAboutBtn => Button : always / About
|
||||||
|
btnToggleAllHelp => Button : toggles yellow help dots
|
||||||
|
}
|
||||||
|
|
||||||
|
map "Presets" as pre {
|
||||||
|
presetSelect => Picker.menu : none + preset id; none is not factory reset
|
||||||
|
btnSavePresetModal => Button : always
|
||||||
|
btnOpenPresetsDialog => Button : always
|
||||||
|
}
|
||||||
|
|
||||||
|
map "Media library" as med {
|
||||||
|
mediaSelect => Picker.menu : never reuses presetSelect
|
||||||
|
mediaRecipeStale => Caption : shown if staleReasons nonempty
|
||||||
|
btnMediaLibraryCapture => Button : disabled if no Stage 2 printer
|
||||||
|
btnMediaLibraryManage => Button : always
|
||||||
|
}
|
||||||
|
|
||||||
|
map "Studio (not stepper)" as stu {
|
||||||
|
btnCalibratePrinter => Button : always; Stage 0
|
||||||
|
btnViewGamut => Button : sidebar always on; same id as Stage 5
|
||||||
|
btnSpotRead => Button : disabled if no cwd or chartread running
|
||||||
|
}
|
||||||
|
|
||||||
|
map "Stepper 1-5 disk is truth" as stp {
|
||||||
|
Stage1 Generate => always
|
||||||
|
Stage2 Lay out => .ti1
|
||||||
|
Stage3 Measure => .ti1 AND .ti2
|
||||||
|
Stage4 Build => .ti3 (not .ti2 alone)
|
||||||
|
Stage5 Verify => .ti3 AND .icc/.icm
|
||||||
|
Calibrate => not a stepper row; always available
|
||||||
|
}
|
||||||
|
|
||||||
|
map "Project chip" as chip {
|
||||||
|
projectChipName => Text : bound name or No project
|
||||||
|
projectChipPath => Text : shown if bound
|
||||||
|
projectChipStale => Caption : diskBehindNotes
|
||||||
|
btnProjectReveal => Button : shown if bound
|
||||||
|
btnProjectSave => Button : canSave = bound + basename + cwd
|
||||||
|
btnProjectOpen => Button : shown if not bound
|
||||||
|
}
|
||||||
|
|
||||||
|
map "File menu" as menu {
|
||||||
|
menuProjectNew => Button Cmd-N
|
||||||
|
menuProjectOpen => Button Cmd-O
|
||||||
|
menuProjectRecents => Menu : disabled if recents empty
|
||||||
|
menuProjectSave => Button Cmd-S : !canSave
|
||||||
|
menuProjectSaveAs => Button Shift-Cmd-S : basename + cwd
|
||||||
|
menuProjectReport => Button : !canReport
|
||||||
|
menuProjectClose => Button : !isBound
|
||||||
|
}
|
||||||
|
|
||||||
|
win -[hidden]down- hdr
|
||||||
|
hdr -[hidden]down- pre
|
||||||
|
pre -[hidden]down- med
|
||||||
|
med -[hidden]down- stu
|
||||||
|
stu -[hidden]down- stp
|
||||||
|
stp -[hidden]down- chip
|
||||||
|
chip -[hidden]down- menu
|
||||||
|
@enduml
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
@startuml ICCery-UI-stage1-2
|
||||||
|
title Stages 1-2 Generate Target / Lay Out and Print
|
||||||
|
!theme plain
|
||||||
|
skinparam backgroundColor white
|
||||||
|
skinparam defaultFontColor black
|
||||||
|
skinparam defaultFontSize 13
|
||||||
|
skinparam mapBackgroundColor white
|
||||||
|
skinparam mapBorderColor #222222
|
||||||
|
skinparam mapFontColor black
|
||||||
|
skinparam arrowColor #222222
|
||||||
|
skinparam shadowing false
|
||||||
|
hide circle
|
||||||
|
top to bottom direction
|
||||||
|
|
||||||
|
map "stage-1 Stage1View.swift" as s1 {
|
||||||
|
colourSpace => Picker.segmented : RGB or CMYK
|
||||||
|
patchCountPreset => Picker
|
||||||
|
patchCountCustom => TextField : shown if preset is custom
|
||||||
|
whitePatches => Stepper 0-50
|
||||||
|
blackPatches => Stepper 0-50
|
||||||
|
targetBasename => TextField
|
||||||
|
btnBrowse => Button
|
||||||
|
btnSelectWorkDir => Button
|
||||||
|
btnOpenExisting => Button : .ti1 or .ti2
|
||||||
|
btn-import-dataset => Button : unlocks stage 4
|
||||||
|
selectedPathDisplay => Text
|
||||||
|
targenAdvancedDetails => DisclosureGroup : UI tests pre-expand
|
||||||
|
btnGenerate => Button : needs basename AND directory
|
||||||
|
targenLog => ProcessLogView
|
||||||
|
}
|
||||||
|
|
||||||
|
map "Stage 1 Advanced (inside disclosure)" as adv {
|
||||||
|
targenGreySteps => Toggle+field : field iff on
|
||||||
|
targenSingleChannelSteps => Toggle+field
|
||||||
|
targenNeutralSteps => Toggle+field
|
||||||
|
targenNeutralConcentration => Toggle+Slider 0-1
|
||||||
|
targenAdaptation => Toggle+Slider 0-1
|
||||||
|
targenPrecondProfile => TextField
|
||||||
|
btnBrowsePrecondProfile => Button
|
||||||
|
targenHighQuality => Toggle
|
||||||
|
targenAlgorithm => Picker
|
||||||
|
targenInkLimitGroup => Toggle+field : CMYK only
|
||||||
|
targenDarkEmphasis => Toggle+Slider 0-3
|
||||||
|
targenDevicePower => Toggle+Slider 0-3
|
||||||
|
}
|
||||||
|
|
||||||
|
map "stage-2 Stage2View.swift" as s2 {
|
||||||
|
cmWarningBanner => Banner : always on stage 2
|
||||||
|
instrumentSelect => Picker : chart code, not USB port
|
||||||
|
pageSizeSelect => Picker
|
||||||
|
customPageW / customPageH => TextField : pageSize custom
|
||||||
|
tiffDpi => Stepper 72-600
|
||||||
|
printtargLayoutOrder => Picker
|
||||||
|
printtargCustomSeed => TextField : custom seed order
|
||||||
|
btnToggleLabelEdit => Button
|
||||||
|
targetMetadataPrinter => TextField
|
||||||
|
targetMetadataInkSet => TextField
|
||||||
|
targetMetadataDriverPaper => TextField
|
||||||
|
targetMetadataActualPaper => TextField
|
||||||
|
targetLabelPreview => TextField or Text
|
||||||
|
btnCreateLayout => Button : disabled if no basename
|
||||||
|
tiffGallery => Grid : after printtarg
|
||||||
|
btnPrintPage-N => Button : disabled if no printer
|
||||||
|
}
|
||||||
|
|
||||||
|
map "rawPrintPanel" as prn {
|
||||||
|
printerSelect => Picker CUPS
|
||||||
|
printerStatusBadge => Caption
|
||||||
|
btnRefreshPrinters => Button
|
||||||
|
btnPrinterProperties => Button : disabled if no printer
|
||||||
|
printerTraySelect => Picker : if trays exist
|
||||||
|
printerMediaTypeSelect => Picker : if media types exist
|
||||||
|
btnOrientPortrait => Button
|
||||||
|
btnOrientLandscape => Button
|
||||||
|
btnPrintAll => Button : needs layout AND printer
|
||||||
|
btnAdvanceToStage3 => Button : needs .ti2 unlock
|
||||||
|
printNotificationText => Caption : if printNotice set
|
||||||
|
}
|
||||||
|
|
||||||
|
s1 -[hidden]down- adv
|
||||||
|
adv -[hidden]down- s2
|
||||||
|
s2 -[hidden]down- prn
|
||||||
|
@enduml
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
@startuml ICCery-UI-stage3-5-cal
|
||||||
|
title Stages 3-5 and Calibrate Printer
|
||||||
|
!theme plain
|
||||||
|
skinparam backgroundColor white
|
||||||
|
skinparam defaultFontColor black
|
||||||
|
skinparam defaultFontSize 13
|
||||||
|
skinparam mapBackgroundColor white
|
||||||
|
skinparam mapBorderColor #222222
|
||||||
|
skinparam mapFontColor black
|
||||||
|
skinparam arrowColor #222222
|
||||||
|
skinparam shadowing false
|
||||||
|
hide circle
|
||||||
|
top to bottom direction
|
||||||
|
|
||||||
|
map "stage-3 Stage3View.swift" as s3 {
|
||||||
|
btnDetectInstruments => Button : disabled while detecting
|
||||||
|
chartreadInstrumentSelect => Picker.menu
|
||||||
|
xyTableHint => Caption : if XY instrument
|
||||||
|
xyTablePanel => Place/Align/Scan/Remove
|
||||||
|
chartreadPrompt => Text
|
||||||
|
chartreadLastError => Caption : if notice set
|
||||||
|
btnStartRead => Button : shown if not running; needs basename+cwd
|
||||||
|
btnCalibrate => Button : running and calibrating
|
||||||
|
btnTrigger => Button : running and awaitingStrip
|
||||||
|
btnDoneReadEarly => Button : awaitingStrip
|
||||||
|
btnAccept => Button : place / align / continue / warning
|
||||||
|
btnRetry => Button : error state
|
||||||
|
btnDoneRead => Button : allStripsRead
|
||||||
|
btnCancel => Button : while running
|
||||||
|
swatchGrid => swatch-rowId-loc
|
||||||
|
btnMeasureAnotherSheet => Button : after a finished pass
|
||||||
|
btnFinishAndAverage => Button : finished AND at least one pass
|
||||||
|
}
|
||||||
|
|
||||||
|
map "stage-4 Stage4View.swift" as s4 {
|
||||||
|
colprofAlgorithm => Picker
|
||||||
|
colprofQuality => Picker
|
||||||
|
colprofFwa => Picker
|
||||||
|
colprofFwaCustomPath => TextField : custom spectrum
|
||||||
|
btnBrowseFwaSp => Button
|
||||||
|
colprofIlluminant => TextField
|
||||||
|
colprofObserver => TextField
|
||||||
|
colprofInputViewCond => TextField
|
||||||
|
colprofOutputViewCond => TextField
|
||||||
|
colprofDescription => TextField
|
||||||
|
colprofCopyright => TextField
|
||||||
|
colprofApplyCalibration => Toggle
|
||||||
|
colprofCalibrationFile => TextField : if applyCalibration
|
||||||
|
btnBrowseCalibrationFile => Button : if applyCalibration
|
||||||
|
btnCreateProfile => Button : basename + cwd, not running
|
||||||
|
}
|
||||||
|
|
||||||
|
map "stage-5 Stage5View.swift" as s5 {
|
||||||
|
btnVerifyProfile => Button : needs created profile
|
||||||
|
btnViewGamut => Button : disabled if no .gam (same id as sidebar)
|
||||||
|
btnInstallProfile => Button : needs created profile
|
||||||
|
driftPrinterFilter => Picker
|
||||||
|
btnExportHistory => Button
|
||||||
|
btnClearHistory => Button
|
||||||
|
verificationHistoryTable => Table
|
||||||
|
driftChart => not interactive
|
||||||
|
profileOverwriteBtn => Alert : install collision
|
||||||
|
profileRenameBtn => Alert
|
||||||
|
profileCancelCollisionBtn => Alert
|
||||||
|
}
|
||||||
|
|
||||||
|
map "stage-cal CalibrationView.swift" as cal {
|
||||||
|
Colour space => Picker.segmented : no a11y id
|
||||||
|
calSteps => TextField
|
||||||
|
White patches => TextField : no a11y id
|
||||||
|
calInkExplore => TextField : CMYK only
|
||||||
|
calNeutralEmphasis => Toggle
|
||||||
|
btnCalGenerate => Button : needs basename + cwd
|
||||||
|
btnCalLayout => Button : same
|
||||||
|
btnCalMeasure => Button : needs .ti3
|
||||||
|
btnCalCompute => Button : .ti3 and not computing
|
||||||
|
calApplyToggle => Toggle : after curves exist
|
||||||
|
btnCalReturn => Button Esc : restores original basename
|
||||||
|
}
|
||||||
|
|
||||||
|
s3 -[hidden]down- s4
|
||||||
|
s4 -[hidden]down- s5
|
||||||
|
s5 -[hidden]down- cal
|
||||||
|
@enduml
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Interactive UI map
|
||||||
|
|
||||||
|
PlantUML **maps** (not class diagrams). Class diagrams laid out 6500px wide, so Gitea on a phone showed a sliver of a black arrow and the UML “C” icon.
|
||||||
|
|
||||||
|
Each file is one stacked column (~560–780px) that Kroki actually fits in the page.
|
||||||
|
|
||||||
|
| Diagram | File | Kroki size |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Shell, sidebar, stepper, File menu | [ui-interactive-map-shell.puml](ui-interactive-map-shell.puml) | 563 × 1367 |
|
||||||
|
| Stages 1–2 | [ui-interactive-map-stage1-2.puml](ui-interactive-map-stage1-2.puml) | 625 × 1366 |
|
||||||
|
| Stages 3–5 + Calibrate | [ui-interactive-map-stage3-5-cal.puml](ui-interactive-map-stage3-5-cal.puml) | 568 × 1386 |
|
||||||
|
| Sheets | [ui-interactive-map-sheets.puml](ui-interactive-map-sheets.puml) | 782 × 1502 |
|
||||||
@@ -21,6 +21,8 @@ targets:
|
|||||||
- ICCery.entitlements
|
- ICCery.entitlements
|
||||||
- ICCery.Debug.entitlements
|
- ICCery.Debug.entitlements
|
||||||
- Argyll
|
- Argyll
|
||||||
|
- dmg-background.png
|
||||||
|
- dmg-background@2x.png
|
||||||
- path: Resources/Argyll
|
- path: Resources/Argyll
|
||||||
type: folder
|
type: folder
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ if [ -z "$TAG" ] || [ "$TAG" = "${GITHUB_REF:-}" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
case "$TAG" in
|
||||||
|
*prerelease*) PRERELEASE=true ;;
|
||||||
|
*) PRERELEASE=false ;;
|
||||||
|
esac
|
||||||
|
|
||||||
DMG="${1:-}"
|
DMG="${1:-}"
|
||||||
if [ -z "$DMG" ]; then
|
if [ -z "$DMG" ]; then
|
||||||
DMG="$(ls -1 ICCery-*.dmg 2>/dev/null | head -n 1 || true)"
|
DMG="$(ls -1 ICCery-*.dmg 2>/dev/null | head -n 1 || true)"
|
||||||
@@ -49,12 +54,12 @@ STATUS="$(curl -sS -o "$BODY" -w '%{http_code}' \
|
|||||||
"$API/repos/$REPO/releases/tags/$TAG" || true)"
|
"$API/repos/$REPO/releases/tags/$TAG" || true)"
|
||||||
|
|
||||||
if [ "$STATUS" = "404" ]; then
|
if [ "$STATUS" = "404" ]; then
|
||||||
echo "==> Creating release $TAG"
|
echo "==> Creating release $TAG (prerelease=$PRERELEASE)"
|
||||||
STATUS="$(curl -sS -o "$BODY" -w '%{http_code}' \
|
STATUS="$(curl -sS -o "$BODY" -w '%{http_code}' \
|
||||||
-H "Authorization: token $TOKEN" \
|
-H "Authorization: token $TOKEN" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-X POST "$API/repos/$REPO/releases" \
|
-X POST "$API/repos/$REPO/releases" \
|
||||||
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"prerelease\":true,\"target_commitish\":\"${GITHUB_SHA:-}\"}")"
|
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"prerelease\":$PRERELEASE,\"target_commitish\":\"${GITHUB_SHA:-}\"}")"
|
||||||
fi
|
fi
|
||||||
if [ "$STATUS" != "200" ] && [ "$STATUS" != "201" ]; then
|
if [ "$STATUS" != "200" ] && [ "$STATUS" != "201" ]; then
|
||||||
echo "error: could not load/create release $TAG (HTTP $STATUS)" >&2
|
echo "error: could not load/create release $TAG (HTTP $STATUS)" >&2
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# scripts/dmgbuild-settings.py
|
# scripts/dmgbuild-settings.py
|
||||||
#
|
#
|
||||||
# dmgbuild settings for ICCery. Set DMG_APP, DMG_FILENAME and DMG_VOLUME_NAME
|
# dmgbuild settings for ICCery. scripts/package-release.sh exports
|
||||||
# in the environment, or accept the defaults. Background art can be supplied
|
# DMG_APP, DMG_FILENAME, DMG_VOLUME_NAME, and DMG_BACKGROUND (a
|
||||||
# later by placing a PNG at Resources/dmg-background.png and setting
|
# HiDPI TIFF). A missing background is a hard error — a grey
|
||||||
# DMG_BACKGROUND.
|
# Finder window is not an acceptable release artefact (#95).
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
@@ -23,15 +23,23 @@ if not app_path or not app_path.endswith('.app') or not os.path.isdir(app_path):
|
|||||||
|
|
||||||
files = [app_path]
|
files = [app_path]
|
||||||
|
|
||||||
# Background art is optional. If the referenced PNG does not exist, fall back
|
# Finder on Sonoma+ is picky about PNG-with-alpha window pictures and
|
||||||
# to a plain window. See docs/23-assets.md for the DMG background spec.
|
# about classic Alias Manager blobs. package-release.sh always passes
|
||||||
background = os.environ.get('DMG_BACKGROUND', 'Resources/dmg-background.png')
|
# a flattened HiDPI TIFF as DMG_BACKGROUND. dmgbuild 1.6.7 (bookmark
|
||||||
if background and not os.path.exists(background):
|
# .DS_Store) needs Python >= 3.10, which the Monterey runner does not
|
||||||
background = None
|
# have; 1.6.5 + TIFF is what CI can ship (#95).
|
||||||
|
background = os.environ.get('DMG_BACKGROUND', '')
|
||||||
|
if not background or not os.path.isfile(background):
|
||||||
|
sys.stderr.write(
|
||||||
|
'error: DMG_BACKGROUND must point at an existing image '
|
||||||
|
'(got %r)\n' % background)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
icon = None
|
icon = None
|
||||||
|
|
||||||
# Window size is enough for the app icon and the Applications alias.
|
# Window size is enough for the app icon and the Applications alias.
|
||||||
|
# Bitmap is slightly larger than this rect so title-bar chrome on
|
||||||
|
# 14+ does not crop the wordmark.
|
||||||
window_rect = ((100, 100), (660, 400))
|
window_rect = ((100, 100), (660, 400))
|
||||||
|
|
||||||
# Use icon view without extra chrome.
|
# Use icon view without extra chrome.
|
||||||
|
|||||||
@@ -2,19 +2,79 @@
|
|||||||
# scripts/ensure-host-tools.sh
|
# scripts/ensure-host-tools.sh
|
||||||
#
|
#
|
||||||
# Bootstrap host tools needed by CI on the macOS 12 runner:
|
# Bootstrap host tools needed by CI on the macOS 12 runner:
|
||||||
# - xcodegen: pinned prebuilt release from GitHub (Homebrew's current
|
# - xcodegen: always. Pinned prebuilt release from GitHub (Homebrew's
|
||||||
# formula requires Xcode 15.3, which cannot be installed on macOS 12).
|
# current formula requires Xcode 15.3, which cannot be installed on
|
||||||
# - dmgbuild: via pip3 (used by scripts/package-release.sh).
|
# macOS 12).
|
||||||
|
# - dmgbuild: only when INSTALL_DMGBUILD=1 or --dmgbuild. Isolated in
|
||||||
|
# build/.venv-dmgbuild so the test job never pip-installs it.
|
||||||
#
|
#
|
||||||
# Safe to run repeatedly: existing tools are left alone.
|
# dmgbuild 1.6.6+, ds_store 1.3.2+ and mac_alias 2.2.3 declare
|
||||||
|
# Requires-Python >= 3.10. The wheels are py3-none-any and run on the
|
||||||
|
# runner's 3.9; PIP_IGNORE_REQUIRES_PYTHON is required or pip will only
|
||||||
|
# offer 1.6.5 and keep a cached venv on that version (#95).
|
||||||
|
# pip itself is capped at <26.1: 26.1+ needs Python 3.10.
|
||||||
|
#
|
||||||
|
# Safe to run repeatedly: existing tools are left alone unless the
|
||||||
|
# dmgbuild pin is not met.
|
||||||
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
|
ROOT="$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||||
XCODEGEN_VERSION="2.38.0"
|
XCODEGEN_VERSION="2.38.0"
|
||||||
INSTALL_ROOT="${XCODEGEN_HOME:-$HOME/.local/xcodegen/$XCODEGEN_VERSION}"
|
INSTALL_ROOT="${XCODEGEN_HOME:-$HOME/.local/xcodegen/$XCODEGEN_VERSION}"
|
||||||
|
VENV="$ROOT/build/.venv-dmgbuild"
|
||||||
|
DMGBUILD_PIN="1.6.7"
|
||||||
|
|
||||||
echo "==> Ensuring dmgbuild"
|
INSTALL_DMGBUILD="${INSTALL_DMGBUILD:-0}"
|
||||||
python3 -c "import dmgbuild" 2>/dev/null || pip3 install dmgbuild
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--dmgbuild) INSTALL_DMGBUILD=1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$INSTALL_DMGBUILD" = "1" ]; then
|
||||||
|
echo "==> Ensuring dmgbuild==$DMGBUILD_PIN in $VENV"
|
||||||
|
mkdir -p "$ROOT/build"
|
||||||
|
# pip 26.1+ requires Python 3.10 (dataclass slots). A leftover
|
||||||
|
# `pip install --upgrade pip` on this 3.9 venv installed 26.2.1 and
|
||||||
|
# the next pip invocation crashed. Recreate if pip is already dead.
|
||||||
|
if [ -x "$VENV/bin/python" ] \
|
||||||
|
&& ! "$VENV/bin/python" -m pip --version >/dev/null 2>&1; then
|
||||||
|
echo "==> venv pip is broken; recreating $VENV"
|
||||||
|
rm -rf "$VENV"
|
||||||
|
fi
|
||||||
|
if [ ! -x "$VENV/bin/python" ]; then
|
||||||
|
python3 -m venv "$VENV"
|
||||||
|
fi
|
||||||
|
# Without this, pip on Python 3.9 hides 1.6.6+ and leaves 1.6.5.
|
||||||
|
PIP_IGNORE_REQUIRES_PYTHON=1
|
||||||
|
export PIP_IGNORE_REQUIRES_PYTHON
|
||||||
|
"$VENV/bin/python" -m pip install --upgrade 'pip>=24.3,<26.1'
|
||||||
|
"$VENV/bin/python" -m pip install --upgrade --force-reinstall \
|
||||||
|
"dmgbuild==$DMGBUILD_PIN" \
|
||||||
|
'ds_store>=1.3.3' \
|
||||||
|
'mac_alias>=2.2.3'
|
||||||
|
"$VENV/bin/python" -c 'from importlib.metadata import version
|
||||||
|
print("dmgbuild", version("dmgbuild"))
|
||||||
|
print("ds_store", version("ds_store"))
|
||||||
|
print("mac_alias", version("mac_alias"))
|
||||||
|
parts=[]
|
||||||
|
for p in version("dmgbuild").split("."):
|
||||||
|
try:
|
||||||
|
parts.append(int("".join(c for c in p if c.isdigit()) or "0"))
|
||||||
|
except ValueError:
|
||||||
|
parts.append(0)
|
||||||
|
parts += [0, 0, 0]
|
||||||
|
raise SystemExit(0 if tuple(parts[:3]) >= (1, 6, 7) else 1)
|
||||||
|
'
|
||||||
|
if [ -n "${GITHUB_PATH:-}" ]; then
|
||||||
|
echo "$VENV/bin" >> "$GITHUB_PATH"
|
||||||
|
fi
|
||||||
|
PATH="$VENV/bin:$PATH"
|
||||||
|
export PATH
|
||||||
|
else
|
||||||
|
echo "==> Skipping dmgbuild (set INSTALL_DMGBUILD=1 for the package job)"
|
||||||
|
fi
|
||||||
|
|
||||||
if command -v xcodegen >/dev/null 2>&1; then
|
if command -v xcodegen >/dev/null 2>&1; then
|
||||||
echo "==> xcodegen already on PATH: $(xcodegen --version)"
|
echo "==> xcodegen already on PATH: $(xcodegen --version)"
|
||||||
|
|||||||
@@ -103,19 +103,39 @@ EOF
|
|||||||
scripts/verify-sidecar-signatures.sh "$APP"
|
scripts/verify-sidecar-signatures.sh "$APP"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "==> Installing / locating dmgbuild"
|
echo "==> Locating dmgbuild"
|
||||||
|
# The package CI job already ran INSTALL_DMGBUILD=1 ensure-host-tools.sh,
|
||||||
|
# which created build/.venv-dmgbuild and prepended it to PATH. Local
|
||||||
|
# runs bootstrap the same venv if dmgbuild is missing.
|
||||||
|
VENV="$ROOT/build/.venv-dmgbuild"
|
||||||
if ! command -v dmgbuild >/dev/null 2>&1; then
|
if ! command -v dmgbuild >/dev/null 2>&1; then
|
||||||
VENV="$ROOT/build/.venv-dmgbuild"
|
if [ ! -x "$VENV/bin/dmgbuild" ]; then
|
||||||
if [ ! -d "$VENV/bin" ]; then
|
INSTALL_DMGBUILD=1 "$ROOT/scripts/ensure-host-tools.sh" --dmgbuild
|
||||||
python3 -m venv "$VENV"
|
|
||||||
"$VENV/bin/pip" install --upgrade pip
|
|
||||||
"$VENV/bin/pip" install dmgbuild
|
|
||||||
fi
|
fi
|
||||||
PATH="$VENV/bin:$PATH"
|
PATH="$VENV/bin:$PATH"
|
||||||
export PATH
|
export PATH
|
||||||
fi
|
fi
|
||||||
if ! command -v dmgbuild >/dev/null 2>&1; then
|
if ! command -v dmgbuild >/dev/null 2>&1; then
|
||||||
echo "error: dmgbuild not available. Try 'python3 -m venv .venv && pip install dmgbuild'" >&2
|
echo "error: dmgbuild not on PATH; run INSTALL_DMGBUILD=1 scripts/ensure-host-tools.sh" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "dmgbuild $(command -v dmgbuild)"
|
||||||
|
if [ -x "$VENV/bin/python" ]; then
|
||||||
|
"$VENV/bin/python" -c 'from importlib.metadata import version; print("dmgbuild", version("dmgbuild"))'
|
||||||
|
fi
|
||||||
|
|
||||||
|
PNG1X="$ROOT/Resources/dmg-background.png"
|
||||||
|
PNG2X="$ROOT/Resources/dmg-background@2x.png"
|
||||||
|
if [ ! -f "$PNG1X" ] || [ ! -f "$PNG2X" ]; then
|
||||||
|
echo "error: missing $PNG1X or $PNG2X" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
mkdir -p "$ROOT/build"
|
||||||
|
DMG_BACKGROUND="$ROOT/build/dmg-background.tiff"
|
||||||
|
echo "==> Building HiDPI DMG background TIFF"
|
||||||
|
tiffutil -cathidpicheck "$PNG1X" "$PNG2X" -out "$DMG_BACKGROUND"
|
||||||
|
if [ ! -f "$DMG_BACKGROUND" ]; then
|
||||||
|
echo "error: tiffutil did not write $DMG_BACKGROUND" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -128,6 +148,7 @@ VOLUME_NAME="ICCery ${VERSION}"
|
|||||||
DMG_APP="$APP" \
|
DMG_APP="$APP" \
|
||||||
DMG_FILENAME="$DMG" \
|
DMG_FILENAME="$DMG" \
|
||||||
DMG_VOLUME_NAME="$VOLUME_NAME" \
|
DMG_VOLUME_NAME="$VOLUME_NAME" \
|
||||||
|
DMG_BACKGROUND="$DMG_BACKGROUND" \
|
||||||
dmgbuild -s scripts/dmgbuild-settings.py "$VOLUME_NAME" "$DMG"
|
dmgbuild -s scripts/dmgbuild-settings.py "$VOLUME_NAME" "$DMG"
|
||||||
|
|
||||||
echo "DMG: $PWD/$DMG"
|
echo "DMG: $PWD/$DMG"
|
||||||
|
|||||||
Reference in New Issue
Block a user