Compare commits
51
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d40932f4cf | ||
|
|
1931da8448 | ||
|
|
f896f60d1a | ||
|
|
2f305c481e | ||
|
|
4249067345 | ||
|
|
800c980137 | ||
|
|
67fb3a9b37 | ||
|
|
eb1503aba9 | ||
|
|
db47a975f0 | ||
|
|
990c49a436 | ||
|
|
60972a0704 | ||
|
|
aa32cc45d3 | ||
|
|
17c47af9f1 | ||
|
|
5bea928042 | ||
|
|
5c8973de73 | ||
|
|
29ecc79585 | ||
|
|
7c5a5e7798 | ||
|
|
607a4e0df4 | ||
|
|
67199877a2 | ||
|
|
9a6da4648f | ||
|
|
b5250591ea | ||
|
|
c1ec07f726 | ||
|
|
a975f1a4e4 | ||
|
|
7c2282a7dd | ||
|
|
caeafb22be | ||
|
|
aac4f10d23 | ||
|
|
b7ac4f92ed | ||
|
|
3244efd366 | ||
|
|
7a05da3a35 | ||
|
|
5134e82e63 | ||
|
|
7fd93247cb | ||
|
|
a0fcede454 | ||
|
|
97eafd11fb | ||
|
|
ed7487c2b6 | ||
|
|
a2e3f11e70 | ||
|
|
c3c9bbc5ba | ||
|
|
b5683aa36a | ||
|
|
707455dcfb | ||
|
|
17ee5d6717 | ||
|
|
2295bcdbae | ||
|
|
c9bff50a1a | ||
|
|
1b3d3dd821 | ||
|
|
55dee0f0cd | ||
|
|
45fd2b988f | ||
|
|
7c6ad1e6ce | ||
|
|
9857ddb4d0 | ||
|
|
7a6b82816b | ||
|
|
32d2184d2e | ||
|
|
a76120d9f7 | ||
|
|
a30a8fc551 | ||
|
|
83f3a4f0e2 |
@@ -21,29 +21,52 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Assert Xcode 14 toolchain
|
- name: Assert Xcode 14+ toolchain
|
||||||
run: xcodebuild -version | grep -E "Xcode 14." || (echo "Unexpected Xcode version" && exit 1)
|
|
||||||
|
|
||||||
- name: Ensure host tools
|
|
||||||
run: |
|
run: |
|
||||||
command -v xcodegen || brew install xcodegen
|
line="$(xcodebuild -version | head -1)"
|
||||||
python3 -c "import dmgbuild" 2>/dev/null || pip3 install dmgbuild
|
major="$(printf '%s' "$line" | sed -n 's/^Xcode \([0-9][0-9]*\)\..*/\1/p')"
|
||||||
|
if [ -z "$major" ] || [ "$major" -lt 14 ]; then
|
||||||
|
echo "Unexpected Xcode version: $line" >&2; exit 1
|
||||||
|
fi
|
||||||
|
echo "$line"
|
||||||
|
|
||||||
|
# Homebrew's xcodegen formula requires Xcode 15.3, which cannot be
|
||||||
|
# installed on macOS 12 (#109). The script installs a pinned
|
||||||
|
# prebuilt release instead.
|
||||||
|
- name: Ensure host tools
|
||||||
|
run: scripts/ensure-host-tools.sh
|
||||||
|
|
||||||
- name: Generate Xcode project
|
- name: Generate Xcode project
|
||||||
run: xcodegen generate --spec project.yml
|
run: xcodegen generate --spec project.yml
|
||||||
|
|
||||||
- name: Build for testing (universal)
|
# Tests only ever run on the runner's own architecture; build
|
||||||
|
# just that slice. Packaging (scripts/package-release.sh) still
|
||||||
|
# produces the universal Release binary.
|
||||||
|
- name: Build for testing (host arch)
|
||||||
run: |
|
run: |
|
||||||
xcodebuild build-for-testing \
|
xcodebuild build-for-testing \
|
||||||
-scheme ICCery \
|
-scheme ICCery \
|
||||||
-destination 'platform=macOS' \
|
-destination 'platform=macOS' \
|
||||||
-derivedDataPath "$DERIVED" \
|
-derivedDataPath "$DERIVED" \
|
||||||
-configuration Debug \
|
-configuration Debug \
|
||||||
ARCHS='arm64 x86_64' \
|
ARCHS="$(uname -m)" \
|
||||||
ONLY_ACTIVE_ARCH=NO \
|
ONLY_ACTIVE_ARCH=NO \
|
||||||
CODE_SIGNING_ALLOWED=YES \
|
CODE_SIGNING_ALLOWED=YES \
|
||||||
CODE_SIGN_IDENTITY='-'
|
CODE_SIGN_IDENTITY='-'
|
||||||
|
|
||||||
|
# Xcode embeds the shared ICCeryCore package framework into the app
|
||||||
|
# and the test bundle without signing it. Ad-hoc hosts still require
|
||||||
|
# every loaded dylib to carry a cdhash — dyld killed the test host at
|
||||||
|
# launch (run 31992) — so sign every embedded copy once the build is
|
||||||
|
# done (embed steps run after any build script phase) (#119).
|
||||||
|
- name: Sign package product frameworks
|
||||||
|
run: |
|
||||||
|
find "$DERIVED/Build/Products/Debug" -depth -name '*_PackageProduct.framework' -print0 \
|
||||||
|
| while IFS= read -r -d '' fw; do
|
||||||
|
echo "signing $fw"
|
||||||
|
codesign --force --sign - --timestamp=none "$fw"
|
||||||
|
done
|
||||||
|
|
||||||
- name: Test unit (ICCeryCoreTests)
|
- name: Test unit (ICCeryCoreTests)
|
||||||
run: |
|
run: |
|
||||||
XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)"
|
XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)"
|
||||||
@@ -110,7 +133,8 @@ jobs:
|
|||||||
echo "error: UI probe failed with a real test error" >&2
|
echo "error: UI probe failed with a real test error" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if run_ui "full suite attempt $attempt" -only-testing:ICCeryUITests; then
|
if run_ui "full suite attempt $attempt" -only-testing:ICCeryUITests \
|
||||||
|
-skip-testing:ICCeryUITests/AboutHelpUITests/testAboutDialogShowsVersionAndBuildDate; then
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
if is_runner_attach_failure; then
|
if is_runner_attach_failure; then
|
||||||
@@ -126,6 +150,30 @@ jobs:
|
|||||||
echo "warning: skipping UI tests after repeated runner attach/activate failures"
|
echo "warning: skipping UI tests after repeated runner attach/activate failures"
|
||||||
exit 0
|
exit 0
|
||||||
|
|
||||||
|
# XCTest stores the a11y hierarchy snapshot and screenshots in the
|
||||||
|
# xcresult on failure — upload it so UI failures can be triaged
|
||||||
|
# without access to the runner (#126).
|
||||||
|
- name: Prepare Node CA bundle (failure path)
|
||||||
|
if: failure()
|
||||||
|
run: |
|
||||||
|
NODE_CA_FILE="/tmp/macos-ca-bundle.pem"
|
||||||
|
security find-certificate -a -p \
|
||||||
|
/System/Library/Keychains/SystemRootCertificates.keychain \
|
||||||
|
/Library/Keychains/System.keychain \
|
||||||
|
> "$NODE_CA_FILE" 2>/dev/null || true
|
||||||
|
if [ ! -s "$NODE_CA_FILE" ] && [ -f /etc/ssl/cert.pem ]; then
|
||||||
|
cp /etc/ssl/cert.pem "$NODE_CA_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Upload UI test xcresult
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
env:
|
||||||
|
NODE_EXTRA_CA_CERTS: /tmp/macos-ca-bundle.pem
|
||||||
|
with:
|
||||||
|
name: ui-test-xcresult
|
||||||
|
path: build/DerivedData-test/Logs/Test
|
||||||
|
|
||||||
package:
|
package:
|
||||||
needs: build-and-test
|
needs: build-and-test
|
||||||
runs-on: macos-12
|
runs-on: macos-12
|
||||||
@@ -134,6 +182,11 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# scripts/package-release.sh runs `xcodegen generate` and dmgbuild;
|
||||||
|
# see build-and-test for why brew is not used on macOS 12 (#109).
|
||||||
|
- name: Ensure host tools
|
||||||
|
run: scripts/ensure-host-tools.sh
|
||||||
|
|
||||||
- name: Package release
|
- name: Package release
|
||||||
run: scripts/package-release.sh
|
run: scripts/package-release.sh
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -45,9 +45,10 @@ PRs via Gitea MCP. Every issue/PR: `Project/ICCery-v2` + `Feature/*` or `Bug/*`
|
|||||||
|
|
||||||
## Verify
|
## Verify
|
||||||
```
|
```
|
||||||
xcodebuild test -scheme ICCery -destination 'platform=macOS' ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
|
xcodebuild test -scheme ICCery -destination 'platform=macOS' ARCHS="$(uname -m)"
|
||||||
codesign -dvv <sidecar>
|
codesign -dvv <sidecar>
|
||||||
```
|
```
|
||||||
|
Universal (`ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO`) is still required for release verification / packaging.
|
||||||
|
|
||||||
## Private ColorSync SPI
|
## Private ColorSync SPI
|
||||||
2-arg `(PMPrintSession, CFStringRef) -> OSStatus`. Never pass integer `1`.
|
2-arg `(PMPrintSession, CFStringRef) -> OSStatus`. Never pass integer `1`.
|
||||||
|
|||||||
+7
-2
@@ -1,6 +1,6 @@
|
|||||||
# BUILD-PLAN.md — ICCery v2 Mac
|
# BUILD-PLAN.md — ICCery v2 Mac
|
||||||
|
|
||||||
Spec snapshot: `docs/`. Source of tickets: Gitea milestones M1–M6 + Later.
|
Spec snapshot: `docs/`. Source of tickets: Gitea milestones M1–M6 + M10 (id 32) + Later.
|
||||||
|
|
||||||
## Sprint rule
|
## Sprint rule
|
||||||
Do not start milestone N+1 implementation until milestone N **CI/mock gate** is green.
|
Do not start milestone N+1 implementation until milestone N **CI/mock gate** is green.
|
||||||
@@ -17,9 +17,14 @@ Hardware gates block *release of that sprint*, not filing, and not starting codi
|
|||||||
| M5 | Profile / verify / install | 23–27 | colprof → `.icc`; profcheck parse; atomic history; install into temp dir | Full `.ti1`→`.icc`; profile visible in ColorSync Utility |
|
| M5 | Profile / verify / install | 23–27 | colprof → `.icc`; profcheck parse; atomic history; install into temp dir | Full `.ti1`→`.icc`; profile visible in ColorSync Utility |
|
||||||
| M6 | Gamut, Stage 0, CGATS, release | 28–32 | `.gam` fixtures; cal argv; CGATS round-trip; signed sidecars; dmgbuild | Stage 0 on a real printer; gamut of a real profile |
|
| M6 | Gamut, Stage 0, CGATS, release | 28–32 | `.gam` fixtures; cal argv; CGATS round-trip; signed sidecars; dmgbuild | Stage 0 on a real printer; gamut of a real profile |
|
||||||
| M7 | Deduplicate & consolidate | 79–86 | Shared runner loop; JSONFileStore; preset↔config maps; Notice/log helper; ProcessManager factory; PrintSession VM; identity + colour-type cleanup | N/A |
|
| M7 | Deduplicate & consolidate | 79–86 | Shared runner loop; JSONFileStore; preset↔config maps; Notice/log helper; ProcessManager factory; PrintSession VM; identity + colour-type cleanup | N/A |
|
||||||
|
| M8 | Deduplicate & consolidate | 79–86 | (already shipped on `develop`) | N/A |
|
||||||
|
| M9 | macOS 12 / Xcode 14.2 retarget | (milestone/m9-monterey, PR #145) | XCTest + ObservableObject + macos-12 CI | N/A |
|
||||||
|
| M10 | Studio workflow | 146–149 | Media library + spot-read + gamut compare + project file unit/UI smoke | Real printer+paper+.cal; live spot-read; two `.gam`; reopen `.icceryproj` |
|
||||||
| Later | Quartz / TargetPrint | 16 | `ICCeryPrintKit` standalone + seam test | 1:1 on paper vs TIFF |
|
| Later | Quartz / TargetPrint | 16 | `ICCeryPrintKit` standalone + seam test | 1:1 on paper vs TIFF |
|
||||||
|
|
||||||
Issue **16 is not an M3 or M6 exit gate.**
|
M8 and M9 merged to `develop` via PR #104 / #145; M10 starts from `800c980`.
|
||||||
|
|
||||||
|
Issue **16 is not an M3, M6, or M10 exit gate.**
|
||||||
|
|
||||||
## Branch taxonomy
|
## Branch taxonomy
|
||||||
|
|
||||||
|
|||||||
@@ -192,6 +192,18 @@ public struct ArgyllRunner: Sendable {
|
|||||||
|
|
||||||
// MARK: - Shared collection
|
// MARK: - Shared collection
|
||||||
|
|
||||||
|
/// DEBUG-only fast path: under `ICCERY_UI_TESTING=1` polling/wait
|
||||||
|
/// intervals shrink ~10x — same env convention as `AppPaths.testRoot`.
|
||||||
|
/// Release builds compile the branch out entirely; no static state.
|
||||||
|
private static func testAwareDelay(_ nanos: UInt64) -> UInt64 {
|
||||||
|
#if DEBUG
|
||||||
|
if ProcessInfo.processInfo.environment["ICCERY_UI_TESTING"] == "1" {
|
||||||
|
return nanos / 10
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
return nanos
|
||||||
|
}
|
||||||
|
|
||||||
/// Cancels any previous child with the same id and waits for it to
|
/// Cancels any previous child with the same id and waits for it to
|
||||||
/// finalize, so `runStreaming` / `runCaptured` never sees a
|
/// finalize, so `runStreaming` / `runCaptured` never sees a
|
||||||
/// `duplicateID` from a leftover process (#50, #52).
|
/// `duplicateID` from a leftover process (#50, #52).
|
||||||
@@ -200,7 +212,7 @@ public struct ArgyllRunner: Sendable {
|
|||||||
await processManager.kill(id: id)
|
await processManager.kill(id: id)
|
||||||
var attempts = 0
|
var attempts = 0
|
||||||
while await processManager.isRunning(id), attempts < 30 {
|
while await processManager.isRunning(id), attempts < 30 {
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
try? await Task.sleep(nanoseconds: Self.testAwareDelay(100_000_000))
|
||||||
attempts += 1
|
attempts += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -592,7 +604,7 @@ public struct ArgyllRunner: Sendable {
|
|||||||
await processManager.setPreKillHook(id: processId) { [processManager] in
|
await processManager.setPreKillHook(id: processId) { [processManager] in
|
||||||
if isXY {
|
if isXY {
|
||||||
try? await processManager.sendStdin(id: processId, bytes: ChartreadInput.quit.bytes)
|
try? await processManager.sendStdin(id: processId, bytes: ChartreadInput.quit.bytes)
|
||||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
try? await Task.sleep(nanoseconds: Self.testAwareDelay(500_000_000))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -730,6 +742,148 @@ public struct ArgyllRunner: Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - spotread (spot-read console, issue #148)
|
||||||
|
|
||||||
|
/// Runs `spotread` and returns an `AsyncStream` of typed events.
|
||||||
|
///
|
||||||
|
/// Same subscribe-before-spawn shape as `runChartread`, but there is
|
||||||
|
/// no artefact: the stream ends with `.exit(code)`. The single-lease
|
||||||
|
/// process id is `ProcessID.spotread` — never `chartread_{basename}`.
|
||||||
|
/// Missing sidecar surfaces as `.failed`; there is no `$PATH` or
|
||||||
|
/// `chartread` fallback (#116, R14/R21).
|
||||||
|
public func runSpotread(config: SpotReadConfig) -> AsyncStream<SpotReadEvent> {
|
||||||
|
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
||||||
|
let args = SpotReadArgs.build(config: config)
|
||||||
|
let binaryURL = binaryResolver.resolve("spotread")
|
||||||
|
let processId = ProcessID.spotread
|
||||||
|
let processManager = self.processManager
|
||||||
|
let isXY = config.isXY
|
||||||
|
let instrumentName = config.instrumentName
|
||||||
|
let instrumentPort = config.instrumentPort
|
||||||
|
|
||||||
|
return AsyncStream { continuation in
|
||||||
|
let task = Task {
|
||||||
|
await ensureNotRunning(id: processId)
|
||||||
|
let events = processManager.events()
|
||||||
|
|
||||||
|
// XY parking hook before any kill, same as chartread.
|
||||||
|
await processManager.setPreKillHook(id: processId) { [processManager] in
|
||||||
|
if isXY {
|
||||||
|
try? await processManager.sendStdin(id: processId, bytes: ChartreadInput.quit.bytes)
|
||||||
|
try? await Task.sleep(nanoseconds: Self.testAwareDelay(500_000_000))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard binaryResolver.exists(binaryURL) else {
|
||||||
|
continuation.yield(.failed(ArgyllRunnerError.toolFailed(
|
||||||
|
tool: "spotread", code: -1,
|
||||||
|
logs: ["spotread sidecar missing — run fetch-argyll"])))
|
||||||
|
continuation.finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await processManager.runStreaming(
|
||||||
|
id: processId,
|
||||||
|
binary: binaryURL,
|
||||||
|
arguments: args,
|
||||||
|
workingDirectory: cwd
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
continuation.yield(.failed(ArgyllRunnerError.toolFailed(
|
||||||
|
tool: "spotread", code: -1, logs: [error.localizedDescription])))
|
||||||
|
continuation.finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var state: ChartreadState = .idle
|
||||||
|
var pendingLogs: [String] = []
|
||||||
|
var lastFlush = Date()
|
||||||
|
var exitCode: Int32?
|
||||||
|
|
||||||
|
func flushLogs() {
|
||||||
|
guard !pendingLogs.isEmpty else { return }
|
||||||
|
let batch = pendingLogs
|
||||||
|
pendingLogs.removeAll(keepingCapacity: true)
|
||||||
|
continuation.yield(.log(batch))
|
||||||
|
}
|
||||||
|
|
||||||
|
for await event in events {
|
||||||
|
guard event.id == processId else { continue }
|
||||||
|
|
||||||
|
switch event {
|
||||||
|
case .stdout(_, let line):
|
||||||
|
if let parsed = SpotReadParser.parse(line: line) {
|
||||||
|
continuation.yield(.sample(SpotReadSample(
|
||||||
|
lab: parsed.lab,
|
||||||
|
xyz: parsed.xyz,
|
||||||
|
instrumentName: instrumentName,
|
||||||
|
port: instrumentPort,
|
||||||
|
rawLine: line
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
let classified = SpotReadClassifier.classify(
|
||||||
|
line: line, previousState: state)
|
||||||
|
if classified.state != state
|
||||||
|
|| classified.requestedWarningKey != nil {
|
||||||
|
state = classified.state
|
||||||
|
continuation.yield(.prompt(classified))
|
||||||
|
}
|
||||||
|
pendingLogs.append(line)
|
||||||
|
|
||||||
|
case .stderr(_, let line):
|
||||||
|
pendingLogs.append(line)
|
||||||
|
|
||||||
|
case .jsonRow:
|
||||||
|
// spotread is never run with `-u`.
|
||||||
|
break
|
||||||
|
|
||||||
|
case .error(_, let message):
|
||||||
|
pendingLogs.append("Error: \(message)")
|
||||||
|
|
||||||
|
case .exit(_, let code):
|
||||||
|
exitCode = code
|
||||||
|
}
|
||||||
|
|
||||||
|
if exitCode == nil,
|
||||||
|
pendingLogs.count >= 20 || Date().timeIntervalSince(lastFlush) >= 0.1 {
|
||||||
|
flushLogs()
|
||||||
|
lastFlush = Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
if exitCode != nil {
|
||||||
|
flushLogs()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
continuation.yield(.exit(exitCode ?? -1))
|
||||||
|
continuation.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
continuation.onTermination = { _ in
|
||||||
|
task.cancel()
|
||||||
|
Task {
|
||||||
|
await processManager.kill(id: processId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send input bytes to the running `spotread` child. Reuses
|
||||||
|
/// `ChartreadInput` — the stdin protocol is identical.
|
||||||
|
public func sendSpotreadInput(_ input: ChartreadInput) async throws {
|
||||||
|
try await processManager.sendStdin(id: ProcessID.spotread, bytes: input.bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Terminate a running `spotread` child. The XY park (`q\n` +
|
||||||
|
/// ~500 ms) runs in the pre-kill hook registered by `runSpotread`.
|
||||||
|
public func cancelSpotread() {
|
||||||
|
Task {
|
||||||
|
await processManager.kill(id: ProcessID.spotread)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Stage 0 calibration
|
// MARK: - Stage 0 calibration
|
||||||
|
|
||||||
/// Generates a calibration wedge `.ti1`.
|
/// Generates a calibration wedge `.ti1`.
|
||||||
@@ -816,6 +970,20 @@ public enum ChartreadEvent: Sendable {
|
|||||||
case failed(ArgyllRunnerError)
|
case failed(ArgyllRunnerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Events emitted by a running `spotread` session (issue #148).
|
||||||
|
public enum SpotReadEvent: Sendable {
|
||||||
|
/// Classified prompt / state update (reuses `ChartreadState`).
|
||||||
|
case prompt(ChartreadClassifyResult)
|
||||||
|
/// A parsed `Result is …` sample line.
|
||||||
|
case sample(SpotReadSample)
|
||||||
|
/// A batched log chunk (stdout + stderr lines).
|
||||||
|
case log([String])
|
||||||
|
/// Process exited with the given code.
|
||||||
|
case exit(Int32)
|
||||||
|
/// Failure (missing sidecar, spawn error).
|
||||||
|
case failed(ArgyllRunnerError)
|
||||||
|
}
|
||||||
|
|
||||||
/// Exact bytes sent to `chartread` stdin.
|
/// Exact bytes sent to `chartread` stdin.
|
||||||
public enum ChartreadInput: Sendable {
|
public enum ChartreadInput: Sendable {
|
||||||
case trigger // " \n"
|
case trigger // " \n"
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Pure argv builder for Argyll's `spotread` tool (issue #148).
|
||||||
|
public enum SpotReadArgs {
|
||||||
|
|
||||||
|
/// Builds `spotread` argv per the Gronod fork protocol.
|
||||||
|
///
|
||||||
|
/// - Always `-v -e` (paper / reflective; never display `-d`).
|
||||||
|
/// - `-c N` is emitted only for `selectedPort != nil` and `N > 1`
|
||||||
|
/// (Auto and port 1 omit it, #111).
|
||||||
|
/// - `-Y l` (letter L) is emitted only when `enableLEDs` is `true` (#204).
|
||||||
|
/// - Never `-u`: the v2.0 `-u` policy covers printtarg + chartread +
|
||||||
|
/// profcheck only.
|
||||||
|
/// - No basename — `spotread` writes no artefact.
|
||||||
|
public static func build(config: SpotReadConfig) -> [String] {
|
||||||
|
var args: [String] = ["-v", "-e"]
|
||||||
|
|
||||||
|
if let port = config.selectedPort, port > 1 {
|
||||||
|
args.append(contentsOf: ["-c", "\(port)"])
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.enableLEDs {
|
||||||
|
args.append(contentsOf: ["-Y", "l"])
|
||||||
|
}
|
||||||
|
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Configuration for a `spotread` invocation (issue #148).
|
||||||
|
///
|
||||||
|
/// `spotread` writes no artefact; `workingDirectory` is still required
|
||||||
|
/// for the spawn (#59 — empty cwd is illegal).
|
||||||
|
public struct SpotReadConfig: Codable, Equatable, Sendable {
|
||||||
|
public var workingDirectory: URL?
|
||||||
|
/// Communication port for `spotread -c`.
|
||||||
|
/// `nil` means omit `-c` (Auto or port 1, #111). Never an array index.
|
||||||
|
public var selectedPort: Int?
|
||||||
|
/// Enable i1Pro 2 visual LEDs (`-Y l`, #204).
|
||||||
|
public var enableLEDs: Bool
|
||||||
|
/// Whether the selected instrument is an XY table — controls the
|
||||||
|
/// `q\n` + ~500 ms park before kill on cancel.
|
||||||
|
public var isXY: Bool
|
||||||
|
/// Display name stamped onto each `SpotReadSample`.
|
||||||
|
public var instrumentName: String
|
||||||
|
/// Instrument port stamped onto each sample (nil for Auto).
|
||||||
|
public var instrumentPort: Int?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
workingDirectory: URL? = nil,
|
||||||
|
selectedPort: Int? = nil,
|
||||||
|
enableLEDs: Bool = false,
|
||||||
|
isXY: Bool = false,
|
||||||
|
instrumentName: String = "",
|
||||||
|
instrumentPort: Int? = nil
|
||||||
|
) {
|
||||||
|
self.workingDirectory = workingDirectory
|
||||||
|
self.selectedPort = selectedPort
|
||||||
|
self.enableLEDs = enableLEDs
|
||||||
|
self.isXY = isXY
|
||||||
|
self.instrumentName = instrumentName
|
||||||
|
self.instrumentPort = instrumentPort
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Persistence for `MediaRecipe` entries (issue #146).
|
||||||
|
///
|
||||||
|
/// `media_library.json` is a sibling of `settings.json`, never a field
|
||||||
|
/// inside it. Writes are atomic via `JSONFileStore` → `AtomicFileWriter`
|
||||||
|
/// (`.tmp` + rename, #213). A corrupt file throws on load/upsert and is
|
||||||
|
/// never overwritten — the view model turns the throw into an empty
|
||||||
|
/// list plus a persistent warning banner.
|
||||||
|
public actor MediaLibraryStore {
|
||||||
|
|
||||||
|
/// Default cap.
|
||||||
|
public static let defaultCapacity = 200
|
||||||
|
|
||||||
|
/// Path to the JSON store.
|
||||||
|
public let url: URL
|
||||||
|
|
||||||
|
/// In-memory cache, kept in sync with disk.
|
||||||
|
private var recipes: [MediaRecipe] = []
|
||||||
|
|
||||||
|
/// Explicit load flag — an empty file is still "loaded".
|
||||||
|
private var loaded = false
|
||||||
|
|
||||||
|
private let capacity: Int
|
||||||
|
private let fileStore: JSONFileStore<[MediaRecipe]>
|
||||||
|
|
||||||
|
public init(
|
||||||
|
url: URL = AppPaths.appDataDir.appendingPathComponent("media_library.json"),
|
||||||
|
capacity: Int = defaultCapacity
|
||||||
|
) {
|
||||||
|
self.url = url
|
||||||
|
self.capacity = capacity
|
||||||
|
self.fileStore = JSONFileStore(
|
||||||
|
fileURL: url,
|
||||||
|
corrupt: .throwCorrupt,
|
||||||
|
defaultValue: { [] },
|
||||||
|
dateEncoding: .iso8601,
|
||||||
|
dateDecoding: .iso8601
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads recipes from disk. Returns the existing cache if already
|
||||||
|
/// loaded.
|
||||||
|
///
|
||||||
|
/// Throws when the file exists but cannot be parsed; the existing
|
||||||
|
/// file is never overwritten in that case and `loaded` stays false
|
||||||
|
/// so the next call re-reads.
|
||||||
|
public func load() throws -> [MediaRecipe] {
|
||||||
|
guard !loaded else { return recipes }
|
||||||
|
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||||
|
loaded = true
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
recipes = try fileStore.load()
|
||||||
|
loaded = true
|
||||||
|
return recipes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns all cached recipes.
|
||||||
|
public func all() -> [MediaRecipe] {
|
||||||
|
recipes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inserts or replaces a recipe matched by `id`, then writes
|
||||||
|
/// atomically. Replacement preserves `created` and bumps `updated`;
|
||||||
|
/// inserts beyond `capacity` throw `.capacityReached` — no silent
|
||||||
|
/// eviction.
|
||||||
|
///
|
||||||
|
/// Loads the existing library first and propagates any load error
|
||||||
|
/// so an unparseable file is never overwritten.
|
||||||
|
@discardableResult
|
||||||
|
public func upsert(_ recipe: MediaRecipe) throws -> [MediaRecipe] {
|
||||||
|
let validated = try recipe.validated()
|
||||||
|
try load()
|
||||||
|
|
||||||
|
var updated = recipes
|
||||||
|
if let index = updated.firstIndex(where: { $0.id == validated.id }) {
|
||||||
|
var existing = validated
|
||||||
|
existing.created = updated[index].created
|
||||||
|
existing.updated = Date()
|
||||||
|
updated[index] = existing
|
||||||
|
} else {
|
||||||
|
guard updated.count < capacity else {
|
||||||
|
throw MediaLibraryError.capacityReached(capacity)
|
||||||
|
}
|
||||||
|
updated.append(validated)
|
||||||
|
}
|
||||||
|
|
||||||
|
try fileStore.save(updated)
|
||||||
|
recipes = updated
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a recipe by id and writes atomically. Returns false when
|
||||||
|
/// no recipe with that id exists.
|
||||||
|
@discardableResult
|
||||||
|
public func delete(id: String) throws -> Bool {
|
||||||
|
try load()
|
||||||
|
let before = recipes.count
|
||||||
|
let updated = recipes.filter { $0.id != id }
|
||||||
|
guard updated.count != before else { return false }
|
||||||
|
try fileStore.save(updated)
|
||||||
|
recipes = updated
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum MediaLibraryError: LocalizedError, Equatable {
|
||||||
|
case capacityReached(Int)
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .capacityReached(let cap):
|
||||||
|
return "Media library is full (\(cap)). Delete a recipe first."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A media recipe: a named binding of CUPS queue + paper + ink set +
|
||||||
|
/// optional `.cal` to a `ProfilingPreset` (issue #146, docs/22 §Media
|
||||||
|
/// library).
|
||||||
|
///
|
||||||
|
/// snake_case keys match the v1 JSON schema so `media_library.json`
|
||||||
|
/// stays import/export compatible. Identity + binding fields are
|
||||||
|
/// required; every other field is optional-defaulted. Unknown keys are
|
||||||
|
/// ignored on decode; missing required fields fail the whole array
|
||||||
|
/// decode (corrupt-file policy, never silently dropped).
|
||||||
|
public struct MediaRecipe: Codable, Equatable, Sendable, Identifiable {
|
||||||
|
|
||||||
|
/// `"recipe-<uuid>"`, never user-typed.
|
||||||
|
public var id: String
|
||||||
|
public var name: String
|
||||||
|
public var notes: String
|
||||||
|
/// CUPS queue id (`Printer.name` — `Printer` has no `id` member).
|
||||||
|
public var printerID: String
|
||||||
|
/// Human label from `Printer.displayName`.
|
||||||
|
public var printerDisplayName: String
|
||||||
|
/// Library metadata only — never written to targen `-P`/`-I` flags.
|
||||||
|
public var paperName: String
|
||||||
|
/// Last captured CUPS `media_type`, read-only.
|
||||||
|
public var driverMediaType: String?
|
||||||
|
/// Free text: `"PK"`, `"MK"`, `"Photo Black"`, …
|
||||||
|
public var inkSet: String
|
||||||
|
/// `"rgb"` | `"cmyk"` — must match the bound preset.
|
||||||
|
public var colourSpace: String
|
||||||
|
/// `ProfilingPreset.id` (built-in or custom).
|
||||||
|
public var presetID: String
|
||||||
|
/// Absolute `.cal` path stored verbatim; `nil` = none.
|
||||||
|
public var calibrationURL: String?
|
||||||
|
public var applyCalibration: Bool
|
||||||
|
public var created: Date
|
||||||
|
public var updated: Date
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
notes: String = "",
|
||||||
|
printerID: String,
|
||||||
|
printerDisplayName: String = "",
|
||||||
|
paperName: String = "",
|
||||||
|
driverMediaType: String? = nil,
|
||||||
|
inkSet: String = "",
|
||||||
|
colourSpace: String,
|
||||||
|
presetID: String,
|
||||||
|
calibrationURL: String? = nil,
|
||||||
|
applyCalibration: Bool = false,
|
||||||
|
created: Date = Date(),
|
||||||
|
updated: Date = Date()
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.notes = notes
|
||||||
|
self.printerID = printerID
|
||||||
|
self.printerDisplayName = printerDisplayName
|
||||||
|
self.paperName = paperName
|
||||||
|
self.driverMediaType = driverMediaType
|
||||||
|
self.inkSet = inkSet
|
||||||
|
self.colourSpace = colourSpace
|
||||||
|
self.presetID = presetID
|
||||||
|
self.calibrationURL = calibrationURL
|
||||||
|
self.applyCalibration = applyCalibration
|
||||||
|
self.created = created
|
||||||
|
self.updated = updated
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, name, notes
|
||||||
|
case printerID = "printer_id"
|
||||||
|
case printerDisplayName = "printer_display_name"
|
||||||
|
case paperName = "paper_name"
|
||||||
|
case driverMediaType = "driver_media_type"
|
||||||
|
case inkSet = "ink_set"
|
||||||
|
case colourSpace = "colour_space"
|
||||||
|
case presetID = "preset_id"
|
||||||
|
case calibrationURL = "calibration_url"
|
||||||
|
case applyCalibration = "apply_calibration"
|
||||||
|
case created, updated
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strict decode: required identity + binding fields must be
|
||||||
|
/// present; optionals default. Unknown keys are ignored.
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try c.decode(String.self, forKey: .id)
|
||||||
|
name = try c.decode(String.self, forKey: .name)
|
||||||
|
notes = try c.decodeIfPresent(String.self, forKey: .notes) ?? ""
|
||||||
|
printerID = try c.decode(String.self, forKey: .printerID)
|
||||||
|
printerDisplayName = try c.decodeIfPresent(String.self, forKey: .printerDisplayName) ?? ""
|
||||||
|
paperName = try c.decodeIfPresent(String.self, forKey: .paperName) ?? ""
|
||||||
|
driverMediaType = try c.decodeIfPresent(String.self, forKey: .driverMediaType)
|
||||||
|
inkSet = try c.decodeIfPresent(String.self, forKey: .inkSet) ?? ""
|
||||||
|
colourSpace = try c.decode(String.self, forKey: .colourSpace)
|
||||||
|
presetID = try c.decode(String.self, forKey: .presetID)
|
||||||
|
calibrationURL = try c.decodeIfPresent(String.self, forKey: .calibrationURL)
|
||||||
|
applyCalibration = try c.decodeIfPresent(Bool.self, forKey: .applyCalibration) ?? false
|
||||||
|
created = try c.decodeIfPresent(Date.self, forKey: .created) ?? Date()
|
||||||
|
updated = try c.decodeIfPresent(Date.self, forKey: .updated) ?? Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ValidationError: LocalizedError, Equatable {
|
||||||
|
case emptyName
|
||||||
|
case emptyPrinterID
|
||||||
|
case invalidColourSpace(String)
|
||||||
|
case emptyPresetID
|
||||||
|
case invalidCalibrationURL(String)
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .emptyName: return "Media recipe is missing a name."
|
||||||
|
case .emptyPrinterID: return "Media recipe is missing a printer."
|
||||||
|
case .invalidColourSpace(let v):
|
||||||
|
return "colour_space must be \"rgb\" or \"cmyk\", got \"\(v)\"."
|
||||||
|
case .emptyPresetID: return "Media recipe is missing a preset."
|
||||||
|
case .invalidCalibrationURL(let v):
|
||||||
|
return "calibration_url must be an absolute path without \"..\" or NUL, got \"\(v)\"."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates the binding fields. `colourSpace` is normalized to
|
||||||
|
/// lowercase before comparison. `CAL_` cal names are **not**
|
||||||
|
/// rejected — that is an apply-time policy, not schema.
|
||||||
|
@discardableResult
|
||||||
|
public func validated() throws -> MediaRecipe {
|
||||||
|
var r = self
|
||||||
|
r.id = id.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
r.name = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
r.colourSpace = colourSpace.lowercased()
|
||||||
|
guard !r.name.isEmpty else { throw ValidationError.emptyName }
|
||||||
|
guard !r.printerID.isEmpty else { throw ValidationError.emptyPrinterID }
|
||||||
|
guard r.colourSpace == "rgb" || r.colourSpace == "cmyk" else {
|
||||||
|
throw ValidationError.invalidColourSpace(colourSpace)
|
||||||
|
}
|
||||||
|
guard !r.presetID.isEmpty else { throw ValidationError.emptyPresetID }
|
||||||
|
if let cal = r.calibrationURL, !cal.isEmpty {
|
||||||
|
guard cal.hasPrefix("/"), !cal.contains(".."), !cal.contains("\0") else {
|
||||||
|
throw ValidationError.invalidCalibrationURL(cal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Line classifier for `spotread` stdout (issue #148).
|
||||||
|
///
|
||||||
|
/// `spotread` shares `chartread`'s white-tile calibration phrasing, so
|
||||||
|
/// this wraps `ChartreadClassifier` and only intercepts the lines that
|
||||||
|
/// would otherwise misclassify:
|
||||||
|
///
|
||||||
|
/// - `… and then hit any key to continue,` / `or hit Esc or Q to abort:`
|
||||||
|
/// continuation lines that trail the calibration and spot prompts —
|
||||||
|
/// sticky to the current prompt state instead of `PROMPT_CONTINUE`.
|
||||||
|
/// - `Place instrument on a spot to be measured,` /
|
||||||
|
/// `and hit a key to take a reading,` → `AWAITING_STRIP` (the Read
|
||||||
|
/// prompt; the generic chartread matcher does not know "take a
|
||||||
|
/// reading").
|
||||||
|
///
|
||||||
|
/// Sample lines (`Result is XYZ: …, D50 Lab: …`) are parsed by
|
||||||
|
/// `SpotReadParser`, not classified here.
|
||||||
|
public enum SpotReadClassifier {
|
||||||
|
|
||||||
|
public static func classify(
|
||||||
|
line: String,
|
||||||
|
previousState: ChartreadState
|
||||||
|
) -> ChartreadClassifyResult {
|
||||||
|
let text = line.lowercased()
|
||||||
|
|
||||||
|
// Spot-read prompt continuations keep the current prompt state.
|
||||||
|
if previousState == .calibrating || previousState == .awaitingStrip {
|
||||||
|
if text.contains("hit any key")
|
||||||
|
|| text.contains("hit space")
|
||||||
|
|| text.contains("esc or")
|
||||||
|
|| text.contains("abort")
|
||||||
|
|| text.contains("to abort") {
|
||||||
|
return ChartreadClassifyResult(state: previousState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Place instrument on a spot to be measured," /
|
||||||
|
// " and hit a key to take a reading," — the Read trigger prompt.
|
||||||
|
if text.contains("spot to be measured")
|
||||||
|
|| text.contains("take a reading")
|
||||||
|
|| text.contains("measure the spot") {
|
||||||
|
return ChartreadClassifyResult(state: .awaitingStrip)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ChartreadClassifier.classify(line: line, previousState: previousState)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// One patch measurement emitted by a running `spotread` child (#148).
|
||||||
|
public struct SpotReadSample: Codable, Sendable, Equatable, Identifiable {
|
||||||
|
public var id: UUID
|
||||||
|
public var timestamp: Date
|
||||||
|
/// D50 Lab (always present — derived from XYZ when needed).
|
||||||
|
public var lab: LabColor
|
||||||
|
/// XYZ on the 0–100 scale used by the fork, when the line carried it.
|
||||||
|
public var xyz: XYZColor?
|
||||||
|
public var instrumentName: String
|
||||||
|
public var port: Int?
|
||||||
|
/// Diagnostics only — never rendered as HTML or shown in the table.
|
||||||
|
public var rawLine: String
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
timestamp: Date = Date(),
|
||||||
|
lab: LabColor,
|
||||||
|
xyz: XYZColor? = nil,
|
||||||
|
instrumentName: String = "",
|
||||||
|
port: Int? = nil,
|
||||||
|
rawLine: String = ""
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.timestamp = timestamp
|
||||||
|
self.lab = lab
|
||||||
|
self.xyz = xyz
|
||||||
|
self.instrumentName = instrumentName
|
||||||
|
self.port = port
|
||||||
|
self.rawLine = rawLine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses `spotread` result lines into Lab / XYZ triples.
|
||||||
|
///
|
||||||
|
/// The fork's line shape is the upstream
|
||||||
|
/// `Result is XYZ: <x> <y> <z>, D50 Lab: <L> <a> <b>`; a Lab-only line
|
||||||
|
/// also parses, and an XYZ-only line derives Lab via
|
||||||
|
/// `LabColorMath.xyzToLab` (D50).
|
||||||
|
public enum SpotReadParser {
|
||||||
|
|
||||||
|
public static func parse(line: String) -> (xyz: XYZColor?, lab: LabColor)? {
|
||||||
|
guard line.range(of: "result is", options: .caseInsensitive) != nil
|
||||||
|
|| line.range(of: #"\bLab\b"#, options: .regularExpression) != nil
|
||||||
|
|| line.range(of: #"\bXYZ\b"#, options: .regularExpression) != nil
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
var xyz: XYZColor?
|
||||||
|
var lab: LabColor?
|
||||||
|
|
||||||
|
if let m = triple(#"\bXYZ\b[:\s]"#, in: line) {
|
||||||
|
xyz = XYZColor(x: m.0, y: m.1, z: m.2)
|
||||||
|
}
|
||||||
|
if let m = triple(#"\bLab\b[:\s]"#, in: line) {
|
||||||
|
lab = LabColor(l: m.0, a: m.1, b: m.2)
|
||||||
|
}
|
||||||
|
if lab == nil, let xyz {
|
||||||
|
lab = LabColorMath.xyzToLab(xyz)
|
||||||
|
}
|
||||||
|
guard let lab else { return nil }
|
||||||
|
return (xyz, lab)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func triple(_ marker: String, in line: String) -> (Double, Double, Double)? {
|
||||||
|
let pattern = marker + #"\s*(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)"#
|
||||||
|
guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive),
|
||||||
|
let match = regex.firstMatch(
|
||||||
|
in: line, options: [], range: NSRange(line.startIndex..., in: line)),
|
||||||
|
match.numberOfRanges == 4,
|
||||||
|
let r1 = Range(match.range(at: 1), in: line),
|
||||||
|
let r2 = Range(match.range(at: 2), in: line),
|
||||||
|
let r3 = Range(match.range(at: 3), in: line),
|
||||||
|
let a = Double(line[r1]),
|
||||||
|
let b = Double(line[r2]),
|
||||||
|
let c = Double(line[r3])
|
||||||
|
else { return nil }
|
||||||
|
return (a, b, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import Foundation
|
|||||||
/// filter events on `id` — historical bug #56 was an id mismatch.
|
/// filter events on `id` — historical bug #56 was an id mismatch.
|
||||||
public enum ProcessID {
|
public enum ProcessID {
|
||||||
public static let instlist = "instlist"
|
public static let instlist = "instlist"
|
||||||
|
/// Spot-read console (issue #148) — single lease, like `instlist`.
|
||||||
|
public static let spotread = "spotread"
|
||||||
|
|
||||||
public static func targen(_ basename: String) -> String { "targen_\(basename)" }
|
public static func targen(_ basename: String) -> String { "targen_\(basename)" }
|
||||||
public static func printtarg(_ basename: String) -> String { "printtarg_\(basename)" }
|
public static func printtarg(_ basename: String) -> String { "printtarg_\(basename)" }
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ All measurement, chart generation, and profile mathematics live in the [Gronod A
|
|||||||
| 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 on `develop` |
|
||||||
| M7 | Pre-UAT hardening & baseline consolidation — shipped on `develop` |
|
| M7 | Pre-UAT hardening & baseline consolidation — shipped on `develop` |
|
||||||
| M8 | Deduplication/consolidation contracts & UAT-ready hardening (#79–#86) — in flight on `milestone/m8-consolidation` |
|
| M8 | Deduplication/consolidation contracts & UAT-ready hardening (#79–#86) — shipped on `develop` |
|
||||||
|
| M9 | macOS 12 / Xcode 14.2 retarget — shipped on `develop` (PR #145) |
|
||||||
|
| M10 | Studio workflow (#146–#149) — in flight on `milestone/m10-studio` |
|
||||||
| Licence | Proprietary source in [`LICENCE.md`](LICENCE.md); bundled Argyll sidecars remain AGPLv3 |
|
| Licence | Proprietary source in [`LICENCE.md`](LICENCE.md); bundled Argyll sidecars remain AGPLv3 |
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
@@ -57,9 +59,11 @@ Equivalent without Make:
|
|||||||
xcodegen generate
|
xcodegen generate
|
||||||
xcodebuild test -scheme ICCery \
|
xcodebuild test -scheme ICCery \
|
||||||
-destination 'platform=macOS' \
|
-destination 'platform=macOS' \
|
||||||
ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
|
ARCHS="$(uname -m)"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`project.yml` sets `ARCHS: "$(ARCHS_STANDARD)"`, so a plain `xcodebuild test` (and `make test`) builds universal; the `ARCHS="$(uname -m)"` override narrows it to the host slice.
|
||||||
|
|
||||||
Sidecars are **not** in git. `scripts/fetch-argyll.sh` pulls the latest (or `ARGYLL_RELEASE_TAG`) macOS-universal release from `gronod/argyllcms`, extracts to `Vendor/Argyll/macos-universal/`, ad-hoc signs every Mach-O, and fails if `codesign -dvv` or the `instlist` marker is missing.
|
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
|
||||||
@@ -124,10 +128,13 @@ docs/ functional spec + v2 ticket plan
|
|||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# full suite (universal)
|
# full suite (host arch)
|
||||||
xcodebuild test -scheme ICCery \
|
xcodebuild test -scheme ICCery \
|
||||||
-destination 'platform=macOS' \
|
-destination 'platform=macOS' \
|
||||||
ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
|
ARCHS="$(uname -m)"
|
||||||
|
|
||||||
|
# to compile-check both slices instead:
|
||||||
|
# ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
|
||||||
|
|
||||||
# examples
|
# examples
|
||||||
xcodebuild test -scheme ICCery -destination 'platform=macOS' \
|
xcodebuild test -scheme ICCery -destination 'platform=macOS' \
|
||||||
@@ -171,11 +178,11 @@ Agent / branch rules: [`AGENTS.md`](AGENTS.md), [`BUILD-PLAN.md`](BUILD-PLAN.md)
|
|||||||
|
|
||||||
```
|
```
|
||||||
develop
|
develop
|
||||||
└── milestone/m8-consolidation # integration branch
|
└── milestone/m10-studio # M10 integration branch
|
||||||
└── feat/<issue>-<slug> # one issue per branch
|
└── feat/<issue>-<slug> # one issue per branch
|
||||||
```
|
```
|
||||||
|
|
||||||
Feature PRs target the current milestone branch, not `develop`. The milestone branch merges to `develop` when its issues are green. Completion PRs for issues #79–#86 target `milestone/m8-consolidation`; `milestone/m8-consolidation` merges into `develop` once all milestone gates pass. Do not open umbrella "bugfix" branches that mix tickets.
|
Feature PRs target the current milestone branch, not `develop`. The milestone branch merges to `develop` when its issues are green. Completion PRs for issues #146–#149 target `milestone/m10-studio`; `milestone/m10-studio` merges into `develop` once all milestone gates pass. Do not open umbrella "bugfix" branches that mix tickets.
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<!-- App Sandbox intentionally absent: ICCery must spawn Argyll tools,
|
||||||
|
read/write user-chosen working directories, and talk to lp/CUPS. -->
|
||||||
|
<key>com.apple.security.device.usb</key>
|
||||||
|
<true/>
|
||||||
|
<!-- Debug only: the shared ICCeryCore package framework embedded in the
|
||||||
|
test products is ad-hoc signed with no Team ID, so hardened-runtime
|
||||||
|
library validation kills the test host at launch (run 31992, #119).
|
||||||
|
Release uses ICCery.entitlements and links the package statically. -->
|
||||||
|
<key>com.apple.security.cs.disable-library-validation</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -13,6 +13,7 @@ struct AppEnvironment: Sendable {
|
|||||||
let runner: ArgyllRunner
|
let runner: ArgyllRunner
|
||||||
let cupsService: CupsService
|
let cupsService: CupsService
|
||||||
let historyStore: VerificationHistoryStore
|
let historyStore: VerificationHistoryStore
|
||||||
|
let mediaStore: MediaLibraryStore
|
||||||
|
|
||||||
static func live(
|
static func live(
|
||||||
environment: [String: String] = ProcessInfo.processInfo.environment
|
environment: [String: String] = ProcessInfo.processInfo.environment
|
||||||
@@ -20,11 +21,15 @@ struct AppEnvironment: Sendable {
|
|||||||
let settingsStore = SettingsStore()
|
let settingsStore = SettingsStore()
|
||||||
var overrideDir = settingsStore.load().argyllBinaryDir
|
var overrideDir = settingsStore.load().argyllBinaryDir
|
||||||
.map { URL(fileURLWithPath: $0) }
|
.map { URL(fileURLWithPath: $0) }
|
||||||
|
var bundledRoot = AppPaths.bundledArgyllDir
|
||||||
var cupsDir = URL(fileURLWithPath: "/usr/bin")
|
var cupsDir = URL(fileURLWithPath: "/usr/bin")
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
if let dir = environment["ICCERY_ARGYLL_BINARY_DIR"], !dir.isEmpty {
|
if let dir = environment["ICCERY_ARGYLL_BINARY_DIR"], !dir.isEmpty {
|
||||||
overrideDir = URL(fileURLWithPath: dir)
|
overrideDir = URL(fileURLWithPath: dir)
|
||||||
}
|
}
|
||||||
|
if let dir = environment["ICCERY_ARGYLL_BUNDLED_ROOT"], !dir.isEmpty {
|
||||||
|
bundledRoot = URL(fileURLWithPath: dir)
|
||||||
|
}
|
||||||
if let dir = environment["ICCERY_CUPS_BIN_DIR"], !dir.isEmpty {
|
if let dir = environment["ICCERY_CUPS_BIN_DIR"], !dir.isEmpty {
|
||||||
cupsDir = URL(fileURLWithPath: dir)
|
cupsDir = URL(fileURLWithPath: dir)
|
||||||
}
|
}
|
||||||
@@ -35,12 +40,14 @@ struct AppEnvironment: Sendable {
|
|||||||
presetStore: PresetStore(settingsStore: settingsStore),
|
presetStore: PresetStore(settingsStore: settingsStore),
|
||||||
runner: ArgyllRunner(
|
runner: ArgyllRunner(
|
||||||
processManager: .shared,
|
processManager: .shared,
|
||||||
binaryResolver: BinaryResolver(overrideDir: overrideDir)
|
binaryResolver: BinaryResolver(
|
||||||
|
bundledRoot: bundledRoot, overrideDir: overrideDir)
|
||||||
),
|
),
|
||||||
cupsService: CupsService(
|
cupsService: CupsService(
|
||||||
processManager: .shared,
|
processManager: .shared,
|
||||||
binaryDir: cupsDir),
|
binaryDir: cupsDir),
|
||||||
historyStore: VerificationHistoryStore()
|
historyStore: VerificationHistoryStore(),
|
||||||
|
mediaStore: MediaLibraryStore()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,6 +81,8 @@ enum UITestHooks {
|
|||||||
static var presetImportURL: URL? { url("ICCERY_TEST_PRESET_IMPORT") }
|
static var presetImportURL: URL? { url("ICCERY_TEST_PRESET_IMPORT") }
|
||||||
/// Preset export destination.
|
/// Preset export destination.
|
||||||
static var presetExportURL: URL? { url("ICCERY_TEST_PRESET_EXPORT") }
|
static var presetExportURL: URL? { url("ICCERY_TEST_PRESET_EXPORT") }
|
||||||
|
/// Spot-read CSV export destination (`selectCsvSavePath`, #148).
|
||||||
|
static var csvExportURL: URL? { url("ICCERY_TEST_CSV_EXPORT") }
|
||||||
|
|
||||||
// MARK: - Print panel / CUPS stubs (issue 13/17)
|
// MARK: - Print panel / CUPS stubs (issue 13/17)
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import ICCeryCore
|
|||||||
|
|
||||||
/// Stage 0 calibration dashboard (issue #29, docs/07).
|
/// Stage 0 calibration dashboard (issue #29, docs/07).
|
||||||
struct CalibrationView: View {
|
struct CalibrationView: View {
|
||||||
@Bindable var model: CalibrationViewModel
|
@ObservedObject var model: CalibrationViewModel
|
||||||
@Bindable var wizard: WizardViewModel
|
@ObservedObject var wizard: WizardViewModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
@@ -73,7 +73,7 @@ struct CalibrationView: View {
|
|||||||
|
|
||||||
if let url = model.computedCalURL {
|
if let url = model.computedCalURL {
|
||||||
Toggle("Apply calibration to next profile", isOn: $model.applyToProfile)
|
Toggle("Apply calibration to next profile", isOn: $model.applyToProfile)
|
||||||
.onChange(of: model.applyToProfile) { model.updateApplyToProfile() }
|
.onChange(of: model.applyToProfile) { _ in model.updateApplyToProfile() }
|
||||||
.accessibilityIdentifier("calApplyToggle")
|
.accessibilityIdentifier("calApplyToggle")
|
||||||
|
|
||||||
Text("Loaded: \(url.lastPathComponent)")
|
Text("Loaded: \(url.lastPathComponent)")
|
||||||
@@ -96,7 +96,6 @@ struct CalibrationView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import ICCeryCore
|
import ICCeryCore
|
||||||
|
|
||||||
/// Stage 0 calibration workflow: generate wedge, print, measure, and
|
/// Stage 0 calibration workflow: generate wedge, print, measure, and
|
||||||
/// compute `.cal` curves.
|
/// compute `.cal` curves.
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
final class CalibrationViewModel: ObservableObject {
|
||||||
final class CalibrationViewModel {
|
|
||||||
|
|
||||||
let workflow: TargetWorkflowViewModel
|
let workflow: TargetWorkflowViewModel
|
||||||
let profile: ProfileWorkflowViewModel
|
let profile: ProfileWorkflowViewModel
|
||||||
@@ -15,16 +14,16 @@ final class CalibrationViewModel {
|
|||||||
|
|
||||||
// MARK: - Form state
|
// MARK: - Form state
|
||||||
|
|
||||||
var colourSpace: ColourSpace = .cmyk
|
@Published var colourSpace: ColourSpace = .cmyk
|
||||||
var steps: Int = 21
|
@Published var steps: Int = 21
|
||||||
var whitePatches: Int = 4
|
@Published var whitePatches: Int = 4
|
||||||
var includeNeutralEmphasis: Bool = false
|
@Published var includeNeutralEmphasis: Bool = false
|
||||||
var inkLimit: String = "320"
|
@Published var inkLimit: String = "320"
|
||||||
var applyToProfile: Bool = false
|
@Published var applyToProfile: Bool = false
|
||||||
var computedCalURL: URL?
|
@Published var computedCalURL: URL?
|
||||||
var calibrationLog: [String] = []
|
@Published var calibrationLog: [String] = []
|
||||||
var isGenerating = false
|
@Published var isGenerating = false
|
||||||
var isComputing = false
|
@Published var isComputing = false
|
||||||
|
|
||||||
init(workflow: TargetWorkflowViewModel, profile: ProfileWorkflowViewModel, environment: AppEnvironment) {
|
init(workflow: TargetWorkflowViewModel, profile: ProfileWorkflowViewModel, environment: AppEnvironment) {
|
||||||
self.workflow = workflow
|
self.workflow = workflow
|
||||||
|
|||||||
@@ -69,12 +69,12 @@ internal struct GamutSceneGeometryBuilder {
|
|||||||
/// a* (green-red) axis is horizontal, L* (lightness) is vertical, and b*
|
/// a* (green-red) axis is horizontal, L* (lightness) is vertical, and b*
|
||||||
/// (blue-yellow) is depth.
|
/// (blue-yellow) is depth.
|
||||||
struct GamutView: View {
|
struct GamutView: View {
|
||||||
@State private var viewModel: GamutViewModel
|
@StateObject private var viewModel: GamutViewModel
|
||||||
@State private var pause: () -> Void = {}
|
@State private var pause: () -> Void = {}
|
||||||
@FocusState private var isFocused: Bool
|
@FocusState private var isFocused: Bool
|
||||||
|
|
||||||
init(profileGamURL: URL? = nil) {
|
init(profileGamURL: URL? = nil) {
|
||||||
_viewModel = State(wrappedValue: GamutViewModel(profileGamURL: profileGamURL))
|
_viewModel = StateObject(wrappedValue: GamutViewModel(profileGamURL: profileGamURL))
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -87,11 +87,6 @@ struct GamutView: View {
|
|||||||
)
|
)
|
||||||
.focusable()
|
.focusable()
|
||||||
.focused($isFocused)
|
.focused($isFocused)
|
||||||
.focusEffectDisabled()
|
|
||||||
.onKeyPress(.init("R"), action: {
|
|
||||||
viewModel.resetCamera()
|
|
||||||
return .handled
|
|
||||||
})
|
|
||||||
.onAppear { isFocused = true }
|
.onAppear { isFocused = true }
|
||||||
|
|
||||||
VStack {
|
VStack {
|
||||||
@@ -148,6 +143,7 @@ private struct GamutSceneView: NSViewRepresentable {
|
|||||||
context.coordinator.scnView = scnView
|
context.coordinator.scnView = scnView
|
||||||
context.coordinator.scene = scene
|
context.coordinator.scene = scene
|
||||||
context.coordinator.buildScene(profile: profileMesh, reference: referenceMesh)
|
context.coordinator.buildScene(profile: profileMesh, reference: referenceMesh)
|
||||||
|
context.coordinator.installKeyMonitor()
|
||||||
|
|
||||||
return scnView
|
return scnView
|
||||||
}
|
}
|
||||||
@@ -168,6 +164,7 @@ private struct GamutSceneView: NSViewRepresentable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func dismantleNSView(_ nsView: SCNView, coordinator: Coordinator) {
|
static func dismantleNSView(_ nsView: SCNView, coordinator: Coordinator) {
|
||||||
|
coordinator.removeKeyMonitor()
|
||||||
nsView.isPlaying = false
|
nsView.isPlaying = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,6 +172,7 @@ private struct GamutSceneView: NSViewRepresentable {
|
|||||||
final class Coordinator: NSObject {
|
final class Coordinator: NSObject {
|
||||||
weak var scnView: SCNView?
|
weak var scnView: SCNView?
|
||||||
weak var scene: SCNScene?
|
weak var scene: SCNScene?
|
||||||
|
private var keyMonitor: Any?
|
||||||
|
|
||||||
private let profileNode = SCNNode()
|
private let profileNode = SCNNode()
|
||||||
private let referenceGroup = SCNNode()
|
private let referenceGroup = SCNNode()
|
||||||
@@ -431,6 +429,31 @@ private struct GamutSceneView: NSViewRepresentable {
|
|||||||
scnView?.isPlaying = false
|
scnView?.isPlaying = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Local key-down monitor for the R camera-reset shortcut (the
|
||||||
|
/// SwiftUI key-press modifier is unavailable on macOS 12). Only
|
||||||
|
/// events aimed at this view's window are handled; everything
|
||||||
|
/// else passes through untouched.
|
||||||
|
func installKeyMonitor() {
|
||||||
|
guard keyMonitor == nil else { return }
|
||||||
|
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) {
|
||||||
|
[weak self] event in
|
||||||
|
guard let self,
|
||||||
|
let scnView = self.scnView,
|
||||||
|
event.window === scnView.window,
|
||||||
|
event.charactersIgnoringModifiers?.uppercased() == "R"
|
||||||
|
else { return event }
|
||||||
|
self.resetCamera()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeKeyMonitor() {
|
||||||
|
if let keyMonitor {
|
||||||
|
NSEvent.removeMonitor(keyMonitor)
|
||||||
|
self.keyMonitor = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func resetCamera() {
|
func resetCamera() {
|
||||||
guard let scnView else { return }
|
guard let scnView else { return }
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,25 @@
|
|||||||
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import ICCeryCore
|
import ICCeryCore
|
||||||
import Observation
|
|
||||||
|
|
||||||
/// View model for the native SceneKit gamut viewer.
|
/// View model for the native SceneKit gamut viewer.
|
||||||
///
|
///
|
||||||
/// Loads the bundled `sRGB.gam` reference immediately and, optionally, a
|
/// Loads the bundled `sRGB.gam` reference immediately and, optionally, a
|
||||||
/// printer/profile `.gam` from the current working directory.
|
/// printer/profile `.gam` from the current working directory.
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
final class GamutViewModel: ObservableObject {
|
||||||
final class GamutViewModel {
|
|
||||||
|
|
||||||
/// Parsed reference sRGB gamut mesh.
|
/// Parsed reference sRGB gamut mesh.
|
||||||
var sRGBMesh: GamutMesh?
|
@Published var sRGBMesh: GamutMesh?
|
||||||
|
|
||||||
/// Parsed printer/profile gamut mesh.
|
/// Parsed printer/profile gamut mesh.
|
||||||
var profileMesh: GamutMesh?
|
@Published var profileMesh: GamutMesh?
|
||||||
|
|
||||||
/// User-facing status line.
|
/// User-facing status line.
|
||||||
var status = "Loading gamut…"
|
@Published var status = "Loading gamut…"
|
||||||
|
|
||||||
/// Closure injected into the SceneKit view to request a camera reset.
|
/// Closure injected into the SceneKit view to request a camera reset.
|
||||||
var resetCamera: () -> Void = {}
|
@Published var resetCamera: () -> Void = {}
|
||||||
|
|
||||||
private let profileGamURL: URL?
|
private let profileGamURL: URL?
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import SwiftUI
|
|||||||
@main
|
@main
|
||||||
struct ICCeryApp: App {
|
struct ICCeryApp: App {
|
||||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||||
@State private var workflow: TargetWorkflowViewModel
|
@StateObject private var workflow: TargetWorkflowViewModel
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
let environment = AppEnvironment.live()
|
let environment = AppEnvironment.live()
|
||||||
_workflow = State(initialValue: TargetWorkflowViewModel(environment: environment))
|
_workflow = StateObject(wrappedValue: TargetWorkflowViewModel(environment: environment))
|
||||||
try? AppPaths.ensureDirectories()
|
try? AppPaths.ensureDirectories()
|
||||||
// Log level is runtime state — apply persisted settings at
|
// Log level is runtime state — apply persisted settings at
|
||||||
// startup (#158); the Settings sheet re-applies on save.
|
// startup (#158); the Settings sheet re-applies on save.
|
||||||
@@ -17,15 +17,17 @@ struct ICCeryApp: App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
// Single fixed window (docs/21 §Shell: 1280×800, min 1100×700).
|
// Single fixed window (docs/21 §Shell: 1280×800, min 1100×700);
|
||||||
Window("ICCery", id: "main") {
|
// metrics are applied by AppDelegate once the window exists.
|
||||||
|
WindowGroup("ICCery") {
|
||||||
RootView(workflow: workflow)
|
RootView(workflow: workflow)
|
||||||
.frame(minWidth: 1100, minHeight: 700)
|
.frame(minWidth: 1100, minHeight: 700)
|
||||||
.preferredColorScheme(.dark)
|
.preferredColorScheme(.dark)
|
||||||
}
|
}
|
||||||
.defaultSize(width: 1280, height: 800)
|
.commands {
|
||||||
.windowResizability(.contentMinSize)
|
// Single-window app: no File > New window.
|
||||||
.defaultPosition(.center)
|
CommandGroup(replacing: .newItem) {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,16 +39,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
private var terminationRequested = false
|
private var terminationRequested = false
|
||||||
|
|
||||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
// SwiftUI `Window` scenes launched by XCTest stay
|
// SwiftUI scenes launched by XCTest stay `.runningBackground`
|
||||||
// `.runningBackground` unless the app takes regular activation
|
// unless the app takes regular activation and orders the window
|
||||||
// and orders the window front (CI run 29804).
|
// front (CI run 29804).
|
||||||
NSApp.setActivationPolicy(.regular)
|
NSApp.setActivationPolicy(.regular)
|
||||||
for window in NSApp.windows {
|
for window in NSApp.windows {
|
||||||
|
configureMainWindow(window)
|
||||||
window.makeKeyAndOrderFront(nil)
|
window.makeKeyAndOrderFront(nil)
|
||||||
}
|
}
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// docs/21 §Shell: 1280×800 content, min 1100×700, centred.
|
||||||
|
private func configureMainWindow(_ window: NSWindow) {
|
||||||
|
window.setContentSize(NSSize(width: 1280, height: 800))
|
||||||
|
window.contentMinSize = NSSize(width: 1100, height: 700)
|
||||||
|
window.center()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dock-click reopen: let the WindowGroup re-show or recreate the
|
||||||
|
/// main window when none are visible.
|
||||||
|
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import ICCeryCore
|
import ICCeryCore
|
||||||
|
|
||||||
@@ -32,8 +32,7 @@ enum XYStep: Equatable, Sendable {
|
|||||||
|
|
||||||
/// Stage 3 workflow state and interaction (issues #18–#22).
|
/// Stage 3 workflow state and interaction (issues #18–#22).
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
final class MeasurementWorkflowViewModel: ObservableObject {
|
||||||
final class MeasurementWorkflowViewModel {
|
|
||||||
|
|
||||||
// MARK: - Authorities
|
// MARK: - Authorities
|
||||||
|
|
||||||
@@ -42,37 +41,37 @@ final class MeasurementWorkflowViewModel {
|
|||||||
|
|
||||||
// MARK: - Settings-driven thresholds
|
// MARK: - Settings-driven thresholds
|
||||||
|
|
||||||
private(set) var goodMax: Double = 2.0
|
@Published private(set) var goodMax: Double = 2.0
|
||||||
private(set) var warningMax: Double = 5.0
|
@Published private(set) var warningMax: Double = 5.0
|
||||||
private(set) var enableLEDs: Bool = false
|
@Published private(set) var enableLEDs: Bool = false
|
||||||
|
|
||||||
// MARK: - Instrument detection
|
// MARK: - Instrument detection
|
||||||
|
|
||||||
var instruments: [InstrumentDevice] = []
|
@Published var instruments: [InstrumentDevice] = []
|
||||||
var selectedInstrument: InstrumentSelection = .auto
|
@Published var selectedInstrument: InstrumentSelection = .auto
|
||||||
var isDetecting = false
|
@Published var isDetecting = false
|
||||||
var detectionError: String?
|
@Published var detectionError: String?
|
||||||
|
|
||||||
// MARK: - Chartread session
|
// MARK: - Chartread session
|
||||||
|
|
||||||
var isChartreadRunning = false
|
@Published var isChartreadRunning = false
|
||||||
var chartreadState: ChartreadState = .idle
|
@Published var chartreadState: ChartreadState = .idle
|
||||||
var currentPrompt: String?
|
@Published var currentPrompt: String?
|
||||||
var requestedWarningKey: String?
|
@Published var requestedWarningKey: String?
|
||||||
var chartreadLog: [String] = []
|
@Published var chartreadLog: [String] = []
|
||||||
var rows: [ChartreadRow] = []
|
@Published var rows: [ChartreadRow] = []
|
||||||
var swatchRows: [SwatchRow] = []
|
@Published var swatchRows: [SwatchRow] = []
|
||||||
var showRemoveSheetNotice = false
|
@Published var showRemoveSheetNotice = false
|
||||||
/// Stage-local chartread error notice (`#chartreadLastError`, #80).
|
/// Stage-local chartread error notice (`#chartreadLastError`, #80).
|
||||||
var chartreadNotice: Notice?
|
@Published var chartreadNotice: Notice?
|
||||||
private var chartreadTask: Task<Void, Never>?
|
private var chartreadTask: Task<Void, Never>?
|
||||||
|
|
||||||
// MARK: - Averaging
|
// MARK: - Averaging
|
||||||
|
|
||||||
var passSnapshots: [URL] = []
|
@Published var passSnapshots: [URL] = []
|
||||||
var isFinishing = false
|
@Published var isFinishing = false
|
||||||
var finishNotice: Notice?
|
@Published var finishNotice: Notice?
|
||||||
var resumedFromTi2 = false
|
@Published var resumedFromTi2 = false
|
||||||
|
|
||||||
init(wizard: WizardViewModel, environment: AppEnvironment) {
|
init(wizard: WizardViewModel, environment: AppEnvironment) {
|
||||||
self.wizard = wizard
|
self.wizard = wizard
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// `#saveMediaRecipeDialog` — capture the current printer + paper +
|
||||||
|
/// ink + `.cal` bound to the selected preset (issue #146). Clones
|
||||||
|
/// `SavePresetDialog` chrome; names render via `Text` only (#114).
|
||||||
|
struct SaveMediaRecipeDialog: View {
|
||||||
|
@ObservedObject var workflow: TargetWorkflowViewModel
|
||||||
|
/// Observed directly: nested ObservableObjects are not tracked
|
||||||
|
/// through the parent's `objectWillChange`.
|
||||||
|
@ObservedObject private var media: MediaLibraryViewModel
|
||||||
|
@ObservedObject private var printSession: PrintSessionViewModel
|
||||||
|
|
||||||
|
init(workflow: TargetWorkflowViewModel) {
|
||||||
|
self.workflow = workflow
|
||||||
|
self._media = ObservedObject(wrappedValue: workflow.media)
|
||||||
|
self._printSession = ObservedObject(wrappedValue: workflow.print)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var printerCaption: String {
|
||||||
|
let queue = printSession.selectedPrinter
|
||||||
|
guard !queue.isEmpty else { return "None" }
|
||||||
|
let display = printSession.printers
|
||||||
|
.first { $0.name == queue }?.displayName ?? queue
|
||||||
|
return "\(display) (\(queue))"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func captureRow(
|
||||||
|
_ label: String, value: String, identifier: String
|
||||||
|
) -> some View {
|
||||||
|
HStack {
|
||||||
|
Text(label).foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Text(value)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.middle)
|
||||||
|
.accessibilityIdentifier(identifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var saveDisabled: Bool {
|
||||||
|
media.saveMediaName.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
|| media.saveMediaPaper.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
|| media.saveMediaInk.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
|| media.captureColourSpaceMismatch
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
Text("Save Media Recipe").font(.title3).foregroundStyle(Theme.text)
|
||||||
|
TextField("Name", text: $media.saveMediaName)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.accessibilityIdentifier("saveMediaName")
|
||||||
|
TextField("Notes (optional)", text: $media.saveMediaNotes)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.accessibilityIdentifier("saveMediaNotes")
|
||||||
|
TextField("Paper", text: $media.saveMediaPaper)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.accessibilityIdentifier("saveMediaPaper")
|
||||||
|
TextField("Ink set", text: $media.saveMediaInk)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.accessibilityIdentifier("saveMediaInk")
|
||||||
|
|
||||||
|
captureRow("Printer", value: printerCaption,
|
||||||
|
identifier: "saveMediaPrinter")
|
||||||
|
captureRow("Preset",
|
||||||
|
value: workflow.selectedPreset?.name ?? "No preset",
|
||||||
|
identifier: "saveMediaPreset")
|
||||||
|
captureRow("Colour space",
|
||||||
|
value: workflow.colourSpace.rawValue.uppercased(),
|
||||||
|
identifier: "saveMediaColourSpace")
|
||||||
|
captureRow("Calibration",
|
||||||
|
value: workflow.profile.calibrationFile.isEmpty
|
||||||
|
? "None" : workflow.profile.calibrationFile,
|
||||||
|
identifier: "saveMediaCal")
|
||||||
|
Toggle("Apply calibration to profile",
|
||||||
|
isOn: $media.saveMediaApplyCal)
|
||||||
|
.disabled(!media.calApplyable)
|
||||||
|
.accessibilityIdentifier("saveMediaApplyCal")
|
||||||
|
|
||||||
|
if media.captureColourSpaceMismatch {
|
||||||
|
Text("Colour space does not match the selected preset.")
|
||||||
|
.font(.caption).foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
if let error = media.saveMediaError {
|
||||||
|
Text(error).font(.caption).foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button("Cancel") { workflow.showingSaveMedia = false }
|
||||||
|
.accessibilityIdentifier("btnCloseSaveMediaDialog")
|
||||||
|
Button("Save") {
|
||||||
|
Task {
|
||||||
|
if await media.captureFromSession() {
|
||||||
|
workflow.showingSaveMedia = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(saveDisabled)
|
||||||
|
.accessibilityIdentifier("btnConfirmSaveMedia")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
.frame(width: 380)
|
||||||
|
.background(Theme.background)
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityIdentifier("saveMediaRecipeDialog")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `#manageMediaDialog` — list, apply, delete, capture (issue #146).
|
||||||
|
/// `List`, not `Table` — macOS 12 target. Clones `ManagePresetsDialog`.
|
||||||
|
struct ManageMediaDialog: View {
|
||||||
|
@ObservedObject var workflow: TargetWorkflowViewModel
|
||||||
|
/// Observed directly: nested ObservableObjects are not tracked
|
||||||
|
/// through the parent's `objectWillChange`.
|
||||||
|
@ObservedObject private var media: MediaLibraryViewModel
|
||||||
|
@State private var selection: String?
|
||||||
|
@State private var pendingDelete: MediaRecipe?
|
||||||
|
|
||||||
|
init(workflow: TargetWorkflowViewModel) {
|
||||||
|
self.workflow = workflow
|
||||||
|
self._media = ObservedObject(wrappedValue: workflow.media)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presetCaption(for recipe: MediaRecipe) -> String {
|
||||||
|
workflow.presets.first { $0.id == recipe.presetID }?.name
|
||||||
|
?? "Missing preset"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func calCaption(for recipe: MediaRecipe) -> String {
|
||||||
|
if media.staleReasons[recipe.id]?.contains(.calibration) == true {
|
||||||
|
return "Stale"
|
||||||
|
}
|
||||||
|
if let days = media.calAgeDays[recipe.id] {
|
||||||
|
return "Cal \(days)d"
|
||||||
|
}
|
||||||
|
return "No cal"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyAndDismiss(_ recipe: MediaRecipe) {
|
||||||
|
Task {
|
||||||
|
if await media.apply(recipe) {
|
||||||
|
workflow.showingManageMedia = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presetMissing(_ recipe: MediaRecipe) -> Bool {
|
||||||
|
!workflow.presets.contains { $0.id == recipe.presetID }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func calStale(_ recipe: MediaRecipe) -> Bool {
|
||||||
|
media.staleReasons[recipe.id]?.contains(.calibration) == true
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func row(_ recipe: MediaRecipe) -> some View {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(recipe.name).foregroundStyle(Theme.text)
|
||||||
|
Text("\(recipe.printerDisplayName) · \(recipe.paperName) · \(recipe.inkSet)")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text(presetCaption(for: recipe))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(presetMissing(recipe) ? .orange : .secondary)
|
||||||
|
Text(calCaption(for: recipe))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(calStale(recipe) ? .orange : .secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button("Apply") { applyAndDismiss(recipe) }
|
||||||
|
.accessibilityIdentifier("btnMediaLibraryApply-\(recipe.id)")
|
||||||
|
Button("Delete", role: .destructive) {
|
||||||
|
pendingDelete = recipe
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("btnMediaLibraryDelete-\(recipe.id)")
|
||||||
|
}
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityIdentifier("mediaRow-\(recipe.id)")
|
||||||
|
.tag(recipe.id)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.simultaneousGesture(
|
||||||
|
TapGesture(count: 2).onEnded { applyAndDismiss(recipe) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Manage Media Recipes").font(.title3).foregroundStyle(Theme.text)
|
||||||
|
|
||||||
|
List(selection: $selection) {
|
||||||
|
if media.recipes.isEmpty {
|
||||||
|
Text("No media recipes yet. Capture the current printer, paper and preset.")
|
||||||
|
.font(.callout).foregroundStyle(.secondary)
|
||||||
|
.accessibilityIdentifier("mediaLibraryEmpty")
|
||||||
|
}
|
||||||
|
ForEach(media.recipes) { recipe in
|
||||||
|
row(recipe)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("mediaLibraryList")
|
||||||
|
.frame(minHeight: 260)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Button("Apply selected") {
|
||||||
|
if let id = selection,
|
||||||
|
let recipe = media.recipes.first(where: { $0.id == id }) {
|
||||||
|
applyAndDismiss(recipe)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(selection == nil)
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
.accessibilityIdentifier("btnMediaLibraryApply")
|
||||||
|
Button("Capture current…") {
|
||||||
|
media.captureAfterManageDismiss = true
|
||||||
|
workflow.showingManageMedia = false
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("btnMediaLibraryCaptureFromManage")
|
||||||
|
Spacer()
|
||||||
|
Button("Close") { workflow.showingManageMedia = false }
|
||||||
|
.accessibilityIdentifier("btnCloseManageMediaDialog")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
.frame(width: 640)
|
||||||
|
.background(Theme.background)
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityIdentifier("manageMediaDialog")
|
||||||
|
.onAppear {
|
||||||
|
media.reload()
|
||||||
|
media.refreshStaleness()
|
||||||
|
}
|
||||||
|
.alert(
|
||||||
|
"Delete media recipe?",
|
||||||
|
isPresented: Binding(
|
||||||
|
get: { pendingDelete != nil },
|
||||||
|
set: { if !$0 { pendingDelete = nil } }
|
||||||
|
),
|
||||||
|
presenting: pendingDelete
|
||||||
|
) { recipe in
|
||||||
|
Button("Cancel", role: .cancel) { pendingDelete = nil }
|
||||||
|
Button("Delete", role: .destructive) {
|
||||||
|
media.delete(recipe)
|
||||||
|
pendingDelete = nil
|
||||||
|
}
|
||||||
|
} message: { recipe in
|
||||||
|
Text("Delete \"\(recipe.name)\"? This does not delete the .cal or the preset.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// Media recipe library: capture / apply / staleness for issue #146.
|
||||||
|
///
|
||||||
|
/// A recipe binds a CUPS queue + paper + ink + `.cal` to a
|
||||||
|
/// `ProfilingPreset`. Applying a recipe goes through the existing
|
||||||
|
/// `applyPreset` (#82) path — there is no second Stage 1 form. Paper
|
||||||
|
/// and ink are library metadata only; they are never written to the
|
||||||
|
/// targen label fields.
|
||||||
|
@MainActor
|
||||||
|
final class MediaLibraryViewModel: ObservableObject {
|
||||||
|
|
||||||
|
/// Why a recipe row is flagged stale.
|
||||||
|
struct StaleReason: OptionSet {
|
||||||
|
let rawValue: Int
|
||||||
|
static let calibration = StaleReason(rawValue: 1 << 0)
|
||||||
|
static let printer = StaleReason(rawValue: 1 << 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
let workflow: TargetWorkflowViewModel
|
||||||
|
let environment: AppEnvironment
|
||||||
|
private let store: MediaLibraryStore
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
@Published var recipes: [MediaRecipe] = []
|
||||||
|
/// Sidebar picker selection; `"none"` = no media recipe. Written
|
||||||
|
/// only on a successful apply so a failed apply snaps back.
|
||||||
|
@Published var selectedRecipeID = "none"
|
||||||
|
/// `recipe.id` → stale reasons for the sidebar badge / manage sheet.
|
||||||
|
@Published var staleReasons: [String: StaleReason] = [:]
|
||||||
|
/// `recipe.id` → whole days since the bound `.cal` was created.
|
||||||
|
@Published var calAgeDays: [String: Int] = [:]
|
||||||
|
|
||||||
|
// Save-sheet state (mirrors savePresetName/savePresetDesc).
|
||||||
|
@Published var saveMediaName = ""
|
||||||
|
@Published var saveMediaNotes = ""
|
||||||
|
@Published var saveMediaPaper = ""
|
||||||
|
@Published var saveMediaInk = ""
|
||||||
|
@Published var saveMediaApplyCal = false
|
||||||
|
/// Inline caption inside the capture sheet (no a11y id — roster complete).
|
||||||
|
@Published var saveMediaError: String?
|
||||||
|
|
||||||
|
/// Pure flow flag — the manage sheet's "Capture current…" asks the
|
||||||
|
/// sheet's `onDismiss` to open the capture sheet, avoiding a
|
||||||
|
/// present-while-dismissing race.
|
||||||
|
var captureAfterManageDismiss = false
|
||||||
|
|
||||||
|
init(workflow: TargetWorkflowViewModel, environment: AppEnvironment) {
|
||||||
|
self.workflow = workflow
|
||||||
|
self.environment = environment
|
||||||
|
self.store = environment.mediaStore
|
||||||
|
|
||||||
|
reload()
|
||||||
|
refreshStaleness()
|
||||||
|
// The library needs queues enumerated at launch so Capture can
|
||||||
|
// enable and the not-installed badge is computable; Stage 2 only
|
||||||
|
// enumerates when a printtarg manifest exists.
|
||||||
|
if workflow.print.printers.isEmpty {
|
||||||
|
workflow.print.refreshPrinters()
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationCenter.default
|
||||||
|
.publisher(for: SettingsStore.settingsDidChange)
|
||||||
|
.sink { [weak self] _ in self?.refreshStaleness() }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
workflow.print.$printers
|
||||||
|
.sink { [weak self] _ in self?.refreshStaleness() }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
workflow.print.$selectedPrinter
|
||||||
|
.sink { [weak self] _ in self?.refreshStaleness() }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit { cancellables.removeAll() }
|
||||||
|
|
||||||
|
// MARK: - Load / corrupt
|
||||||
|
|
||||||
|
func reload() {
|
||||||
|
Task { await reloadAsync() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func reloadAsync() async {
|
||||||
|
do {
|
||||||
|
recipes = try await store.load()
|
||||||
|
} catch {
|
||||||
|
// Corrupt-file policy: keep the file, keep the cache,
|
||||||
|
// persistent warning; the picker falls back to "none".
|
||||||
|
workflow.wizard.showNotice(
|
||||||
|
"Media library is unreadable — the existing file was kept.",
|
||||||
|
kind: .warning,
|
||||||
|
autoHideAfter: nil
|
||||||
|
)
|
||||||
|
if recipes.isEmpty { selectedRecipeID = "none" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Selection / apply
|
||||||
|
|
||||||
|
/// Sidebar `mediaSelect` binding. `"none"` clears the selection
|
||||||
|
/// without resetting any Stage 1/2/4 field — it is not "reset to
|
||||||
|
/// factory".
|
||||||
|
func selectRecipe(_ id: String) {
|
||||||
|
if id == "none" {
|
||||||
|
selectedRecipeID = "none"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let recipe = recipes.first(where: { $0.id == id }) else { return }
|
||||||
|
Task { _ = await apply(recipe) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The single apply path — sidebar picker, manage-row Apply, and
|
||||||
|
/// the manage footer all funnel here.
|
||||||
|
///
|
||||||
|
/// Returns `false` when any bound resource is unresolved (missing
|
||||||
|
/// preset, colour-space mismatch, queue absent, missing/unparseable
|
||||||
|
/// `.cal`) so the manage sheet stays open and the picker reverts.
|
||||||
|
/// A `CAL_`-blocked calibration counts as applied (`true` — success
|
||||||
|
/// with warning; the refusal is permanent so re-clicking can't help).
|
||||||
|
@discardableResult
|
||||||
|
func apply(_ recipe: MediaRecipe) async -> Bool {
|
||||||
|
guard let r = try? recipe.validated() else {
|
||||||
|
workflow.wizard.showNotice(
|
||||||
|
"Media recipe is invalid — not applied.", kind: .error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
guard let preset = environment.presetStore.all()
|
||||||
|
.first(where: { $0.id == r.presetID })
|
||||||
|
else {
|
||||||
|
workflow.wizard.showNotice(
|
||||||
|
"Preset \(r.presetID) no longer exists — recipe not applied.",
|
||||||
|
kind: .error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
guard preset.colourSpace.lowercased() == r.colourSpace.lowercased() else {
|
||||||
|
workflow.wizard.showNotice(
|
||||||
|
"Recipe colour space does not match its preset — not applied.",
|
||||||
|
kind: .error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Existing #82 mapping: presetSelect jumps, Stage 1/2/4 fields.
|
||||||
|
workflow.applyPreset(preset)
|
||||||
|
// Literal per issue: displayName, not the queue id.
|
||||||
|
workflow.wizard.printerName = r.printerDisplayName
|
||||||
|
|
||||||
|
var succeeded = true
|
||||||
|
|
||||||
|
// Queue: enumerate fresh via the session's serialized path —
|
||||||
|
// listPrinters uses fixed process ids, so an overlapping
|
||||||
|
// enumeration would throw duplicateID. An empty result is a
|
||||||
|
// valid list.
|
||||||
|
if let queues = await workflow.print.enumeratePrinters() {
|
||||||
|
if queues.contains(where: { $0.name == r.printerID }) {
|
||||||
|
workflow.print.selectedPrinter = r.printerID
|
||||||
|
await workflow.print.reloadSelectedCapabilities()
|
||||||
|
} else {
|
||||||
|
workflow.wizard.showNotice(
|
||||||
|
"Printer \(r.printerDisplayName) is not installed.",
|
||||||
|
kind: .warning)
|
||||||
|
succeeded = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
workflow.wizard.showNotice(
|
||||||
|
"Could not enumerate printers — queue left unchanged.",
|
||||||
|
kind: .warning)
|
||||||
|
succeeded = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calibration — the recipe is authoritative and runs after
|
||||||
|
// applyPreset so the preset's own cal fields don't win.
|
||||||
|
let calPath = r.calibrationURL?.trimmingCharacters(in: .whitespaces) ?? ""
|
||||||
|
let calStem = URL(fileURLWithPath: calPath)
|
||||||
|
.deletingPathExtension().lastPathComponent
|
||||||
|
let blocked = r.applyCalibration && !calPath.isEmpty
|
||||||
|
&& (CalibrationIdentity.isCalibration(calStem)
|
||||||
|
|| CalibrationIdentity.isCalibration(workflow.wizard.basename))
|
||||||
|
|
||||||
|
if blocked {
|
||||||
|
// Literal CAL_ refusal on both names (decision 1): keep the
|
||||||
|
// path for display but never let `printtarg -K` see it.
|
||||||
|
workflow.profile.applyCalibration = false
|
||||||
|
workflow.profile.calibrationFile = calPath
|
||||||
|
selectedRecipeID = r.id
|
||||||
|
refreshStaleness()
|
||||||
|
workflow.wizard.showNotice(
|
||||||
|
"Applied \(r.name) — CAL_ calibrations cannot enable printtarg -K.",
|
||||||
|
kind: .warning,
|
||||||
|
autoHideAfter: nil)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.applyCalibration && !calPath.isEmpty {
|
||||||
|
guard FileManager.default.fileExists(atPath: calPath) else {
|
||||||
|
workflow.profile.applyCalibration = false
|
||||||
|
workflow.profile.calibrationFile = calPath
|
||||||
|
workflow.wizard.showNotice(
|
||||||
|
"Calibration file is missing: \(calPath)", kind: .error)
|
||||||
|
refreshStaleness()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let staleDays = environment.settingsStore.load().calibrationStaleDays
|
||||||
|
let calStore = CalibrationStore(staleDays: staleDays)
|
||||||
|
try await calStore.load(url: URL(fileURLWithPath: calPath))
|
||||||
|
workflow.profile.calibrationFile = calPath
|
||||||
|
workflow.profile.applyCalibration = true
|
||||||
|
// Age check only — the .cal DESCRIPTOR is free text, not
|
||||||
|
// a queue id, so a name compare false-positives.
|
||||||
|
if await calStore.isStale() {
|
||||||
|
workflow.wizard.showNotice(
|
||||||
|
"Applied \(r.name) — calibration is stale.",
|
||||||
|
kind: .warning)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
workflow.profile.applyCalibration = false
|
||||||
|
workflow.wizard.showNotice(
|
||||||
|
"Could not load calibration: \(error.localizedDescription)",
|
||||||
|
kind: .error)
|
||||||
|
refreshStaleness()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
workflow.profile.applyCalibration = false
|
||||||
|
workflow.profile.calibrationFile = calPath
|
||||||
|
}
|
||||||
|
|
||||||
|
if succeeded {
|
||||||
|
selectedRecipeID = r.id
|
||||||
|
workflow.wizard.showNotice("Applied \(r.name)")
|
||||||
|
}
|
||||||
|
refreshStaleness()
|
||||||
|
return succeeded
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Capture
|
||||||
|
|
||||||
|
/// Whether the live `profile.calibrationFile` may be applied:
|
||||||
|
/// non-empty, not a `CAL_` stem, and present on disk.
|
||||||
|
var calApplyable: Bool {
|
||||||
|
let path = workflow.profile.calibrationFile
|
||||||
|
guard !path.isEmpty else { return false }
|
||||||
|
let stem = URL(fileURLWithPath: path)
|
||||||
|
.deletingPathExtension().lastPathComponent
|
||||||
|
guard !CalibrationIdentity.isCalibration(stem) else { return false }
|
||||||
|
return FileManager.default.fileExists(atPath: path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A bound preset whose colour space disagrees with the live form —
|
||||||
|
/// the sheet shows the mismatch caption and disables Save.
|
||||||
|
var captureColourSpaceMismatch: Bool {
|
||||||
|
guard let preset = workflow.selectedPreset else { return false }
|
||||||
|
return preset.colourSpace.lowercased() != workflow.colourSpace.rawValue
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the capture sheet, prefilled from the selected recipe else
|
||||||
|
/// the most recently captured one ("last recipe … or empty").
|
||||||
|
func beginCapture() {
|
||||||
|
let source = recipes.first(where: { $0.id == selectedRecipeID })
|
||||||
|
?? recipes.last
|
||||||
|
saveMediaPaper = source?.paperName ?? ""
|
||||||
|
saveMediaInk = source?.inkSet ?? ""
|
||||||
|
saveMediaName = ""
|
||||||
|
saveMediaNotes = ""
|
||||||
|
saveMediaError = nil
|
||||||
|
saveMediaApplyCal = workflow.profile.applyCalibration && calApplyable
|
||||||
|
if workflow.print.printers.isEmpty {
|
||||||
|
workflow.print.refreshPrinters()
|
||||||
|
}
|
||||||
|
workflow.showingSaveMedia = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save button — the sheet closes only on `true`.
|
||||||
|
func captureFromSession() async -> Bool {
|
||||||
|
let name = saveMediaName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let paper = saveMediaPaper.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let ink = saveMediaInk.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !name.isEmpty, !paper.isEmpty, !ink.isEmpty else {
|
||||||
|
saveMediaError = "Name, paper and ink are required."
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let queue = workflow.print.selectedPrinter
|
||||||
|
guard !queue.isEmpty else {
|
||||||
|
saveMediaError = "Select a printer in Stage 2 first."
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preset binding: the selected preset when its colour space
|
||||||
|
// matches the live form; otherwise auto-snapshot the live form
|
||||||
|
// as a custom preset (decision 2).
|
||||||
|
let presetID: String
|
||||||
|
if let bound = workflow.selectedPreset {
|
||||||
|
guard bound.colourSpace.lowercased() == workflow.colourSpace.rawValue else {
|
||||||
|
saveMediaError = "Colour space does not match the selected preset."
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
presetID = bound.id
|
||||||
|
} else {
|
||||||
|
let snapshot = ProfilingPreset(
|
||||||
|
id: "custom-\(UUID().uuidString.lowercased())",
|
||||||
|
name: name,
|
||||||
|
description: "Auto-saved for media recipe",
|
||||||
|
targen: workflow.buildTargenConfig(),
|
||||||
|
printtarg: workflow.buildPrinttargConfig(),
|
||||||
|
colprof: workflow.profile.buildColprofConfig(),
|
||||||
|
calibrationFile: nil,
|
||||||
|
applyCalibration: nil
|
||||||
|
)
|
||||||
|
do {
|
||||||
|
try environment.presetStore.saveCustom(snapshot)
|
||||||
|
workflow.reloadPresets()
|
||||||
|
workflow.selectedPresetID = snapshot.id
|
||||||
|
} catch {
|
||||||
|
saveMediaError = "Could not save preset: \(error.localizedDescription)"
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
presetID = snapshot.id
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cal path is stored verbatim; applyCalibration is forced
|
||||||
|
// off for CAL_/missing paths via calApplyable.
|
||||||
|
let calPath = workflow.profile.calibrationFile
|
||||||
|
let printer = workflow.print.printers.first { $0.name == queue }
|
||||||
|
let now = Date()
|
||||||
|
let recipe = MediaRecipe(
|
||||||
|
id: "recipe-\(UUID().uuidString.lowercased())",
|
||||||
|
name: name,
|
||||||
|
notes: saveMediaNotes.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
printerID: queue,
|
||||||
|
printerDisplayName: printer?.displayName ?? queue,
|
||||||
|
paperName: paper,
|
||||||
|
driverMediaType: workflow.print.selectedMediaType,
|
||||||
|
inkSet: ink,
|
||||||
|
colourSpace: workflow.colourSpace.rawValue,
|
||||||
|
presetID: presetID,
|
||||||
|
calibrationURL: calPath.isEmpty ? nil : calPath,
|
||||||
|
applyCalibration: saveMediaApplyCal && calApplyable,
|
||||||
|
created: now,
|
||||||
|
updated: now
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let validated = try recipe.validated()
|
||||||
|
try await store.upsert(validated)
|
||||||
|
await reloadAsync()
|
||||||
|
selectedRecipeID = validated.id
|
||||||
|
workflow.wizard.showNotice("Media recipe saved: \(validated.name)")
|
||||||
|
return true
|
||||||
|
} catch let error as MediaLibraryStore.MediaLibraryError {
|
||||||
|
saveMediaError = error.errorDescription
|
||||||
|
return false
|
||||||
|
} catch {
|
||||||
|
saveMediaError = "Could not save: \(error.localizedDescription)"
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Delete
|
||||||
|
|
||||||
|
func delete(_ recipe: MediaRecipe) {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await store.delete(id: recipe.id)
|
||||||
|
await reloadAsync()
|
||||||
|
if selectedRecipeID == recipe.id {
|
||||||
|
selectedRecipeID = "none"
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
workflow.wizard.showNotice(
|
||||||
|
"Could not delete: \(error.localizedDescription)",
|
||||||
|
kind: .error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Staleness
|
||||||
|
|
||||||
|
func refreshStaleness() {
|
||||||
|
Task { await refreshStalenessAsync() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recomputes `staleReasons` + `calAgeDays` for every recipe.
|
||||||
|
///
|
||||||
|
/// `.printer` fires only when `printerID` is absent from a
|
||||||
|
/// **non-empty** enumerated queue list — an un-enumerated list is
|
||||||
|
/// indeterminate, not stale (decision 5). `.calibration` is a pure
|
||||||
|
/// age check (`isStale()` with no `comparedTo:`) — the `.cal`
|
||||||
|
/// DESCRIPTOR is free text, not a queue id.
|
||||||
|
func refreshStalenessAsync() async {
|
||||||
|
let staleDays = environment.settingsStore.load().calibrationStaleDays
|
||||||
|
let queues = workflow.print.printers
|
||||||
|
let now = Date()
|
||||||
|
let calStore = CalibrationStore(staleDays: staleDays)
|
||||||
|
|
||||||
|
var reasons: [String: StaleReason] = [:]
|
||||||
|
var ages: [String: Int] = [:]
|
||||||
|
for r in recipes {
|
||||||
|
var flags: StaleReason = []
|
||||||
|
if !queues.isEmpty, !queues.contains(where: { $0.name == r.printerID }) {
|
||||||
|
flags.insert(.printer)
|
||||||
|
}
|
||||||
|
if let raw = r.calibrationURL?.trimmingCharacters(in: .whitespaces),
|
||||||
|
!raw.isEmpty,
|
||||||
|
FileManager.default.fileExists(atPath: raw),
|
||||||
|
(try? await calStore.load(url: URL(fileURLWithPath: raw))) != nil,
|
||||||
|
let created = await calStore.data?.created {
|
||||||
|
ages[r.id] = Calendar.current
|
||||||
|
.dateComponents([.day], from: created, to: now).day ?? 0
|
||||||
|
if await calStore.isStale() {
|
||||||
|
flags.insert(.calibration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !flags.isEmpty { reasons[r.id] = flags }
|
||||||
|
}
|
||||||
|
staleReasons = reasons
|
||||||
|
calAgeDays = ages
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Manage sheet flow
|
||||||
|
|
||||||
|
/// Called from the manage sheet's `onDismiss`. A deferred capture
|
||||||
|
/// request opens the save sheet only now, after the manage sheet has
|
||||||
|
/// fully dismissed.
|
||||||
|
func manageDismissed() {
|
||||||
|
if captureAfterManageDismiss {
|
||||||
|
captureAfterManageDismiss = false
|
||||||
|
beginCapture()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import ICCeryCore
|
|||||||
/// `#savePresetDialog` — save the live Stage 1/2 form as a custom
|
/// `#savePresetDialog` — save the live Stage 1/2 form as a custom
|
||||||
/// preset (issue #11). Names/descriptions render via `Text` only (#114).
|
/// preset (issue #11). Names/descriptions render via `Text` only (#114).
|
||||||
struct SavePresetDialog: View {
|
struct SavePresetDialog: View {
|
||||||
@Bindable var workflow: TargetWorkflowViewModel
|
@ObservedObject var workflow: TargetWorkflowViewModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 14) {
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
@@ -35,7 +35,7 @@ struct SavePresetDialog: View {
|
|||||||
|
|
||||||
/// `#managePresetsDialog` — list, delete (custom only), import, export.
|
/// `#managePresetsDialog` — list, delete (custom only), import, export.
|
||||||
struct ManagePresetsDialog: View {
|
struct ManagePresetsDialog: View {
|
||||||
@Bindable var workflow: TargetWorkflowViewModel
|
@ObservedObject var workflow: TargetWorkflowViewModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
|||||||
@@ -1,23 +1,22 @@
|
|||||||
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
|
||||||
import ICCeryCore
|
import ICCeryCore
|
||||||
|
|
||||||
/// CUPS queue selection, bound print panel, and `lp` spool (issues 12–15, 17 / #85).
|
/// CUPS queue selection, bound print panel, and `lp` spool (issues 12–15, 17 / #85).
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
final class PrintSessionViewModel: ObservableObject {
|
||||||
final class PrintSessionViewModel {
|
|
||||||
let wizard: WizardViewModel
|
let wizard: WizardViewModel
|
||||||
let environment: AppEnvironment
|
let environment: AppEnvironment
|
||||||
|
|
||||||
var printers: [Printer] = []
|
@Published var printers: [Printer] = []
|
||||||
var selectedPrinter = ""
|
@Published var selectedPrinter = ""
|
||||||
var printerCaps = PrinterCapabilities()
|
@Published var printerCaps = PrinterCapabilities()
|
||||||
var selectedTray: Int?
|
@Published var selectedTray: Int?
|
||||||
var selectedMediaType: String?
|
@Published var selectedMediaType: String?
|
||||||
var printOrientation = "portrait"
|
@Published var printOrientation = "portrait"
|
||||||
var capturedCupsOptions: [String: String] = [:]
|
@Published var capturedCupsOptions: [String: String] = [:]
|
||||||
var printNotice: Notice?
|
@Published var printNotice: Notice?
|
||||||
var isPrinting = false
|
@Published var isPrinting = false
|
||||||
private var printTask: Task<Void, Never>?
|
private var printTask: Task<Void, Never>?
|
||||||
|
|
||||||
init(wizard: WizardViewModel, environment: AppEnvironment) {
|
init(wizard: WizardViewModel, environment: AppEnvironment) {
|
||||||
@@ -25,24 +24,41 @@ final class PrintSessionViewModel {
|
|||||||
self.environment = environment
|
self.environment = environment
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var printerEnumTask: Task<[Printer]?, Never>?
|
||||||
|
|
||||||
func refreshPrinters() {
|
func refreshPrinters() {
|
||||||
let cups = environment.cupsService
|
Task { @MainActor in _ = await enumeratePrinters() }
|
||||||
Task { @MainActor in
|
}
|
||||||
|
|
||||||
|
/// Serialized queue enumeration — `listPrinters` uses fixed process
|
||||||
|
/// ids, so overlapping calls would throw `duplicateID`. Concurrent
|
||||||
|
/// callers coalesce onto the in-flight task (#146).
|
||||||
|
@discardableResult
|
||||||
|
func enumeratePrinters() async -> [Printer]? {
|
||||||
|
if let pending = printerEnumTask { return await pending.value }
|
||||||
|
let task = Task { @MainActor [weak self] () -> [Printer]? in
|
||||||
|
guard let self else { return nil }
|
||||||
do {
|
do {
|
||||||
let list = try await cups.listPrinters()
|
let list = try await self.environment.cupsService.listPrinters()
|
||||||
printers = list
|
self.printers = list
|
||||||
if !list.contains(where: { $0.name == selectedPrinter }) {
|
if !list.contains(where: { $0.name == self.selectedPrinter }) {
|
||||||
selectedPrinter = list.first { $0.isDefault }?.name
|
self.selectedPrinter = list.first { $0.isDefault }?.name
|
||||||
?? list.first?.name ?? ""
|
?? list.first?.name ?? ""
|
||||||
}
|
}
|
||||||
await reloadSelectedCapabilities()
|
await self.reloadSelectedCapabilities()
|
||||||
|
return list
|
||||||
} catch {
|
} catch {
|
||||||
printNotice = Notice(
|
self.printNotice = Notice(
|
||||||
kind: .error,
|
kind: .error,
|
||||||
text: "Could not list printers: \(error.localizedDescription)"
|
text: "Could not list printers: \(error.localizedDescription)"
|
||||||
)
|
)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
printerEnumTask = task
|
||||||
|
let result = await task.value
|
||||||
|
printerEnumTask = nil
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func reloadSelectedCapabilities() async {
|
func reloadSelectedCapabilities() async {
|
||||||
@@ -111,7 +127,8 @@ final class PrintSessionViewModel {
|
|||||||
isPrinting = true
|
isPrinting = true
|
||||||
let task = Task { @MainActor [weak self] in
|
let task = Task { @MainActor [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
defer { self.printTask = nil }
|
// `defer` cannot mutate isolated state under Swift 5.7
|
||||||
|
// (Xcode 14.2 / macOS 12 runner), so clear explicitly (#113).
|
||||||
var printed = 0
|
var printed = 0
|
||||||
for page in result.pages {
|
for page in result.pages {
|
||||||
do {
|
do {
|
||||||
@@ -124,6 +141,7 @@ final class PrintSessionViewModel {
|
|||||||
+ error.localizedDescription
|
+ error.localizedDescription
|
||||||
)
|
)
|
||||||
isPrinting = false
|
isPrinting = false
|
||||||
|
self.printTask = nil
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,6 +151,7 @@ final class PrintSessionViewModel {
|
|||||||
autoHideAfter: nil
|
autoHideAfter: nil
|
||||||
)
|
)
|
||||||
isPrinting = false
|
isPrinting = false
|
||||||
|
self.printTask = nil
|
||||||
}
|
}
|
||||||
printTask = task
|
printTask = task
|
||||||
}
|
}
|
||||||
@@ -142,7 +161,6 @@ final class PrintSessionViewModel {
|
|||||||
isPrinting = true
|
isPrinting = true
|
||||||
let task = Task { @MainActor [weak self] in
|
let task = Task { @MainActor [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
defer { self.printTask = nil }
|
|
||||||
do {
|
do {
|
||||||
try await spool(page, index: page.index, pageSize: pageSize)
|
try await spool(page, index: page.index, pageSize: pageSize)
|
||||||
printNotice = Notice(
|
printNotice = Notice(
|
||||||
@@ -157,6 +175,7 @@ final class PrintSessionViewModel {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
isPrinting = false
|
isPrinting = false
|
||||||
|
self.printTask = nil
|
||||||
}
|
}
|
||||||
printTask = task
|
printTask = task
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import ICCeryCore
|
import ICCeryCore
|
||||||
|
|
||||||
/// Stage 4/5 workflow: build a profile, verify it, track drift, and install.
|
/// Stage 4/5 workflow: build a profile, verify it, track drift, and install.
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
final class ProfileWorkflowViewModel: ObservableObject {
|
||||||
final class ProfileWorkflowViewModel {
|
|
||||||
|
|
||||||
let wizard: WizardViewModel
|
let wizard: WizardViewModel
|
||||||
let environment: AppEnvironment
|
let environment: AppEnvironment
|
||||||
@@ -14,50 +13,50 @@ final class ProfileWorkflowViewModel {
|
|||||||
|
|
||||||
// MARK: - Stage 4 form
|
// MARK: - Stage 4 form
|
||||||
|
|
||||||
var algorithm: String = "l" // l | x | X | m
|
@Published var algorithm: String = "l" // l | x | X | m
|
||||||
var quality: String = "m" // l | m | h | u
|
@Published var quality: String = "m" // l | m | h | u
|
||||||
var intent: String = "" // usually empty at Stage 4
|
@Published var intent: String = "" // usually empty at Stage 4
|
||||||
var fwaSelection: ColprofFwaSelection = .none
|
@Published var fwaSelection: ColprofFwaSelection = .none
|
||||||
var fwaCustomPath: String = ""
|
@Published var fwaCustomPath: String = ""
|
||||||
var illuminant: String = ""
|
@Published var illuminant: String = ""
|
||||||
var observer: String = ""
|
@Published var observer: String = ""
|
||||||
var inputViewingCond: String = ""
|
@Published var inputViewingCond: String = ""
|
||||||
var outputViewingCond: String = ""
|
@Published var outputViewingCond: String = ""
|
||||||
var profileDescription: String = ""
|
@Published var profileDescription: String = ""
|
||||||
var copyright: String = ""
|
@Published var copyright: String = ""
|
||||||
|
|
||||||
// MARK: - Run state
|
// MARK: - Run state
|
||||||
|
|
||||||
var isColprofRunning = false
|
@Published var isColprofRunning = false
|
||||||
var colprofLog: [String] = []
|
@Published var colprofLog: [String] = []
|
||||||
var colprofProgress: String?
|
@Published var colprofProgress: String?
|
||||||
var createdProfileURL: URL?
|
@Published var createdProfileURL: URL?
|
||||||
/// Path to the `.gam` gamut mesh extracted post-`colprof` (issue #28).
|
/// Path to the `.gam` gamut mesh extracted post-`colprof` (issue #28).
|
||||||
var createdGamutURL: URL?
|
@Published var createdGamutURL: URL?
|
||||||
|
|
||||||
// MARK: - Stage 4/5 calibration (issue #24)
|
// MARK: - Stage 4/5 calibration (issue #24)
|
||||||
|
|
||||||
var applyCalibration = false
|
@Published var applyCalibration = false
|
||||||
var calibrationFile: String = ""
|
@Published var calibrationFile: String = ""
|
||||||
|
|
||||||
// MARK: - Stage 5 verification (issue #25)
|
// MARK: - Stage 5 verification (issue #25)
|
||||||
|
|
||||||
var profcheckReport: ProfcheckReport?
|
@Published var profcheckReport: ProfcheckReport?
|
||||||
var profcheckWarning: String?
|
@Published var profcheckWarning: String?
|
||||||
var isProfcheckRunning = false
|
@Published var isProfcheckRunning = false
|
||||||
|
|
||||||
// MARK: - History / drift (issue #26)
|
// MARK: - History / drift (issue #26)
|
||||||
|
|
||||||
var verificationHistory: [VerificationRecord] = []
|
@Published var verificationHistory: [VerificationRecord] = []
|
||||||
var driftPrinterFilter: String? = nil
|
@Published var driftPrinterFilter: String? = nil
|
||||||
var driftAlert: String?
|
@Published var driftAlert: String?
|
||||||
var isHistoryStoreError: String?
|
@Published var isHistoryStoreError: String?
|
||||||
|
|
||||||
// MARK: - Install (issue #27)
|
// MARK: - Install (issue #27)
|
||||||
|
|
||||||
var installResult: InstallProfileResult?
|
@Published var installResult: InstallProfileResult?
|
||||||
var showingInstallCollision = false
|
@Published var showingInstallCollision = false
|
||||||
var installCollisionMessage: String = ""
|
@Published var installCollisionMessage: String = ""
|
||||||
var pendingInstallOptions: InstallProfileOptions?
|
var pendingInstallOptions: InstallProfileOptions?
|
||||||
|
|
||||||
init(wizard: WizardViewModel, environment: AppEnvironment) {
|
init(wizard: WizardViewModel, environment: AppEnvironment) {
|
||||||
|
|||||||
@@ -5,12 +5,18 @@ import ICCeryCore
|
|||||||
/// Root layout: 270 pt sidebar + main stage area with the notification
|
/// Root layout: 270 pt sidebar + main stage area with the notification
|
||||||
/// banner pinned to the top (docs/21 §Shell).
|
/// banner pinned to the top (docs/21 §Shell).
|
||||||
struct RootView: View {
|
struct RootView: View {
|
||||||
@Bindable var workflow: TargetWorkflowViewModel
|
@ObservedObject var workflow: TargetWorkflowViewModel
|
||||||
|
/// Observed directly: nested ObservableObjects are not tracked
|
||||||
|
/// through the parent's `objectWillChange`.
|
||||||
|
@ObservedObject private var model: WizardViewModel
|
||||||
@State private var showingSettings = false
|
@State private var showingSettings = false
|
||||||
@State private var showingAbout = false
|
@State private var showingAbout = false
|
||||||
@State private var showingAllHelp = false
|
@State private var showingAllHelp = false
|
||||||
|
|
||||||
private var model: WizardViewModel { workflow.wizard }
|
init(workflow: TargetWorkflowViewModel) {
|
||||||
|
self.workflow = workflow
|
||||||
|
self._model = ObservedObject(wrappedValue: workflow.wizard)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
@@ -50,6 +56,25 @@ struct RootView: View {
|
|||||||
.sheet(isPresented: $workflow.showingManagePresets) {
|
.sheet(isPresented: $workflow.showingManagePresets) {
|
||||||
ManagePresetsDialog(workflow: workflow)
|
ManagePresetsDialog(workflow: workflow)
|
||||||
}
|
}
|
||||||
|
// Media library sheets live on RootView, never inside the
|
||||||
|
// 270 pt sidebar column (#146).
|
||||||
|
.sheet(isPresented: $workflow.showingSaveMedia) {
|
||||||
|
SaveMediaRecipeDialog(workflow: workflow)
|
||||||
|
}
|
||||||
|
.sheet(
|
||||||
|
isPresented: $workflow.showingManageMedia,
|
||||||
|
onDismiss: { workflow.media.manageDismissed() }
|
||||||
|
) {
|
||||||
|
ManageMediaDialog(workflow: workflow)
|
||||||
|
}
|
||||||
|
// Spot-read console sheet (issue #148). Dismiss runs the same
|
||||||
|
// `q\n` + ~500 ms + kill path as the sheet's Stop button.
|
||||||
|
.sheet(
|
||||||
|
isPresented: $workflow.showingSpotRead,
|
||||||
|
onDismiss: { workflow.spotRead.sheetClosed() }
|
||||||
|
) {
|
||||||
|
SpotReadView(model: workflow.spotRead)
|
||||||
|
}
|
||||||
.sheet(isPresented: $showingAbout) {
|
.sheet(isPresented: $showingAbout) {
|
||||||
AboutView { showingAbout = false }
|
AboutView { showingAbout = false }
|
||||||
}
|
}
|
||||||
@@ -64,11 +89,11 @@ struct RootView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Content for the active wizard stage. Isolated into its own view so that
|
/// Content for the active wizard stage. Isolated into its own view so that
|
||||||
/// `WizardViewModel` is tracked via `@Bindable` instead of the parent's
|
/// `WizardViewModel` is tracked via `@ObservedObject` instead of the parent's
|
||||||
/// `TargetWorkflowViewModel`, which does not observe nested `wizard` mutations.
|
/// `TargetWorkflowViewModel`, which does not observe nested `wizard` mutations.
|
||||||
private struct WizardStageContent: View {
|
private struct WizardStageContent: View {
|
||||||
@Bindable var model: WizardViewModel
|
@ObservedObject var model: WizardViewModel
|
||||||
var workflow: TargetWorkflowViewModel
|
@ObservedObject var workflow: TargetWorkflowViewModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
switch model.stage {
|
switch model.stage {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import ICCeryCore
|
|||||||
/// Settings sheet (issue #5, docs/21 §Settings). Dark-theme Form with
|
/// Settings sheet (issue #5, docs/21 §Settings). Dark-theme Form with
|
||||||
/// the full v1 field set; ΔE validation shows inline under the fields.
|
/// the full v1 field set; ΔE validation shows inline under the fields.
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@State var model = SettingsViewModel()
|
@StateObject var model = SettingsViewModel()
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
private static let instruments: [(code: String, label: String)] = [
|
private static let instruments: [(code: String, label: String)] = [
|
||||||
@@ -58,7 +58,7 @@ struct SettingsView: View {
|
|||||||
Text($0.label).tag($0.code)
|
Text($0.label).tag($0.code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text("Display-only — Stage 2's instrument select is used for actual runs.")
|
Text("Seeds Spot Read and Stage 3 when the instrument is plugged in. printtarg -i is still chosen on Stage 2.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
@@ -143,7 +143,6 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import ICCeryCore
|
import ICCeryCore
|
||||||
|
|
||||||
@@ -6,12 +7,11 @@ import ICCeryCore
|
|||||||
/// validation; the log level is applied live via `LogSink` (#158) and a
|
/// validation; the log level is applied live via `LogSink` (#158) and a
|
||||||
/// `settingsDidChange` notification fans out to #20.
|
/// `settingsDidChange` notification fans out to #20.
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
final class SettingsViewModel: ObservableObject {
|
||||||
final class SettingsViewModel {
|
|
||||||
|
|
||||||
var settings: AppSettings
|
@Published var settings: AppSettings
|
||||||
var validationErrors: [String] = []
|
@Published var validationErrors: [String] = []
|
||||||
var savedFlash = false
|
@Published var savedFlash = false
|
||||||
|
|
||||||
private let store: SettingsStore
|
private let store: SettingsStore
|
||||||
private let sink: LogSink
|
private let sink: LogSink
|
||||||
|
|||||||
@@ -4,12 +4,34 @@ import ICCeryCore
|
|||||||
/// 270 pt sidebar (docs/21 §Shell): logo, settings/about buttons, preset
|
/// 270 pt sidebar (docs/21 §Shell): logo, settings/about buttons, preset
|
||||||
/// select, Calibrate Printer + status chip, and the 1–5 stepper.
|
/// select, Calibrate Printer + status chip, and the 1–5 stepper.
|
||||||
struct SidebarView: View {
|
struct SidebarView: View {
|
||||||
@Bindable var workflow: TargetWorkflowViewModel
|
@ObservedObject var workflow: TargetWorkflowViewModel
|
||||||
|
/// Observed directly: nested ObservableObjects are not tracked
|
||||||
|
/// through the parent's `objectWillChange`.
|
||||||
|
@ObservedObject private var model: WizardViewModel
|
||||||
|
@ObservedObject private var profile: ProfileWorkflowViewModel
|
||||||
|
@ObservedObject private var media: MediaLibraryViewModel
|
||||||
|
@ObservedObject private var printSession: PrintSessionViewModel
|
||||||
|
@ObservedObject private var measurement: MeasurementWorkflowViewModel
|
||||||
var onOpenSettings: () -> Void
|
var onOpenSettings: () -> Void
|
||||||
var onOpenAbout: () -> Void
|
var onOpenAbout: () -> Void
|
||||||
@Binding var showingAllHelp: Bool
|
@Binding var showingAllHelp: Bool
|
||||||
|
|
||||||
private var model: WizardViewModel { workflow.wizard }
|
init(
|
||||||
|
workflow: TargetWorkflowViewModel,
|
||||||
|
onOpenSettings: @escaping () -> Void,
|
||||||
|
onOpenAbout: @escaping () -> Void,
|
||||||
|
showingAllHelp: Binding<Bool>
|
||||||
|
) {
|
||||||
|
self.workflow = workflow
|
||||||
|
self._model = ObservedObject(wrappedValue: workflow.wizard)
|
||||||
|
self._profile = ObservedObject(wrappedValue: workflow.profile)
|
||||||
|
self._media = ObservedObject(wrappedValue: workflow.media)
|
||||||
|
self._printSession = ObservedObject(wrappedValue: workflow.print)
|
||||||
|
self._measurement = ObservedObject(wrappedValue: workflow.measurement)
|
||||||
|
self.onOpenSettings = onOpenSettings
|
||||||
|
self.onOpenAbout = onOpenAbout
|
||||||
|
self._showingAllHelp = showingAllHelp
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
@@ -74,6 +96,56 @@ struct SidebarView: View {
|
|||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.bottom, 8)
|
.padding(.bottom, 8)
|
||||||
|
|
||||||
|
// Media library (`#mediaSelect`) — issue #146. Selection
|
||||||
|
// applies the recipe immediately, like presets; names render
|
||||||
|
// via Text only (#114). Never reuses `presetSelect` (#137).
|
||||||
|
Picker("Media", selection: Binding(
|
||||||
|
get: { media.selectedRecipeID },
|
||||||
|
set: { media.selectRecipe($0) }
|
||||||
|
)) {
|
||||||
|
Text("No media recipe").tag("none")
|
||||||
|
ForEach(media.recipes) { recipe in
|
||||||
|
Text(recipe.name).tag(recipe.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
.accessibilityIdentifier("mediaSelect")
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.helpOverlay(
|
||||||
|
"Saved printer + paper + ink + .cal bound to a preset.",
|
||||||
|
showing: $showingAllHelp)
|
||||||
|
|
||||||
|
if let reasons = media.staleReasons[media.selectedRecipeID],
|
||||||
|
!reasons.isEmpty {
|
||||||
|
Text(reasons.contains(.printer)
|
||||||
|
? "Printer not installed" : "Calibration stale")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.accessibilityIdentifier("mediaRecipeStale")
|
||||||
|
.helpOverlay(
|
||||||
|
"Re-run Stage 0 or pick a different recipe.",
|
||||||
|
showing: $showingAllHelp)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Button("Capture") { media.beginCapture() }
|
||||||
|
.disabled(printSession.selectedPrinter.isEmpty)
|
||||||
|
.accessibilityIdentifier("btnMediaLibraryCapture")
|
||||||
|
.helpOverlay(
|
||||||
|
"Select a printer in Stage 2 first",
|
||||||
|
showing: $showingAllHelp)
|
||||||
|
Button("Manage") { workflow.showingManageMedia = true }
|
||||||
|
.accessibilityIdentifier("btnMediaLibraryManage")
|
||||||
|
.helpOverlay(
|
||||||
|
"Apply or delete saved media recipes.",
|
||||||
|
showing: $showingAllHelp)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
|
||||||
// Calibrate Printer (`#btnCalibratePrinter`).
|
// 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")
|
||||||
@@ -91,6 +163,26 @@ struct SidebarView: View {
|
|||||||
.accessibilityIdentifier("btnViewGamut")
|
.accessibilityIdentifier("btnViewGamut")
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
|
|
||||||
|
// Spot Read sheet (`#btnSpotRead`) — issue #148. Enabled
|
||||||
|
// only with a working folder (#59) and while no Stage 3
|
||||||
|
// chartread child is live; opening never kills
|
||||||
|
// `chartread_{basename}`.
|
||||||
|
Button(action: { workflow.showingSpotRead = true }) {
|
||||||
|
Label("Spot Read", systemImage: "eyedropper")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.controlSize(.large)
|
||||||
|
.disabled(model.workingDirectory == nil || measurement.isChartreadRunning)
|
||||||
|
.helpOverlay(
|
||||||
|
model.workingDirectory == nil
|
||||||
|
? "Set a working folder in Stage 1 first."
|
||||||
|
: (measurement.isChartreadRunning
|
||||||
|
? "Stop the Stage 3 chart read first."
|
||||||
|
: "Read a single patch as Lab/XYZ from the instrument."),
|
||||||
|
showing: $showingAllHelp)
|
||||||
|
.accessibilityIdentifier("btnSpotRead")
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
|
||||||
Divider().overlay(Theme.border)
|
Divider().overlay(Theme.border)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,360 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// Spot Read sheet (issue #148) — one patch Lab/XYZ from the live
|
||||||
|
/// instrument. A `RootView` sheet, not a wizard stage and not a Stage 3
|
||||||
|
/// tab; all identifiers are `spot*` — Stage 3 `chartread` ids are never
|
||||||
|
/// reused here.
|
||||||
|
struct SpotReadView: View {
|
||||||
|
@ObservedObject var model: SpotReadViewModel
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
header
|
||||||
|
if !model.sidecarAvailable {
|
||||||
|
missingSidecar
|
||||||
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
instrumentCard
|
||||||
|
promptLine
|
||||||
|
transport
|
||||||
|
lastSampleCard
|
||||||
|
historySection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
footer
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.frame(width: 560, height: 640)
|
||||||
|
.background(Theme.background)
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityIdentifier("spotReadView")
|
||||||
|
.onAppear { model.sheetOpened() }
|
||||||
|
.onDisappear { model.sheetClosed() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Header / missing sidecar
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
HStack(alignment: .firstTextBaseline) {
|
||||||
|
Text("Spot Read")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
Spacer()
|
||||||
|
if model.isRunning {
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(0.8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var missingSidecar: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("spotread sidecar missing — run fetch-argyll")
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.accessibilityIdentifier("spotSidecarMissing")
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Instrument card (clones Stage 3 look, own ids)
|
||||||
|
|
||||||
|
private var instrumentCard: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack {
|
||||||
|
Text("Instrument")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
Spacer()
|
||||||
|
Button(action: { model.detectInstruments() }) {
|
||||||
|
Image(systemName: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
.disabled(!model.canDetect)
|
||||||
|
.accessibilityIdentifier("btnSpotDetectInstruments")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = model.detectionError {
|
||||||
|
Text(error)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.accessibilityIdentifier("spotDetectError")
|
||||||
|
}
|
||||||
|
|
||||||
|
Picker("Instrument", selection: Binding(
|
||||||
|
get: { instrumentTag },
|
||||||
|
set: { newTag in
|
||||||
|
if newTag.isEmpty {
|
||||||
|
model.selectedInstrument = .auto
|
||||||
|
} else if let device = model.instruments.first(where: { "\($0.port)" == newTag }) {
|
||||||
|
model.selectedInstrument = .device(device)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)) {
|
||||||
|
Text("Auto (first available port)").tag("")
|
||||||
|
ForEach(model.instruments) { device in
|
||||||
|
Text(device.displayName).tag("\(device.port)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
.disabled(model.isRunning)
|
||||||
|
.accessibilityIdentifier("spotInstrumentSelect")
|
||||||
|
|
||||||
|
if model.defaultMissing {
|
||||||
|
Text("Saved default instrument not present")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.accessibilityIdentifier("spotDefaultMissing")
|
||||||
|
}
|
||||||
|
|
||||||
|
Toggle("Also set as default instrument", isOn: Binding(
|
||||||
|
get: { model.setAsDefault },
|
||||||
|
set: { model.applyDefaultToggle($0) }
|
||||||
|
))
|
||||||
|
.accessibilityIdentifier("spotSetDefault")
|
||||||
|
|
||||||
|
if model.selectedInstrument.isXY {
|
||||||
|
Text("XY tables use Stage 3. Spot Read is a handheld / reflective probe.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Theme.accent)
|
||||||
|
.accessibilityIdentifier("spotXYHint")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Theme.panel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var instrumentTag: String {
|
||||||
|
switch model.selectedInstrument {
|
||||||
|
case .auto:
|
||||||
|
return ""
|
||||||
|
case .device(let device):
|
||||||
|
return "\(device.port)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Prompt line
|
||||||
|
|
||||||
|
private var promptLine: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack {
|
||||||
|
Text("Status")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
Spacer()
|
||||||
|
Text(model.prompt)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.accessibilityIdentifier("spotPrompt")
|
||||||
|
}
|
||||||
|
if let error = model.lastError {
|
||||||
|
Text(error)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.accessibilityIdentifier("spotLastError")
|
||||||
|
.accessibilityValue(error)
|
||||||
|
}
|
||||||
|
if !model.log.isEmpty {
|
||||||
|
ProcessLogView(
|
||||||
|
lines: model.log,
|
||||||
|
minHeight: 60,
|
||||||
|
maxHeight: 100,
|
||||||
|
containerId: "spotLogContainer",
|
||||||
|
logId: "spotLog"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Theme.panel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Transport
|
||||||
|
|
||||||
|
private var transport: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
if !model.isRunning {
|
||||||
|
Button("Start") { model.start() }
|
||||||
|
.disabled(!model.canStart)
|
||||||
|
.accessibilityIdentifier("btnSpotStart")
|
||||||
|
} else {
|
||||||
|
switch model.state {
|
||||||
|
case .calibrating:
|
||||||
|
Button("Calibrate") { model.calibrate() }
|
||||||
|
.accessibilityIdentifier("btnSpotCalibrate")
|
||||||
|
case .awaitingStrip:
|
||||||
|
Button("Read") { model.trigger() }
|
||||||
|
.accessibilityIdentifier("btnSpotTrigger")
|
||||||
|
default:
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
Button("Stop") { model.stopIfNeeded() }
|
||||||
|
.accessibilityIdentifier("btnSpotStop")
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Last sample
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var lastSampleCard: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Last sample")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
|
||||||
|
if let sample = model.displayedSample {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
let rgb = LabColorMath.labToSRGB(sample.lab)
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.fill(Color(red: rgb.r, green: rgb.g, blue: rgb.b))
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 4).stroke(Theme.border))
|
||||||
|
.accessibilityIdentifier("spotSwatch")
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Text(String(format: "L* %.1f", sample.lab.l))
|
||||||
|
.accessibilityIdentifier("spotLabL")
|
||||||
|
Text(String(format: "a* %.1f", sample.lab.a))
|
||||||
|
.accessibilityIdentifier("spotLabA")
|
||||||
|
Text(String(format: "b* %.1f", sample.lab.b))
|
||||||
|
.accessibilityIdentifier("spotLabB")
|
||||||
|
}
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
|
||||||
|
if let xyz = sample.xyz {
|
||||||
|
Text(String(format: "XYZ %.2f %.2f %.2f", xyz.x, xyz.y, xyz.z))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.accessibilityIdentifier("spotXYZ")
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text(sample.port.map { "\(sample.instrumentName) · port \($0)" }
|
||||||
|
?? sample.instrumentName)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.accessibilityIdentifier("spotLastInstrument")
|
||||||
|
|
||||||
|
if let de = model.displayedDeltaE {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Circle()
|
||||||
|
.fill(deltaEColor)
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
Text(String(format: "ΔE %.2f", de))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityIdentifier("spotDeltaE")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if model.isDisplayedLabImplausible {
|
||||||
|
Text("Implausible L*")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.accessibilityIdentifier("spotLabImplausible")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityIdentifier("spotLastSample")
|
||||||
|
} else {
|
||||||
|
Text("No readings yet.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.accessibilityIdentifier("spotLastEmpty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Theme.panel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var deltaEColor: Color {
|
||||||
|
switch model.deltaEClassification {
|
||||||
|
case .good, nil: return .green
|
||||||
|
case .warning: return .orange
|
||||||
|
case .bad: return .red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - History
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var historySection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("History")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
|
||||||
|
if model.samples.isEmpty {
|
||||||
|
Text("No history.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.accessibilityIdentifier("spotHistoryEmpty")
|
||||||
|
} else {
|
||||||
|
List {
|
||||||
|
ForEach(Array(model.samples.enumerated()), id: \.element.id) { index, sample in
|
||||||
|
historyRow(index: index, sample: sample)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(minHeight: 120)
|
||||||
|
.accessibilityIdentifier("spotHistoryTable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Theme.panel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func historyRow(index: Int, sample: SpotReadSample) -> some View {
|
||||||
|
let previous = index + 1 < model.samples.count ? model.samples[index + 1] : nil
|
||||||
|
let deltaE = previous.map { ColorDifference.deltaE00($0.lab, sample.lab) }
|
||||||
|
return Button(action: { model.selectFromHistory(sample) }) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Text(sample.timestamp, style: .time)
|
||||||
|
.frame(width: 70, alignment: .leading)
|
||||||
|
Text(String(format: "%.1f", sample.lab.l))
|
||||||
|
.frame(width: 44, alignment: .trailing)
|
||||||
|
Text(String(format: "%.1f", sample.lab.a))
|
||||||
|
.frame(width: 44, alignment: .trailing)
|
||||||
|
Text(String(format: "%.1f", sample.lab.b))
|
||||||
|
.frame(width: 44, alignment: .trailing)
|
||||||
|
Text(deltaE.map { String(format: "%.2f", $0) } ?? "")
|
||||||
|
.frame(width: 44, alignment: .trailing)
|
||||||
|
Text(sample.instrumentName)
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityIdentifier("spotHistoryRow-\(sample.id.uuidString)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Footer
|
||||||
|
|
||||||
|
private var footer: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button("Copy Lab") { model.copyLab() }
|
||||||
|
.disabled(model.displayedSample == nil)
|
||||||
|
.accessibilityIdentifier("btnSpotCopyLab")
|
||||||
|
Button("Export CSV…") { model.exportCsv() }
|
||||||
|
.disabled(model.samples.isEmpty)
|
||||||
|
.accessibilityIdentifier("btnSpotExportCsv")
|
||||||
|
Spacer()
|
||||||
|
Button("Close") { dismiss() }
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
.accessibilityIdentifier("btnCloseSpotRead")
|
||||||
|
}
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
import AppKit
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// Spot-read console state and interaction (issue #148).
|
||||||
|
///
|
||||||
|
/// Runs the bundled `spotread` sidecar under the single-lease process id
|
||||||
|
/// `spotread`; Stage 3 `chartread` is untouched. `defaultInstrument`
|
||||||
|
/// seeds the instrument picker on sheet open — it is never written into
|
||||||
|
/// `printtarg -i` or `targen` argv (R15).
|
||||||
|
@MainActor
|
||||||
|
final class SpotReadViewModel: ObservableObject {
|
||||||
|
|
||||||
|
let workflow: TargetWorkflowViewModel
|
||||||
|
let environment: AppEnvironment
|
||||||
|
private let fileDialogs = FileDialogService.shared
|
||||||
|
|
||||||
|
// MARK: - Instrument card
|
||||||
|
|
||||||
|
@Published var instruments: [InstrumentDevice] = []
|
||||||
|
@Published var selectedInstrument: InstrumentSelection = .auto
|
||||||
|
@Published var isDetecting = false
|
||||||
|
@Published var detectionError: String?
|
||||||
|
/// `spotDefaultMissing` — set when `defaultInstrument` is saved but
|
||||||
|
/// no detected device matches it.
|
||||||
|
@Published var defaultMissing = false
|
||||||
|
/// `spotSetDefault` toggle state.
|
||||||
|
@Published var setAsDefault = false
|
||||||
|
|
||||||
|
// MARK: - Session
|
||||||
|
|
||||||
|
@Published var isRunning = false
|
||||||
|
@Published var state: ChartreadState = .idle
|
||||||
|
@Published var prompt = "Press Start to open the instrument."
|
||||||
|
@Published var lastError: String?
|
||||||
|
@Published var log: [String] = []
|
||||||
|
|
||||||
|
// MARK: - Samples / history (in-memory, cap 50, newest first)
|
||||||
|
|
||||||
|
@Published private(set) var samples: [SpotReadSample] = []
|
||||||
|
@Published private(set) var displayedSample: SpotReadSample?
|
||||||
|
@Published private(set) var displayedDeltaE: Double?
|
||||||
|
|
||||||
|
private let historyLimit = 50
|
||||||
|
private var streamTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
init(workflow: TargetWorkflowViewModel, environment: AppEnvironment) {
|
||||||
|
self.workflow = workflow
|
||||||
|
self.environment = environment
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Derived state
|
||||||
|
|
||||||
|
/// Whether the bundled `spotread` sidecar resolves to an executable.
|
||||||
|
/// `BinaryResolver` only — never `$PATH`, never `chartread`.
|
||||||
|
var sidecarAvailable: Bool {
|
||||||
|
let url = environment.runner.binaryResolver.resolve("spotread")
|
||||||
|
return environment.runner.binaryResolver.exists(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
var isChartreadRunning: Bool { workflow.measurement.isChartreadRunning }
|
||||||
|
|
||||||
|
var canStart: Bool {
|
||||||
|
sidecarAvailable && !isDetecting && !isRunning && !isChartreadRunning
|
||||||
|
&& workflow.wizard.effectiveWorkingDirectory != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var canDetect: Bool { !isDetecting && !isRunning }
|
||||||
|
|
||||||
|
var deltaEClassification: SwatchClassification? {
|
||||||
|
guard let de = displayedDeltaE else { return nil }
|
||||||
|
let settings = environment.settingsStore.load()
|
||||||
|
return ColorDifference.classify(
|
||||||
|
deltaE: de,
|
||||||
|
goodMax: settings.deltaEGoodMax,
|
||||||
|
warningMax: settings.deltaEWarningMax)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `spotLabImplausible` — L* outside 0…100 still displays, unclamped.
|
||||||
|
var isDisplayedLabImplausible: Bool {
|
||||||
|
guard let l = displayedSample?.lab.l else { return false }
|
||||||
|
return l < 0 || l > 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sheet lifecycle
|
||||||
|
|
||||||
|
/// Called from `SpotReadView.onAppear`. Resets the in-memory session
|
||||||
|
/// and runs detection once; a missing sidecar gets a wizard notice.
|
||||||
|
func sheetOpened() {
|
||||||
|
samples = []
|
||||||
|
displayedSample = nil
|
||||||
|
displayedDeltaE = nil
|
||||||
|
log = []
|
||||||
|
lastError = nil
|
||||||
|
state = .idle
|
||||||
|
prompt = "Press Start to open the instrument."
|
||||||
|
|
||||||
|
guard sidecarAvailable else {
|
||||||
|
workflow.wizard.showNotice(
|
||||||
|
"spotread sidecar missing — run fetch-argyll", kind: .error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
detectInstruments()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called from `onDisappear` *and* the sheet's `onDismiss` — clearing
|
||||||
|
/// the flag alone is not enough; a live child must be quit and
|
||||||
|
/// killed (R14).
|
||||||
|
func sheetClosed() {
|
||||||
|
stopIfNeeded()
|
||||||
|
samples = []
|
||||||
|
displayedSample = nil
|
||||||
|
displayedDeltaE = nil
|
||||||
|
log = []
|
||||||
|
defaultMissing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Detection
|
||||||
|
|
||||||
|
func detectInstruments() {
|
||||||
|
guard canDetect else { return }
|
||||||
|
isDetecting = true
|
||||||
|
detectionError = nil
|
||||||
|
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
// `instlist` is an exclusive lease (#116) — never spawn a
|
||||||
|
// second one; surface the busy state instead.
|
||||||
|
if await self.environment.runner.processManager.isRunning(ProcessID.instlist) {
|
||||||
|
self.detectionError = "Instrument detection is already running."
|
||||||
|
self.isDetecting = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let devices = try await self.environment.runner.detectInstruments()
|
||||||
|
self.instruments = devices
|
||||||
|
self.seedDefault(from: devices)
|
||||||
|
if case .device(let selected) = self.selectedInstrument,
|
||||||
|
!devices.contains(where: { $0.port == selected.port }) {
|
||||||
|
self.selectedInstrument = .auto
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
self.detectionError = error.localizedDescription
|
||||||
|
}
|
||||||
|
self.isDetecting = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seed the picker from `AppSettings.defaultInstrument`; no match →
|
||||||
|
/// `.auto` + `spotDefaultMissing`.
|
||||||
|
private func seedDefault(from devices: [InstrumentDevice]) {
|
||||||
|
guard let code = environment.settingsStore.load().defaultInstrument,
|
||||||
|
!code.isEmpty else {
|
||||||
|
defaultMissing = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let match = devices.first(where: { Self.matches(code: code, device: $0) }) {
|
||||||
|
selectedInstrument = .device(match)
|
||||||
|
defaultMissing = false
|
||||||
|
} else {
|
||||||
|
selectedInstrument = .auto
|
||||||
|
defaultMissing = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether an `instlist` device corresponds to a `printtarg -i` /
|
||||||
|
/// settings instrument code (`i1`, `CM`, `p3`, `SS`, `20`/`22`/`41`/`51`).
|
||||||
|
static func matches(code: String, device: InstrumentDevice) -> Bool {
|
||||||
|
let haystack = "\(device.name) \(device.type)".lowercased()
|
||||||
|
switch code {
|
||||||
|
case "i1": return haystack.contains("i1pro") && !haystack.contains("i1pro 3") && !haystack.contains("i1pro3")
|
||||||
|
case "p3": return haystack.contains("i1pro 3") || haystack.contains("i1pro3")
|
||||||
|
case "CM": return haystack.contains("colormunki")
|
||||||
|
case "SS": return haystack.contains("specbos") || haystack.contains("spectraval") || haystack.contains("spectroscan") || haystack.contains("spectro scan")
|
||||||
|
case "20": return haystack.contains("display 2")
|
||||||
|
case "22": return haystack.contains("display")
|
||||||
|
case "41": return haystack.contains("spyder 4") || haystack.contains("spyder 5") || haystack.contains("spyder4") || haystack.contains("spyder5")
|
||||||
|
case "51": return haystack.contains("spyder x")
|
||||||
|
default: return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reverse of `matches` — most specific codes first.
|
||||||
|
static func code(for device: InstrumentDevice) -> String? {
|
||||||
|
for code in ["p3", "51", "41", "22", "20", "CM", "SS", "i1"]
|
||||||
|
where matches(code: code, device: device) {
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `spotSetDefault` — writes `AppSettings.defaultInstrument` only.
|
||||||
|
/// Never touches `printtarg -i` or `targen`.
|
||||||
|
func applyDefaultToggle(_ on: Bool) {
|
||||||
|
setAsDefault = on
|
||||||
|
var settings = environment.settingsStore.load()
|
||||||
|
if on, case .device(let device) = selectedInstrument {
|
||||||
|
settings.defaultInstrument = Self.code(for: device)
|
||||||
|
} else if !on {
|
||||||
|
settings.defaultInstrument = nil
|
||||||
|
}
|
||||||
|
try? environment.settingsStore.save(settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Session control
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
guard sidecarAvailable else {
|
||||||
|
lastError = "spotread sidecar missing — run fetch-argyll"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard !isChartreadRunning else {
|
||||||
|
lastError = "Stop the Stage 3 chart read first."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let cwd = workflow.wizard.effectiveWorkingDirectory else {
|
||||||
|
lastError = "Set a working folder in Stage 1 first."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
// `spotread` is an exclusive lease — a second Start while a
|
||||||
|
// child is live is an error, not a kill + respawn (#116).
|
||||||
|
if await self.environment.runner.processManager.isRunning(ProcessID.spotread) {
|
||||||
|
self.lastError = "A spotread session is already running."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.begin(config: self.buildConfig(cwd: cwd))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildConfig(cwd: URL) -> SpotReadConfig {
|
||||||
|
let port: Int?
|
||||||
|
let name: String
|
||||||
|
switch selectedInstrument {
|
||||||
|
case .auto:
|
||||||
|
port = nil
|
||||||
|
name = "Auto"
|
||||||
|
case .device(let device):
|
||||||
|
port = device.port
|
||||||
|
name = device.name
|
||||||
|
}
|
||||||
|
return SpotReadConfig(
|
||||||
|
workingDirectory: cwd,
|
||||||
|
selectedPort: selectedInstrument.chartreadPort,
|
||||||
|
enableLEDs: environment.settingsStore.load().enableI1Pro2Leds,
|
||||||
|
isXY: selectedInstrument.isXY,
|
||||||
|
instrumentName: name,
|
||||||
|
instrumentPort: port
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func begin(config: SpotReadConfig) {
|
||||||
|
isRunning = true
|
||||||
|
state = .idle
|
||||||
|
lastError = nil
|
||||||
|
prompt = "Waiting for a reading…"
|
||||||
|
|
||||||
|
let stream = environment.runner.runSpotread(config: config)
|
||||||
|
streamTask = Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
for await event in stream {
|
||||||
|
self.handle(event: event)
|
||||||
|
}
|
||||||
|
self.isRunning = false
|
||||||
|
self.state = .idle
|
||||||
|
if self.lastError == nil {
|
||||||
|
self.prompt = "Press Start to open the instrument."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handle(event: SpotReadEvent) {
|
||||||
|
switch event {
|
||||||
|
case .prompt(let result):
|
||||||
|
state = result.state
|
||||||
|
prompt = promptText(for: result.state)
|
||||||
|
|
||||||
|
case .sample(let sample):
|
||||||
|
let previous = samples.first
|
||||||
|
samples.insert(sample, at: 0)
|
||||||
|
if samples.count > historyLimit {
|
||||||
|
samples.removeLast()
|
||||||
|
}
|
||||||
|
displayedSample = sample
|
||||||
|
displayedDeltaE = previous.map {
|
||||||
|
ColorDifference.deltaE00($0.lab, sample.lab)
|
||||||
|
}
|
||||||
|
|
||||||
|
case .log(let batch):
|
||||||
|
log.append(contentsOf: batch)
|
||||||
|
|
||||||
|
case .exit(let code):
|
||||||
|
if code != 0 {
|
||||||
|
lastError = "spotread exited with code \(code)"
|
||||||
|
}
|
||||||
|
|
||||||
|
case .failed(let error):
|
||||||
|
lastError = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func promptText(for state: ChartreadState) -> String {
|
||||||
|
switch state {
|
||||||
|
case .calibrating:
|
||||||
|
return "Place the instrument on the calibration tile, then Calibrate."
|
||||||
|
case .awaitingStrip:
|
||||||
|
return "Place on the patch, then Read."
|
||||||
|
case .reading, .promptContinue:
|
||||||
|
return "Waiting for a reading…"
|
||||||
|
case .warning:
|
||||||
|
return "Instrument warning — stop and restart if it persists."
|
||||||
|
case .error:
|
||||||
|
return "Read error — Stop, then Start again."
|
||||||
|
default:
|
||||||
|
return "Waiting for a reading…"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Transport
|
||||||
|
|
||||||
|
/// `btnSpotCalibrate` — same bytes Stage 3 sends for calibrate.
|
||||||
|
func calibrate() {
|
||||||
|
send(.trigger)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `btnSpotTrigger` — the Read key (`" \n"`).
|
||||||
|
func trigger() {
|
||||||
|
send(.trigger)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func send(_ input: ChartreadInput) {
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self, self.isRunning else { return }
|
||||||
|
try? await self.environment.runner.sendSpotreadInput(input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `btnSpotStop` / sheet dismiss: `q\n`, ~500 ms, then kill if the
|
||||||
|
/// child is still live.
|
||||||
|
func stopIfNeeded() {
|
||||||
|
guard isRunning else { return }
|
||||||
|
streamTask?.cancel()
|
||||||
|
streamTask = nil
|
||||||
|
let processManager = environment.runner.processManager
|
||||||
|
Task { @MainActor in
|
||||||
|
try? await processManager.sendStdin(
|
||||||
|
id: ProcessID.spotread, bytes: ChartreadInput.quit.bytes)
|
||||||
|
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||||
|
await processManager.kill(id: ProcessID.spotread)
|
||||||
|
}
|
||||||
|
isRunning = false
|
||||||
|
state = .idle
|
||||||
|
prompt = "Press Start to open the instrument."
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - History / export
|
||||||
|
|
||||||
|
/// Click a history row: copies that sample into the last-sample card.
|
||||||
|
/// Never re-triggers the instrument.
|
||||||
|
func selectFromHistory(_ sample: SpotReadSample) {
|
||||||
|
displayedSample = sample
|
||||||
|
if let index = samples.firstIndex(of: sample), index + 1 < samples.count {
|
||||||
|
displayedDeltaE = ColorDifference.deltaE00(samples[index + 1].lab, sample.lab)
|
||||||
|
} else {
|
||||||
|
displayedDeltaE = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `btnSpotCopyLab` — `L* a* b*` of the displayed sample as plain
|
||||||
|
/// text (`50.0 1.2 -3.4`).
|
||||||
|
func copyLab() {
|
||||||
|
guard let sample = displayedSample else { return }
|
||||||
|
let text = String(format: "%.1f %.1f %.1f", sample.lab.l, sample.lab.a, sample.lab.b)
|
||||||
|
NSPasteboard.general.clearContents()
|
||||||
|
NSPasteboard.general.setString(text, forType: .string)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `btnSpotExportCsv` — RFC-4180 via `selectCsvSavePath`. Cancel is
|
||||||
|
/// a no-op. Rows are newest-first, matching the history list.
|
||||||
|
func exportCsv() {
|
||||||
|
guard !samples.isEmpty else { return }
|
||||||
|
let url = UITestHooks.isEnabled
|
||||||
|
? UITestHooks.csvExportURL
|
||||||
|
: fileDialogs.selectCsvSavePath()
|
||||||
|
guard let url else { return }
|
||||||
|
|
||||||
|
var out = "timestamp,L,a,b,dE00,instrument,port\r\n"
|
||||||
|
for (index, sample) in samples.enumerated() {
|
||||||
|
let deltaE = index + 1 < samples.count
|
||||||
|
? String(format: "%.2f", ColorDifference.deltaE00(samples[index + 1].lab, sample.lab))
|
||||||
|
: ""
|
||||||
|
out += "\(csvField(iso8601(sample.timestamp))),\(f1(sample.lab.l)),\(f1(sample.lab.a)),\(f1(sample.lab.b)),\(deltaE),\(csvField(sample.instrumentName)),\(sample.port.map(String.init) ?? "")\r\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try out.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
workflow.wizard.showNotice("Spot readings exported: \(url.lastPathComponent)")
|
||||||
|
} catch {
|
||||||
|
workflow.wizard.showNotice(
|
||||||
|
"Export failed: \(error.localizedDescription)", kind: .error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func f1(_ value: Double) -> String {
|
||||||
|
String(format: "%.1f", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func iso8601(_ date: Date) -> String {
|
||||||
|
ISO8601DateFormatter().string(from: date)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func csvField(_ text: String) -> String {
|
||||||
|
guard text.contains(",") || text.contains("\"") || text.contains("\n") else {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
return "\"\(text.replacingOccurrences(of: "\"", with: "\"\""))\""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import ICCeryCore
|
|||||||
/// docs/08). All documented element ids are wired as accessibility
|
/// docs/08). All documented element ids are wired as accessibility
|
||||||
/// identifiers so the UI-test contract stays stable.
|
/// identifiers so the UI-test contract stays stable.
|
||||||
struct Stage1View: View {
|
struct Stage1View: View {
|
||||||
@Bindable var workflow: TargetWorkflowViewModel
|
@ObservedObject var workflow: TargetWorkflowViewModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
|
|||||||
@@ -5,7 +5,19 @@ import ICCeryCore
|
|||||||
/// issues #9/#10, docs/09). Print controls are visible but inert —
|
/// issues #9/#10, docs/09). Print controls are visible but inert —
|
||||||
/// real spooling lands in M3.
|
/// real spooling lands in M3.
|
||||||
struct Stage2View: View {
|
struct Stage2View: View {
|
||||||
@Bindable var workflow: TargetWorkflowViewModel
|
@ObservedObject var workflow: TargetWorkflowViewModel
|
||||||
|
/// Observed directly: nested ObservableObjects are not tracked
|
||||||
|
/// through the parent's `objectWillChange`.
|
||||||
|
@ObservedObject private var printSession: PrintSessionViewModel
|
||||||
|
@ObservedObject private var wizard: WizardViewModel
|
||||||
|
|
||||||
|
@State private var printGenerationTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
init(workflow: TargetWorkflowViewModel) {
|
||||||
|
self.workflow = workflow
|
||||||
|
self._printSession = ObservedObject(wrappedValue: workflow.print)
|
||||||
|
self._wizard = ObservedObject(wrappedValue: workflow.wizard)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
@@ -240,7 +252,7 @@ struct Stage2View: View {
|
|||||||
}
|
}
|
||||||
.frame(maxWidth: 320)
|
.frame(maxWidth: 320)
|
||||||
.accessibilityIdentifier("printerSelect")
|
.accessibilityIdentifier("printerSelect")
|
||||||
.onChange(of: workflow.print.selectedPrinter) { _, _ in
|
.onChange(of: workflow.print.selectedPrinter) { _ in
|
||||||
workflow.print.selectedTray = nil
|
workflow.print.selectedTray = nil
|
||||||
workflow.print.selectedMediaType = nil
|
workflow.print.selectedMediaType = nil
|
||||||
Task { @MainActor in await workflow.print.reloadSelectedCapabilities() }
|
Task { @MainActor in await workflow.print.reloadSelectedCapabilities() }
|
||||||
@@ -328,9 +340,18 @@ struct Stage2View: View {
|
|||||||
.clipShape(RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium))
|
.clipShape(RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium))
|
||||||
.accessibilityElement(children: .contain)
|
.accessibilityElement(children: .contain)
|
||||||
.accessibilityIdentifier("rawPrintPanel")
|
.accessibilityIdentifier("rawPrintPanel")
|
||||||
.task(id: workflow.printtargResult?.pages.count) {
|
.onAppear { schedulePrinterRefresh() }
|
||||||
// Auto-enumerate once a manifest exists and whenever it
|
.onChange(of: workflow.printtargResult?.pages.count) { _ in
|
||||||
// changes (e.g. resume from .ti2).
|
schedulePrinterRefresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Auto-enumerates printers once a manifest exists and whenever it
|
||||||
|
/// changes (e.g. resume from .ti2). The explicit task handle keeps
|
||||||
|
/// a superseded run from racing the next one.
|
||||||
|
private func schedulePrinterRefresh() {
|
||||||
|
printGenerationTask?.cancel()
|
||||||
|
printGenerationTask = Task { @MainActor in
|
||||||
if workflow.print.printers.isEmpty, workflow.printtargResult != nil {
|
if workflow.print.printers.isEmpty, workflow.printtargResult != nil {
|
||||||
workflow.print.refreshPrinters()
|
workflow.print.refreshPrinters()
|
||||||
}
|
}
|
||||||
@@ -341,7 +362,16 @@ struct Stage2View: View {
|
|||||||
/// One gallery cell: PNG preview + per-page Print button.
|
/// One gallery cell: PNG preview + per-page Print button.
|
||||||
private struct GalleryPageView: View {
|
private struct GalleryPageView: View {
|
||||||
let page: GalleryPage
|
let page: GalleryPage
|
||||||
let workflow: TargetWorkflowViewModel
|
@ObservedObject var workflow: TargetWorkflowViewModel
|
||||||
|
/// Observed directly: `print` is a nested ObservableObject and its
|
||||||
|
/// `isPrinting`/`selectedPrinter` changes drive this cell's button.
|
||||||
|
@ObservedObject private var printSession: PrintSessionViewModel
|
||||||
|
|
||||||
|
init(page: GalleryPage, workflow: TargetWorkflowViewModel) {
|
||||||
|
self.page = page
|
||||||
|
self.workflow = workflow
|
||||||
|
self._printSession = ObservedObject(wrappedValue: workflow.print)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 6) {
|
VStack(spacing: 6) {
|
||||||
|
|||||||
@@ -4,7 +4,15 @@ import ICCeryCore
|
|||||||
|
|
||||||
/// Stage 3 — measurement, live swatches, and multi-pass averaging.
|
/// Stage 3 — measurement, live swatches, and multi-pass averaging.
|
||||||
struct Stage3View: View {
|
struct Stage3View: View {
|
||||||
@Bindable var model: MeasurementWorkflowViewModel
|
@ObservedObject var model: MeasurementWorkflowViewModel
|
||||||
|
/// `model.basename`/`model.workingDirectory` delegate to `wizard`;
|
||||||
|
/// observe it directly so header updates propagate.
|
||||||
|
@ObservedObject private var wizard: WizardViewModel
|
||||||
|
|
||||||
|
init(model: MeasurementWorkflowViewModel) {
|
||||||
|
self.model = model
|
||||||
|
self._wizard = ObservedObject(wrappedValue: model.wizard)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
|||||||
@@ -3,7 +3,15 @@ import ICCeryCore
|
|||||||
|
|
||||||
/// Stage 4 — build an ICC/ICM profile from the canonical `.ti3`.
|
/// Stage 4 — build an ICC/ICM profile from the canonical `.ti3`.
|
||||||
struct Stage4View: View {
|
struct Stage4View: View {
|
||||||
@Bindable var model: ProfileWorkflowViewModel
|
@ObservedObject var model: ProfileWorkflowViewModel
|
||||||
|
/// Header reads `model.wizard.basename`; observe the nested
|
||||||
|
/// ObservableObject directly.
|
||||||
|
@ObservedObject private var wizard: WizardViewModel
|
||||||
|
|
||||||
|
init(model: ProfileWorkflowViewModel) {
|
||||||
|
self.model = model
|
||||||
|
self._wizard = ObservedObject(wrappedValue: model.wizard)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -130,16 +138,20 @@ struct Stage4View: View {
|
|||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
.accessibilityIdentifier("colprofCopyright")
|
.accessibilityIdentifier("colprofCopyright")
|
||||||
|
|
||||||
Toggle("Apply calibration curve", isOn: $model.applyCalibration)
|
// Nested VStack keeps the parent at the Swift 5.7 ViewBuilder
|
||||||
.accessibilityIdentifier("colprofApplyCalibration")
|
// 10-child limit (Xcode 14.2 / macOS 12 CI runner, #111).
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Toggle("Apply calibration curve", isOn: $model.applyCalibration)
|
||||||
|
.accessibilityIdentifier("colprofApplyCalibration")
|
||||||
|
|
||||||
if model.applyCalibration {
|
if model.applyCalibration {
|
||||||
HStack {
|
HStack {
|
||||||
TextField("Calibration .cal file", text: $model.calibrationFile)
|
TextField("Calibration .cal file", text: $model.calibrationFile)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
.accessibilityIdentifier("colprofCalibrationFile")
|
.accessibilityIdentifier("colprofCalibrationFile")
|
||||||
Button("Browse…") { model.browseForCalibrationFile() }
|
Button("Browse…") { model.browseForCalibrationFile() }
|
||||||
.accessibilityIdentifier("btnBrowseCalibrationFile")
|
.accessibilityIdentifier("btnBrowseCalibrationFile")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,15 @@ import ICCeryCore
|
|||||||
|
|
||||||
/// Stage 5 — verify the generated profile, track drift, and install.
|
/// Stage 5 — verify the generated profile, track drift, and install.
|
||||||
struct Stage5View: View {
|
struct Stage5View: View {
|
||||||
@Bindable var model: ProfileWorkflowViewModel
|
@ObservedObject var model: ProfileWorkflowViewModel
|
||||||
|
/// Header/buttons read `model.wizard.*`; observe the nested
|
||||||
|
/// ObservableObject directly.
|
||||||
|
@ObservedObject private var wizard: WizardViewModel
|
||||||
|
|
||||||
|
init(model: ProfileWorkflowViewModel) {
|
||||||
|
self.model = model
|
||||||
|
self._wizard = ObservedObject(wrappedValue: model.wizard)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
|
||||||
import ICCeryCore
|
import ICCeryCore
|
||||||
|
|
||||||
/// Stage 1/2 form state, runner orchestration, resume flow, and preset
|
/// Stage 1/2 form state, runner orchestration, resume flow, and preset
|
||||||
@@ -10,8 +10,7 @@ import ICCeryCore
|
|||||||
/// All process work runs through `ArgyllRunner` off `@MainActor`; only
|
/// All process work runs through `ArgyllRunner` off `@MainActor`; only
|
||||||
/// coalesced log batches and completion hop back.
|
/// coalesced log batches and completion hop back.
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
final class TargetWorkflowViewModel: ObservableObject {
|
||||||
final class TargetWorkflowViewModel {
|
|
||||||
|
|
||||||
let wizard: WizardViewModel
|
let wizard: WizardViewModel
|
||||||
let environment: AppEnvironment
|
let environment: AppEnvironment
|
||||||
@@ -19,95 +18,109 @@ final class TargetWorkflowViewModel {
|
|||||||
|
|
||||||
// MARK: - Stage 1 form (targen)
|
// MARK: - Stage 1 form (targen)
|
||||||
|
|
||||||
var colourSpace: ColourSpace = .rgb {
|
@Published var colourSpace: ColourSpace = .rgb {
|
||||||
didSet {
|
didSet {
|
||||||
guard colourSpace != oldValue else { return }
|
guard colourSpace != oldValue else { return }
|
||||||
// CMYK black patches default to 0, RGB to 4 (docs/08).
|
// CMYK black patches default to 0, RGB to 4 (docs/08).
|
||||||
blackPatches = colourSpace == .cmyk ? 0 : 4
|
blackPatches = colourSpace == .cmyk ? 0 : 4
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var patchPreset: PatchCountPreset = .standard800
|
@Published var patchPreset: PatchCountPreset = .standard800
|
||||||
/// `#patchCountCustom` — used when `patchPreset == .custom`.
|
/// `#patchCountCustom` — used when `patchPreset == .custom`.
|
||||||
var customPatchCount = 2500
|
@Published var customPatchCount = 2500
|
||||||
var whitePatches = 4
|
@Published var whitePatches = 4
|
||||||
var blackPatches = 4
|
@Published var blackPatches = 4
|
||||||
|
|
||||||
// Advanced — each optional flag is enabled + value, so an untouched
|
// Advanced — each optional flag is enabled + value, so an untouched
|
||||||
// control emits nothing (#advanced fields are opt-in).
|
// control emits nothing (#advanced fields are opt-in).
|
||||||
var greyStepsEnabled = false
|
@Published var greyStepsEnabled = false
|
||||||
var greySteps = 5
|
@Published var greySteps = 5
|
||||||
var singleChannelEnabled = false
|
@Published var singleChannelEnabled = false
|
||||||
var singleChannelSteps = 5
|
@Published var singleChannelSteps = 5
|
||||||
var neutralStepsEnabled = false
|
@Published var neutralStepsEnabled = false
|
||||||
var neutralSteps = 3
|
@Published var neutralSteps = 3
|
||||||
var neutralConcEnabled = false
|
@Published var neutralConcEnabled = false
|
||||||
var neutralConcentration = 0.50
|
@Published var neutralConcentration = 0.50
|
||||||
var preconditioningProfile: String?
|
@Published var preconditioningProfile: String?
|
||||||
var highQuality = false
|
@Published var highQuality = false
|
||||||
var adaptationEnabled = false
|
@Published var adaptationEnabled = false
|
||||||
var adaptation = 0.10
|
@Published var adaptation = 0.10
|
||||||
var algorithm: FullSpreadAlgorithm = .ofps
|
@Published var algorithm: FullSpreadAlgorithm = .ofps
|
||||||
var inkLimitEnabled = false
|
@Published var inkLimitEnabled = false
|
||||||
var totalInkLimit = 320
|
@Published var totalInkLimit = 320
|
||||||
var darkEmphasisEnabled = false
|
@Published var darkEmphasisEnabled = false
|
||||||
var darkEmphasis = 1.0
|
@Published var darkEmphasis = 1.0
|
||||||
var devicePowerEnabled = false
|
@Published var devicePowerEnabled = false
|
||||||
var devicePower = 1.0
|
@Published var devicePower = 1.0
|
||||||
|
|
||||||
/// `#targetBasename` — no placeholder is ever invented (#60).
|
/// `#targetBasename` — no placeholder is ever invented (#60).
|
||||||
var targetBasename = ""
|
@Published var targetBasename = ""
|
||||||
/// `#selectedPathDisplay` / resolved cwd.
|
/// `#selectedPathDisplay` / resolved cwd.
|
||||||
var targetDirectory: URL?
|
@Published var targetDirectory: URL?
|
||||||
|
|
||||||
// MARK: - Stage 2 form (printtarg)
|
// MARK: - Stage 2 form (printtarg)
|
||||||
|
|
||||||
var instrument: PrintInstrument = .i1
|
@Published var instrument: PrintInstrument = .i1
|
||||||
var pageSize: PageSize = .a4
|
@Published var pageSize: PageSize = .a4
|
||||||
var customPageW = 210.0
|
@Published var customPageW = 210.0
|
||||||
var customPageH = 297.0
|
@Published var customPageH = 297.0
|
||||||
var bitDepth: TiffBitDepth = .eight
|
@Published var bitDepth: TiffBitDepth = .eight
|
||||||
/// `#tiffDpi` — two-way bound; presets can change it (150-DPI draft
|
/// `#tiffDpi` — two-way bound; presets can change it (150-DPI draft
|
||||||
/// regression must be visible here).
|
/// regression must be visible here).
|
||||||
var tiffDpi = 300
|
@Published var tiffDpi = 300
|
||||||
var layoutOrder: LayoutOrder = .deterministic
|
@Published var layoutOrder: LayoutOrder = .deterministic
|
||||||
var customSeed = 1
|
@Published var customSeed = 1
|
||||||
var labelIsCustom = false
|
@Published var labelIsCustom = false
|
||||||
var customLabel = ""
|
@Published var customLabel = ""
|
||||||
var metaPrinter = ""
|
@Published var metaPrinter = ""
|
||||||
var metaInkSet = ""
|
@Published var metaInkSet = ""
|
||||||
var metaDriverPaper = ""
|
@Published var metaDriverPaper = ""
|
||||||
var metaActualPaper = ""
|
@Published var metaActualPaper = ""
|
||||||
|
|
||||||
// MARK: - Run state
|
// MARK: - Run state
|
||||||
|
|
||||||
var targenRunning = false
|
@Published var targenRunning = false
|
||||||
var targenLog: [String] = []
|
@Published var targenLog: [String] = []
|
||||||
var printtargRunning = false
|
@Published var printtargRunning = false
|
||||||
var printtargLog: [String] = []
|
@Published var printtargLog: [String] = []
|
||||||
var printtargResult: PrinttargResult?
|
@Published var printtargResult: PrinttargResult?
|
||||||
/// Sticky until the target changes: `.ti2` resume landed us on
|
/// Sticky until the target changes: `.ti2` resume landed us on
|
||||||
/// Stage 3 (`#stage3LoadedTargetBanner` data).
|
/// Stage 3 (`#stage3LoadedTargetBanner` data).
|
||||||
var resumedFromTi2 = false
|
@Published var resumedFromTi2 = false
|
||||||
|
|
||||||
// MARK: - Presets
|
// MARK: - Presets
|
||||||
|
|
||||||
var presets: [ProfilingPreset] = []
|
@Published var presets: [ProfilingPreset] = []
|
||||||
var selectedPresetID = "none"
|
@Published var selectedPresetID = "none"
|
||||||
var showingSavePreset = false
|
@Published var showingSavePreset = false
|
||||||
var showingManagePresets = false
|
@Published var showingManagePresets = false
|
||||||
var savePresetName = ""
|
@Published var savePresetName = ""
|
||||||
var savePresetDesc = ""
|
@Published var savePresetDesc = ""
|
||||||
|
|
||||||
|
// MARK: - Media library (issue #146)
|
||||||
|
|
||||||
|
@Published var showingSaveMedia = false
|
||||||
|
@Published var showingManageMedia = false
|
||||||
|
|
||||||
|
// MARK: - Spot read (issue #148)
|
||||||
|
|
||||||
|
/// `RootView` sheet binding for the spot-read console.
|
||||||
|
@Published var showingSpotRead = false
|
||||||
|
|
||||||
/// Stage 3 measurement workflow, owned at the app level so it persists
|
/// Stage 3 measurement workflow, owned at the app level so it persists
|
||||||
/// across stage switches and can observe settings changes.
|
/// across stage switches and can observe settings changes.
|
||||||
var measurement: MeasurementWorkflowViewModel
|
@Published var measurement: MeasurementWorkflowViewModel
|
||||||
/// Stage 4/5 profile workflow, owned at the app level so it persists
|
/// Stage 4/5 profile workflow, owned at the app level so it persists
|
||||||
/// across stage switches and can observe preset values.
|
/// across stage switches and can observe preset values.
|
||||||
var profile: ProfileWorkflowViewModel
|
@Published var profile: ProfileWorkflowViewModel
|
||||||
/// Stage 0 calibration workflow.
|
/// Stage 0 calibration workflow.
|
||||||
var calibration: CalibrationViewModel!
|
@Published var calibration: CalibrationViewModel!
|
||||||
/// Stage 2 unmanaged print session.
|
/// Stage 2 unmanaged print session.
|
||||||
var print: PrintSessionViewModel!
|
@Published var print: PrintSessionViewModel!
|
||||||
|
/// Media recipe library — needs a complete `self`.
|
||||||
|
@Published var media: MediaLibraryViewModel!
|
||||||
|
/// Spot-read console, created last — needs `wizard` / `measurement`.
|
||||||
|
@Published var spotRead: SpotReadViewModel!
|
||||||
|
|
||||||
init(environment: AppEnvironment = .live()) {
|
init(environment: AppEnvironment = .live()) {
|
||||||
self.environment = environment
|
self.environment = environment
|
||||||
@@ -127,6 +140,14 @@ final class TargetWorkflowViewModel {
|
|||||||
profile: self.profile,
|
profile: self.profile,
|
||||||
environment: environment
|
environment: environment
|
||||||
)
|
)
|
||||||
|
self.media = MediaLibraryViewModel(
|
||||||
|
workflow: self,
|
||||||
|
environment: environment
|
||||||
|
)
|
||||||
|
self.spotRead = SpotReadViewModel(
|
||||||
|
workflow: self,
|
||||||
|
environment: environment
|
||||||
|
)
|
||||||
reloadPresets()
|
reloadPresets()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
|
||||||
import ICCeryCore
|
import ICCeryCore
|
||||||
|
|
||||||
/// Wizard state machine + artefact gating (issue #4, docs/06).
|
/// Wizard state machine + artefact gating (issue #4, docs/06).
|
||||||
@@ -10,47 +10,46 @@ import ICCeryCore
|
|||||||
/// `wizard_state.json`; unlocks come from `ArtefactProbe.verify` —
|
/// `wizard_state.json`; unlocks come from `ArtefactProbe.verify` —
|
||||||
/// navigation is disk, not buttons.
|
/// navigation is disk, not buttons.
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
final class WizardViewModel: ObservableObject {
|
||||||
final class WizardViewModel {
|
|
||||||
|
|
||||||
// MARK: - wizardState fields (persisted)
|
// MARK: - wizardState fields (persisted)
|
||||||
|
|
||||||
var stage: WizardStage {
|
@Published var stage: WizardStage {
|
||||||
didSet { if stage != oldValue { persist() } }
|
didSet { if stage != oldValue { persist() } }
|
||||||
}
|
}
|
||||||
/// `wizardState.basename` — empty until a real artefact names it (#60).
|
/// `wizardState.basename` — empty until a real artefact names it (#60).
|
||||||
var basename: String {
|
@Published var basename: String {
|
||||||
didSet { if basename != oldValue { refreshGating(); persist() } }
|
didSet { if basename != oldValue { refreshGating(); persist() } }
|
||||||
}
|
}
|
||||||
/// `wizardState.cwd` — resolved via `resolveSafeCwd` (#59).
|
/// `wizardState.cwd` — resolved via `resolveSafeCwd` (#59).
|
||||||
var workingDirectory: URL? {
|
@Published var workingDirectory: URL? {
|
||||||
didSet { if workingDirectory != oldValue { refreshGating(); persist() } }
|
didSet { if workingDirectory != oldValue { refreshGating(); persist() } }
|
||||||
}
|
}
|
||||||
var printerName: String? {
|
@Published var printerName: String? {
|
||||||
didSet { if printerName != oldValue { persist() } }
|
didSet { if printerName != oldValue { persist() } }
|
||||||
}
|
}
|
||||||
var sessionMode: SessionMode {
|
@Published var sessionMode: SessionMode {
|
||||||
didSet { if sessionMode != oldValue { persist() } }
|
didSet { if sessionMode != oldValue { persist() } }
|
||||||
}
|
}
|
||||||
/// `profileBasename` may differ after a `.ti3` import (#94).
|
/// `profileBasename` may differ after a `.ti3` import (#94).
|
||||||
var profileBasename: String? {
|
@Published var profileBasename: String? {
|
||||||
didSet { if profileBasename != oldValue { persist() } }
|
didSet { if profileBasename != oldValue { persist() } }
|
||||||
}
|
}
|
||||||
/// Pre-`CAL_` basename, persisted so relaunch/Force Quit can restore it (#29).
|
/// Pre-`CAL_` basename, persisted so relaunch/Force Quit can restore it (#29).
|
||||||
var calibrationOriginalBasename: String {
|
@Published var calibrationOriginalBasename: String {
|
||||||
didSet { if calibrationOriginalBasename != oldValue { persist() } }
|
didSet { if calibrationOriginalBasename != oldValue { persist() } }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Ephemeral
|
// MARK: - Ephemeral
|
||||||
|
|
||||||
/// Banner notice currently displayed (`#wizardNotification`).
|
/// Banner notice currently displayed (`#wizardNotification`).
|
||||||
var notice: Notice?
|
@Published var notice: Notice?
|
||||||
/// Current artefact probe result; recomputed on `refreshGating()`.
|
/// Current artefact probe result; recomputed on `refreshGating()`.
|
||||||
private(set) var artefacts = StageArtefacts()
|
@Published private(set) var artefacts = StageArtefacts()
|
||||||
/// Whether the 3D gamut viewer sheet is open (issue #28).
|
/// Whether the 3D gamut viewer sheet is open (issue #28).
|
||||||
var showingGamutViewer = false
|
@Published var showingGamutViewer = false
|
||||||
/// Optional `.gam` URL to show alongside the sRGB reference.
|
/// Optional `.gam` URL to show alongside the sRGB reference.
|
||||||
var gamutProfileURL: URL?
|
@Published var gamutProfileURL: URL?
|
||||||
|
|
||||||
private let stateStore: WizardStateStore
|
private let stateStore: WizardStateStore
|
||||||
private var noticeDismissTask: Task<Void, Never>?
|
private var noticeDismissTask: Task<Void, Never>?
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import XCTest
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
@Suite("ArgyllRunner Calibration")
|
final class ArgyllRunnerCalibrationTests: XCTestCase {
|
||||||
struct ArgyllRunnerCalibrationTests {
|
|
||||||
|
|
||||||
private func makeRunner(processManager: ProcessManager = ProcessManager()) -> ArgyllRunner {
|
private func makeRunner(processManager: ProcessManager = ProcessManager()) -> ArgyllRunner {
|
||||||
let binDir = URL(fileURLWithPath: #filePath)
|
let binDir = URL(fileURLWithPath: #filePath)
|
||||||
@@ -23,8 +22,7 @@ struct ArgyllRunnerCalibrationTests {
|
|||||||
return root
|
return root
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Calibration targen produces CAL_*.ti1")
|
func testCalibrationTargenProducesTi1() async throws {
|
||||||
func calibrationTargenProducesTi1() async throws {
|
|
||||||
let testRoot = try makeTestDir()
|
let testRoot = try makeTestDir()
|
||||||
let runner = makeRunner()
|
let runner = makeRunner()
|
||||||
let config = CalibrationTargenConfig(
|
let config = CalibrationTargenConfig(
|
||||||
@@ -36,13 +34,12 @@ struct ArgyllRunnerCalibrationTests {
|
|||||||
|
|
||||||
let url = try await runner.runCalibrationTargen(config: config)
|
let url = try await runner.runCalibrationTargen(config: config)
|
||||||
|
|
||||||
#expect(url.lastPathComponent == "CAL_demo.ti1")
|
XCTAssertEqual(url.lastPathComponent, "CAL_demo.ti1")
|
||||||
#expect(FileManager.default.fileExists(atPath: url.path))
|
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
|
||||||
try? FileManager.default.removeItem(at: testRoot)
|
try? FileManager.default.removeItem(at: testRoot)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Calibration targen from foo runs as process id targen_CAL_foo")
|
func testCalibrationTargenProcessId() async throws {
|
||||||
func calibrationTargenProcessId() async throws {
|
|
||||||
let testRoot = try makeTestDir()
|
let testRoot = try makeTestDir()
|
||||||
let pm = ProcessManager()
|
let pm = ProcessManager()
|
||||||
let runner = makeRunner(processManager: pm)
|
let runner = makeRunner(processManager: pm)
|
||||||
@@ -65,13 +62,13 @@ struct ArgyllRunnerCalibrationTests {
|
|||||||
|
|
||||||
let url = try await runner.runCalibrationTargen(config: config)
|
let url = try await runner.runCalibrationTargen(config: config)
|
||||||
|
|
||||||
#expect(url.lastPathComponent == "CAL_foo.ti1")
|
XCTAssertEqual(url.lastPathComponent, "CAL_foo.ti1")
|
||||||
#expect(await sawExit.value)
|
let sawExitEvent = await sawExit.value
|
||||||
|
XCTAssertTrue(sawExitEvent)
|
||||||
try? FileManager.default.removeItem(at: testRoot)
|
try? FileManager.default.removeItem(at: testRoot)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("printcal captured run creates .cal")
|
func testPrintcalProducesCal() async throws {
|
||||||
func printcalProducesCal() async throws {
|
|
||||||
let testRoot = try makeTestDir()
|
let testRoot = try makeTestDir()
|
||||||
let runner = makeRunner()
|
let runner = makeRunner()
|
||||||
let output = testRoot.appendingPathComponent("CAL_demo.cal")
|
let output = testRoot.appendingPathComponent("CAL_demo.cal")
|
||||||
@@ -83,13 +80,12 @@ struct ArgyllRunnerCalibrationTests {
|
|||||||
|
|
||||||
let url = try await runner.runPrintcal(config: config)
|
let url = try await runner.runPrintcal(config: config)
|
||||||
|
|
||||||
#expect(url.lastPathComponent == "CAL_demo.cal")
|
XCTAssertEqual(url.lastPathComponent, "CAL_demo.cal")
|
||||||
#expect(FileManager.default.fileExists(atPath: url.path))
|
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
|
||||||
try? FileManager.default.removeItem(at: testRoot)
|
try? FileManager.default.removeItem(at: testRoot)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("printcal failure throws toolFailed")
|
func testPrintcalFailureThrows() async throws {
|
||||||
func printcalFailureThrows() async throws {
|
|
||||||
let testRoot = try makeTestDir()
|
let testRoot = try makeTestDir()
|
||||||
defer { try? FileManager.default.removeItem(at: testRoot) }
|
defer { try? FileManager.default.removeItem(at: testRoot) }
|
||||||
|
|
||||||
@@ -117,9 +113,11 @@ struct ArgyllRunnerCalibrationTests {
|
|||||||
outputURL: output
|
outputURL: output
|
||||||
)
|
)
|
||||||
|
|
||||||
await #expect(throws: ArgyllRunnerError.toolFailed(
|
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
|
||||||
tool: "printcal", code: 1, logs: ["printcal mock failure\n"])) {
|
|
||||||
_ = try await runner.runPrintcal(config: config)
|
_ = try await runner.runPrintcal(config: config)
|
||||||
|
} errorHandler: { error in
|
||||||
|
XCTAssertEqual(error, .toolFailed(
|
||||||
|
tool: "printcal", code: 1, logs: ["printcal mock failure\n"]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import XCTest
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
final class LogHolder: @unchecked Sendable {
|
final class LogHolder: @unchecked Sendable {
|
||||||
@@ -19,11 +19,9 @@ final class LogHolder: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suite("ArgyllRunner colprof")
|
final class ArgyllRunnerColprofTests: XCTestCase {
|
||||||
struct ArgyllRunnerColprofTests {
|
|
||||||
|
|
||||||
@Test("Mock colprof produces .icc")
|
func testColprofProducesIcc() async throws {
|
||||||
func colprofProducesIcc() async throws {
|
|
||||||
let binDir = URL(fileURLWithPath: #filePath)
|
let binDir = URL(fileURLWithPath: #filePath)
|
||||||
.deletingLastPathComponent()
|
.deletingLastPathComponent()
|
||||||
.deletingLastPathComponent()
|
.deletingLastPathComponent()
|
||||||
@@ -43,15 +41,14 @@ struct ArgyllRunnerColprofTests {
|
|||||||
holder.append(batch)
|
holder.append(batch)
|
||||||
}
|
}
|
||||||
|
|
||||||
#expect(url.lastPathComponent == "testrun.icc")
|
XCTAssertEqual(url.lastPathComponent, "testrun.icc")
|
||||||
#expect(FileManager.default.fileExists(atPath: url.path))
|
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
|
||||||
#expect(holder.lines.contains { $0.contains("Gamut mapping") })
|
XCTAssertTrue(holder.lines.contains { $0.contains("Gamut mapping") })
|
||||||
|
|
||||||
try? FileManager.default.removeItem(at: testRoot)
|
try? FileManager.default.removeItem(at: testRoot)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Failing colprof throws toolFailed with code and logs")
|
func testColprofFailureThrowsToolFailed() async throws {
|
||||||
func colprofFailureThrowsToolFailed() async throws {
|
|
||||||
let dir = FileManager.default.temporaryDirectory
|
let dir = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("colprof-fail-\(UUID().uuidString)")
|
.appendingPathComponent("colprof-fail-\(UUID().uuidString)")
|
||||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
@@ -72,9 +69,11 @@ struct ArgyllRunnerColprofTests {
|
|||||||
)
|
)
|
||||||
let config = ColprofConfig(basename: "failrun", workingDirectory: dir)
|
let config = ColprofConfig(basename: "failrun", workingDirectory: dir)
|
||||||
|
|
||||||
await #expect(throws: ArgyllRunnerError.toolFailed(
|
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
|
||||||
tool: "colprof", code: 4, logs: ["colprof broke"])) {
|
|
||||||
try await runner.runColprof(config: config)
|
try await runner.runColprof(config: config)
|
||||||
|
} errorHandler: { error in
|
||||||
|
XCTAssertEqual(error, .toolFailed(
|
||||||
|
tool: "colprof", code: 4, logs: ["colprof broke"]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import XCTest
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
/// Focused contracts for the shared `runStreamingTool` loop (#79).
|
/// Focused contracts for the shared `runStreamingTool` loop (#79).
|
||||||
@@ -7,8 +7,7 @@ import Testing
|
|||||||
/// Every test uses a per-test temporary directory, unique basenames,
|
/// Every test uses a per-test temporary directory, unique basenames,
|
||||||
/// and a fresh `ProcessManager` — no shared UI fixture scripts and no
|
/// and a fresh `ProcessManager` — no shared UI fixture scripts and no
|
||||||
/// process-environment mutation.
|
/// process-environment mutation.
|
||||||
@Suite("ArgyllRunner streaming loop contracts")
|
final class ArgyllRunnerStreamingLoopTests: XCTestCase {
|
||||||
struct ArgyllRunnerStreamingLoopTests {
|
|
||||||
|
|
||||||
private func makeTempDir() throws -> URL {
|
private func makeTempDir() throws -> URL {
|
||||||
let dir = FileManager.default.temporaryDirectory
|
let dir = FileManager.default.temporaryDirectory
|
||||||
@@ -30,8 +29,7 @@ struct ArgyllRunnerStreamingLoopTests {
|
|||||||
binaryResolver: BinaryResolver(bundledRoot: binDir, overrideDir: binDir))
|
binaryResolver: BinaryResolver(bundledRoot: binDir, overrideDir: binDir))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Non-zero exit throws toolFailed retaining code and collected stdout/stderr lines")
|
func testNonZeroExitThrowsToolFailed() async throws {
|
||||||
func nonZeroExitThrowsToolFailed() async throws {
|
|
||||||
let dir = try makeTempDir()
|
let dir = try makeTempDir()
|
||||||
defer { try? FileManager.default.removeItem(at: dir) }
|
defer { try? FileManager.default.removeItem(at: dir) }
|
||||||
try writeMock("targen", """
|
try writeMock("targen", """
|
||||||
@@ -47,21 +45,20 @@ struct ArgyllRunnerStreamingLoopTests {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
_ = try await runner.runTargen(config: config)
|
_ = try await runner.runTargen(config: config)
|
||||||
Issue.record("Expected toolFailed")
|
XCTFail("Expected toolFailed")
|
||||||
} catch let error as ArgyllRunnerError {
|
} catch let error as ArgyllRunnerError {
|
||||||
guard case .toolFailed(let tool, let code, let logs) = error else {
|
guard case .toolFailed(let tool, let code, let logs) = error else {
|
||||||
Issue.record("Expected toolFailed, got \(error)")
|
XCTFail("Expected toolFailed, got \(error)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
#expect(tool == "targen")
|
XCTAssertEqual(tool, "targen")
|
||||||
#expect(code == 3)
|
XCTAssertEqual(code, 3)
|
||||||
#expect(logs.contains("Generating patches..."))
|
XCTAssertTrue(logs.contains("Generating patches..."))
|
||||||
#expect(logs.contains("targen: too few patches"))
|
XCTAssertTrue(logs.contains("targen: too few patches"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Exit 0 without expected artefact throws missingArtefact with the artefact path")
|
func testZeroExitMissingArtefact() async throws {
|
||||||
func zeroExitMissingArtefact() async throws {
|
|
||||||
let dir = try makeTempDir()
|
let dir = try makeTempDir()
|
||||||
defer { try? FileManager.default.removeItem(at: dir) }
|
defer { try? FileManager.default.removeItem(at: dir) }
|
||||||
try writeMock("targen", """
|
try writeMock("targen", """
|
||||||
@@ -75,13 +72,14 @@ struct ArgyllRunnerStreamingLoopTests {
|
|||||||
colourSpace: .rgb, patchCount: 800, whitePatches: 4,
|
colourSpace: .rgb, patchCount: 800, whitePatches: 4,
|
||||||
blackPatches: 4, basename: "gone", workingDirectory: dir)
|
blackPatches: 4, basename: "gone", workingDirectory: dir)
|
||||||
|
|
||||||
await #expect(throws: ArgyllRunnerError.missingArtefact(expectedPath)) {
|
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
|
||||||
try await runner.runTargen(config: config)
|
try await runner.runTargen(config: config)
|
||||||
|
} errorHandler: { error in
|
||||||
|
XCTAssertEqual(error, .missingArtefact(expectedPath))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Immediate exit after one stdout line still delivers the line and succeeds")
|
func testImmediateExitDeliversLine() async throws {
|
||||||
func immediateExitDeliversLine() async throws {
|
|
||||||
let dir = try makeTempDir()
|
let dir = try makeTempDir()
|
||||||
defer { try? FileManager.default.removeItem(at: dir) }
|
defer { try? FileManager.default.removeItem(at: dir) }
|
||||||
try writeMock("targen", """
|
try writeMock("targen", """
|
||||||
@@ -101,13 +99,12 @@ struct ArgyllRunnerStreamingLoopTests {
|
|||||||
let url = try await runner.runTargen(config: config) { batch in
|
let url = try await runner.runTargen(config: config) { batch in
|
||||||
holder.append(batch)
|
holder.append(batch)
|
||||||
}
|
}
|
||||||
#expect(url.lastPathComponent == "quick.ti1")
|
XCTAssertEqual(url.lastPathComponent, "quick.ti1")
|
||||||
#expect(FileManager.default.fileExists(atPath: url.path))
|
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
|
||||||
#expect(holder.lines.contains("only line"))
|
XCTAssertTrue(holder.lines.contains("only line"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("colprof unterminated progress fragment reaches onLogBatch before exit")
|
func testColprofPartialLineFlush() async throws {
|
||||||
func colprofPartialLineFlush() async throws {
|
|
||||||
let dir = try makeTempDir()
|
let dir = try makeTempDir()
|
||||||
defer { try? FileManager.default.removeItem(at: dir) }
|
defer { try? FileManager.default.removeItem(at: dir) }
|
||||||
// The fragment is printed without a newline, then the mock sleeps
|
// The fragment is printed without a newline, then the mock sleeps
|
||||||
@@ -129,13 +126,13 @@ struct ArgyllRunnerStreamingLoopTests {
|
|||||||
let url = try await runner.runColprof(config: config) { batch in
|
let url = try await runner.runColprof(config: config) { batch in
|
||||||
holder.append(batch)
|
holder.append(batch)
|
||||||
}
|
}
|
||||||
#expect(url.lastPathComponent == "frag.icc")
|
XCTAssertEqual(url.lastPathComponent, "frag.icc")
|
||||||
#expect(FileManager.default.fileExists(atPath: url.path))
|
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
|
||||||
#expect(holder.lines.contains("Doing gamut mapping"))
|
XCTAssertTrue(holder.lines.contains("Doing gamut mapping"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("toolFailed maps each tool to its user-facing description",
|
func testToolDescriptions() {
|
||||||
arguments: [
|
let cases: [(tool: String, expected: String)] = [
|
||||||
(tool: "chartread", expected: "Chartread failed: boom"),
|
(tool: "chartread", expected: "Chartread failed: boom"),
|
||||||
(tool: "average", expected: "Averaging failed: boom"),
|
(tool: "average", expected: "Averaging failed: boom"),
|
||||||
(tool: "colprof", expected: "Profile creation failed: boom"),
|
(tool: "colprof", expected: "Profile creation failed: boom"),
|
||||||
@@ -143,19 +140,19 @@ struct ArgyllRunnerStreamingLoopTests {
|
|||||||
(tool: "applycal", expected: "Apply calibration failed: boom"),
|
(tool: "applycal", expected: "Apply calibration failed: boom"),
|
||||||
(tool: "iccgamut", expected: "Gamut extraction failed: boom"),
|
(tool: "iccgamut", expected: "Gamut extraction failed: boom"),
|
||||||
(tool: "profcheck", expected: "Profile verification failed: boom"),
|
(tool: "profcheck", expected: "Profile verification failed: boom"),
|
||||||
])
|
]
|
||||||
func toolDescriptions(tool: String, expected: String) {
|
for (tool, expected) in cases {
|
||||||
let error = ArgyllRunnerError.toolFailed(tool: tool, code: 1, logs: ["boom"])
|
let error = ArgyllRunnerError.toolFailed(tool: tool, code: 1, logs: ["boom"])
|
||||||
#expect(error.errorDescription == expected)
|
XCTAssertEqual(error.errorDescription, expected)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("toolFailed falls back to a generic description for unmapped tools and empty logs")
|
func testGenericFallbacks() {
|
||||||
func genericFallbacks() {
|
|
||||||
let unknown = ArgyllRunnerError.toolFailed(tool: "targen", code: 7, logs: ["boom"])
|
let unknown = ArgyllRunnerError.toolFailed(tool: "targen", code: 7, logs: ["boom"])
|
||||||
#expect(unknown.errorDescription == "Process exited with code 7")
|
XCTAssertEqual(unknown.errorDescription, "Process exited with code 7")
|
||||||
|
|
||||||
let emptyLogs = ArgyllRunnerError.toolFailed(tool: "colprof", code: 2, logs: [])
|
let emptyLogs = ArgyllRunnerError.toolFailed(tool: "colprof", code: 2, logs: [])
|
||||||
#expect(emptyLogs.errorDescription
|
XCTAssertEqual(emptyLogs.errorDescription,
|
||||||
== "Profile creation failed: exited with code 2")
|
"Profile creation failed: exited with code 2")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import XCTest
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
@Suite("CalibrationStore")
|
final class CalibrationStoreTests: XCTestCase {
|
||||||
struct CalibrationStoreTests {
|
|
||||||
|
|
||||||
private static let sampleCal = """
|
private static let sampleCal = """
|
||||||
CTI3
|
CTI3
|
||||||
@@ -23,8 +22,7 @@ struct CalibrationStoreTests {
|
|||||||
END_DATA
|
END_DATA
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@Test("Loads metadata and curves from .cal")
|
func testParseCal() async throws {
|
||||||
func parseCal() async throws {
|
|
||||||
let url = FileManager.default.temporaryDirectory
|
let url = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("test_\(UUID().uuidString).cal")
|
.appendingPathComponent("test_\(UUID().uuidString).cal")
|
||||||
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
|
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
|
||||||
@@ -33,17 +31,16 @@ struct CalibrationStoreTests {
|
|||||||
try await store.load(url: url)
|
try await store.load(url: url)
|
||||||
|
|
||||||
let data = await store.data
|
let data = await store.data
|
||||||
#expect(data?.colorRep == "RGB")
|
XCTAssertEqual(data?.colorRep, "RGB")
|
||||||
#expect(data?.descriptor == "Test printer")
|
XCTAssertEqual(data?.descriptor, "Test printer")
|
||||||
#expect(data?.maxTac == 300)
|
XCTAssertEqual(data?.maxTac, 300)
|
||||||
#expect(data?.curves.count == 3)
|
XCTAssertEqual(data?.curves.count, 3)
|
||||||
|
|
||||||
let r = data?.curves.first { $0.channel == "R" }
|
let r = data?.curves.first { $0.channel == "R" }
|
||||||
#expect(r?.output == [0, 64, 255])
|
XCTAssertEqual(r?.output, [0, 64, 255])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Staleness is true for a very old calibration")
|
func testStaleCalibration() async throws {
|
||||||
func staleCalibration() async throws {
|
|
||||||
let url = FileManager.default.temporaryDirectory
|
let url = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("stale_\(UUID().uuidString).cal")
|
.appendingPathComponent("stale_\(UUID().uuidString).cal")
|
||||||
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
|
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
|
||||||
@@ -51,11 +48,10 @@ struct CalibrationStoreTests {
|
|||||||
let store = CalibrationStore(staleDays: 0)
|
let store = CalibrationStore(staleDays: 0)
|
||||||
try await store.load(url: url)
|
try await store.load(url: url)
|
||||||
let stale = await store.isStale(comparedTo: "Other")
|
let stale = await store.isStale(comparedTo: "Other")
|
||||||
#expect(stale == true)
|
XCTAssertEqual(stale, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Printer mismatch is flagged as stale")
|
func testPrinterMismatch() async throws {
|
||||||
func printerMismatch() async throws {
|
|
||||||
let url = FileManager.default.temporaryDirectory
|
let url = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("mismatch_\(UUID().uuidString).cal")
|
.appendingPathComponent("mismatch_\(UUID().uuidString).cal")
|
||||||
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
|
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
|
||||||
@@ -64,6 +60,6 @@ struct CalibrationStoreTests {
|
|||||||
try await store.load(url: url)
|
try await store.load(url: url)
|
||||||
await store.setPrinterName("Printer A")
|
await store.setPrinterName("Printer A")
|
||||||
let stale = await store.isStale(comparedTo: "Printer B")
|
let stale = await store.isStale(comparedTo: "Printer B")
|
||||||
#expect(stale == true)
|
XCTAssertEqual(stale, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,10 +71,14 @@ final class ColorSyncSuppressorTests: XCTestCase {
|
|||||||
s.modeResolver = { name in
|
s.modeResolver = { name in
|
||||||
if Self.missing.contains(name) { return nil }
|
if Self.missing.contains(name) { return nil }
|
||||||
Self.currentSymbol = name
|
Self.currentSymbol = name
|
||||||
|
// `Self` inside a @convention(c) closure is a dynamic-Self
|
||||||
|
// capture — spell the (final) class name instead.
|
||||||
return { _, modeArg in
|
return { _, modeArg in
|
||||||
Self.recorded.append((Self.currentSymbol, modeArg as String))
|
ColorSyncSuppressorTests.recorded.append(
|
||||||
if let ok = Self.succeeding,
|
(ColorSyncSuppressorTests.currentSymbol, modeArg as String))
|
||||||
Self.currentSymbol == ok.0, (modeArg as String) == ok.1 {
|
if let ok = ColorSyncSuppressorTests.succeeding,
|
||||||
|
ColorSyncSuppressorTests.currentSymbol == ok.0,
|
||||||
|
(modeArg as String) == ok.1 {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
@@ -1,26 +1,23 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import XCTest
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
@Suite("JSONFileStore")
|
final class JSONFileStoreTests: XCTestCase {
|
||||||
struct JSONFileStoreTests {
|
|
||||||
private func tempURL() -> URL {
|
private func tempURL() -> URL {
|
||||||
FileManager.default.temporaryDirectory
|
FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("json-store-\(UUID().uuidString).json")
|
.appendingPathComponent("json-store-\(UUID().uuidString).json")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Missing file returns default")
|
func testMissingFileDefaults() throws {
|
||||||
func missingFileDefaults() throws {
|
|
||||||
let store = JSONFileStore<AppSettings>(
|
let store = JSONFileStore<AppSettings>(
|
||||||
fileURL: tempURL(),
|
fileURL: tempURL(),
|
||||||
corrupt: .throwCorrupt,
|
corrupt: .throwCorrupt,
|
||||||
defaultValue: { .default }
|
defaultValue: { .default }
|
||||||
)
|
)
|
||||||
#expect(try store.load() == .default)
|
XCTAssertEqual(try store.load(), .default)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Corrupt file with replaceWithDefault returns default and leaves bytes")
|
func testCorruptDefaults() throws {
|
||||||
func corruptDefaults() throws {
|
|
||||||
let url = tempURL()
|
let url = tempURL()
|
||||||
try "{ not json".write(to: url, atomically: true, encoding: .utf8)
|
try "{ not json".write(to: url, atomically: true, encoding: .utf8)
|
||||||
let store = JSONFileStore<AppSettings>(
|
let store = JSONFileStore<AppSettings>(
|
||||||
@@ -28,13 +25,12 @@ struct JSONFileStoreTests {
|
|||||||
corrupt: .replaceWithDefault,
|
corrupt: .replaceWithDefault,
|
||||||
defaultValue: { .default }
|
defaultValue: { .default }
|
||||||
)
|
)
|
||||||
#expect(try store.load() == .default)
|
XCTAssertEqual(try store.load(), .default)
|
||||||
let kept = try String(contentsOf: url, encoding: .utf8)
|
let kept = try String(contentsOf: url, encoding: .utf8)
|
||||||
#expect(kept == "{ not json")
|
XCTAssertEqual(kept, "{ not json")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Corrupt file with throwCorrupt throws and leaves bytes")
|
func testCorruptThrows() throws {
|
||||||
func corruptThrows() throws {
|
|
||||||
let url = tempURL()
|
let url = tempURL()
|
||||||
try "not json".write(to: url, atomically: true, encoding: .utf8)
|
try "not json".write(to: url, atomically: true, encoding: .utf8)
|
||||||
let store = JSONFileStore<[Int]>(
|
let store = JSONFileStore<[Int]>(
|
||||||
@@ -42,15 +38,12 @@ struct JSONFileStoreTests {
|
|||||||
corrupt: .throwCorrupt,
|
corrupt: .throwCorrupt,
|
||||||
defaultValue: { [] }
|
defaultValue: { [] }
|
||||||
)
|
)
|
||||||
#expect(throws: DecodingError.self) {
|
XCTAssertThrowsError(try store.load()) { error in XCTAssertTrue(error is DecodingError) }
|
||||||
_ = try store.load()
|
|
||||||
}
|
|
||||||
let kept = try String(contentsOf: url, encoding: .utf8)
|
let kept = try String(contentsOf: url, encoding: .utf8)
|
||||||
#expect(kept == "not json")
|
XCTAssertEqual(kept, "not json")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Pretty sorted keys")
|
func testPrettySorted() throws {
|
||||||
func prettySorted() throws {
|
|
||||||
let url = tempURL()
|
let url = tempURL()
|
||||||
let store = JSONFileStore<AppSettings>(
|
let store = JSONFileStore<AppSettings>(
|
||||||
fileURL: url,
|
fileURL: url,
|
||||||
@@ -59,8 +52,8 @@ struct JSONFileStoreTests {
|
|||||||
)
|
)
|
||||||
try store.save(.default)
|
try store.save(.default)
|
||||||
let text = try String(contentsOf: url, encoding: .utf8)
|
let text = try String(contentsOf: url, encoding: .utf8)
|
||||||
#expect(text.contains("\n"))
|
XCTAssertTrue(text.contains("\n"))
|
||||||
#expect(text.contains("\"delta_e_good_max\""))
|
XCTAssertTrue(text.contains("\"delta_e_good_max\""))
|
||||||
// Lexical key sorting: ascending order of top-level keys.
|
// Lexical key sorting: ascending order of top-level keys.
|
||||||
let keys = [
|
let keys = [
|
||||||
"ask_before_overwrite_profile",
|
"ask_before_overwrite_profile",
|
||||||
@@ -75,7 +68,7 @@ struct JSONFileStoreTests {
|
|||||||
var lastIndex = text.startIndex
|
var lastIndex = text.startIndex
|
||||||
for key in keys {
|
for key in keys {
|
||||||
guard let range = text.range(of: "\"\(key)\"", range: lastIndex..<text.endIndex) else {
|
guard let range = text.range(of: "\"\(key)\"", range: lastIndex..<text.endIndex) else {
|
||||||
Issue.record("missing or out-of-order key \(key)")
|
XCTFail("missing or out-of-order key \(key)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
lastIndex = range.upperBound
|
lastIndex = range.upperBound
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import Foundation
|
||||||
|
import XCTest
|
||||||
|
@testable import ICCeryCore
|
||||||
|
|
||||||
|
/// Issue #146 — `MediaLibraryStore` persistence contract.
|
||||||
|
final class MediaLibraryStoreTests: XCTestCase {
|
||||||
|
|
||||||
|
private var url: URL!
|
||||||
|
|
||||||
|
override func setUp() {
|
||||||
|
url = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("media-lib-\(UUID().uuidString).json")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() {
|
||||||
|
try? FileManager.default.removeItem(at: url)
|
||||||
|
url = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func recipe(id: String, name: String = "R") -> MediaRecipe {
|
||||||
|
MediaRecipe(
|
||||||
|
id: id, name: name, printerID: "q",
|
||||||
|
colourSpace: "rgb", presetID: "preset-std-rgb")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRoundTrip() async throws {
|
||||||
|
let store = MediaLibraryStore(url: url)
|
||||||
|
let r = recipe(id: "recipe-1", name: "Epson Rag")
|
||||||
|
try await store.upsert(r)
|
||||||
|
let loaded = try await store.load()
|
||||||
|
XCTAssertEqual(loaded, [r])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCorruptFileThrowsAndPreservesBytes() async throws {
|
||||||
|
try "not json".write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
let before = try Data(contentsOf: url)
|
||||||
|
|
||||||
|
let store = MediaLibraryStore(url: url)
|
||||||
|
await assertAsyncThrows(expectedType: DecodingError.self) {
|
||||||
|
try await store.load()
|
||||||
|
}
|
||||||
|
// upsert must also propagate — a corrupt file is never wiped.
|
||||||
|
await assertAsyncThrows(expectedType: DecodingError.self) {
|
||||||
|
try await store.upsert(recipe(id: "x"))
|
||||||
|
}
|
||||||
|
XCTAssertEqual(try Data(contentsOf: url), before)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDelete() async throws {
|
||||||
|
let store = MediaLibraryStore(url: url)
|
||||||
|
try await store.upsert(recipe(id: "a"))
|
||||||
|
try await store.upsert(recipe(id: "b"))
|
||||||
|
|
||||||
|
let removed = try await store.delete(id: "a")
|
||||||
|
XCTAssertTrue(removed)
|
||||||
|
let remaining = try await store.load().map(\.id)
|
||||||
|
XCTAssertEqual(remaining, ["b"])
|
||||||
|
|
||||||
|
let again = try await store.delete(id: "a")
|
||||||
|
XCTAssertFalse(again)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCapacityReached() async throws {
|
||||||
|
let store = MediaLibraryStore(url: url, capacity: 2)
|
||||||
|
try await store.upsert(recipe(id: "1"))
|
||||||
|
try await store.upsert(recipe(id: "2"))
|
||||||
|
await assertAsyncThrows(
|
||||||
|
expectedType: MediaLibraryStore.MediaLibraryError.self
|
||||||
|
) {
|
||||||
|
try await store.upsert(recipe(id: "3"))
|
||||||
|
} errorHandler: {
|
||||||
|
XCTAssertEqual($0, .capacityReached(2))
|
||||||
|
}
|
||||||
|
let stored = try await store.load()
|
||||||
|
XCTAssertEqual(stored.count, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUpsertPreservesCreatedBumpsUpdated() async throws {
|
||||||
|
let store = MediaLibraryStore(url: url)
|
||||||
|
var r = recipe(id: "recipe-1")
|
||||||
|
r.created = Date(timeIntervalSince1970: 1_000_000)
|
||||||
|
r.updated = Date(timeIntervalSince1970: 1_000_000)
|
||||||
|
try await store.upsert(r)
|
||||||
|
|
||||||
|
var edit = r
|
||||||
|
edit.name = "Renamed"
|
||||||
|
edit.updated = Date(timeIntervalSince1970: 2_000_000)
|
||||||
|
try await store.upsert(edit)
|
||||||
|
|
||||||
|
let loaded = try await store.load()
|
||||||
|
XCTAssertEqual(loaded.count, 1)
|
||||||
|
XCTAssertEqual(loaded[0].name, "Renamed")
|
||||||
|
XCTAssertEqual(loaded[0].created, r.created)
|
||||||
|
XCTAssertGreaterThan(loaded[0].updated, r.updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
import Foundation
|
||||||
|
import XCTest
|
||||||
|
@testable import ICCeryCore
|
||||||
|
@testable import ICCery
|
||||||
|
|
||||||
|
/// Issue #146 — `MediaLibraryViewModel` apply / capture / staleness
|
||||||
|
/// under an isolated `TestAppEnvironment` with a mock CUPS `bin` dir.
|
||||||
|
@MainActor
|
||||||
|
final class MediaLibraryViewModelTests: XCTestCase {
|
||||||
|
|
||||||
|
// CTI3 fixture mirrored from CalibrationStoreTests.
|
||||||
|
private static let sampleCal = """
|
||||||
|
CTI3
|
||||||
|
DESCRIPTOR "Test printer"
|
||||||
|
COLOR_REP "RGB"
|
||||||
|
DEVICE_CLASS "OUTPUT"
|
||||||
|
MAX_TAC "300"
|
||||||
|
NUMBER_OF_FIELDS 5
|
||||||
|
NUMBER_OF_SETS 3
|
||||||
|
BEGIN_DATA_FORMAT
|
||||||
|
SAMPLE_ID INPUT_VALUE R G B
|
||||||
|
END_DATA_FORMAT
|
||||||
|
BEGIN_DATA
|
||||||
|
1 0 0 0 0
|
||||||
|
2 128 64 64 64
|
||||||
|
3 255 255 255 255
|
||||||
|
END_DATA
|
||||||
|
"""
|
||||||
|
|
||||||
|
/// Same fixture plus an old CREATED keyword so the age check fires.
|
||||||
|
private static let staleCal = """
|
||||||
|
CTI3
|
||||||
|
DESCRIPTOR "Test printer"
|
||||||
|
CREATED "2020-01-01T00:00:00Z"
|
||||||
|
COLOR_REP "RGB"
|
||||||
|
DEVICE_CLASS "OUTPUT"
|
||||||
|
NUMBER_OF_FIELDS 5
|
||||||
|
NUMBER_OF_SETS 3
|
||||||
|
BEGIN_DATA_FORMAT
|
||||||
|
SAMPLE_ID INPUT_VALUE R G B
|
||||||
|
END_DATA_FORMAT
|
||||||
|
BEGIN_DATA
|
||||||
|
1 0 0 0 0
|
||||||
|
2 128 64 64 64
|
||||||
|
3 255 255 255 255
|
||||||
|
END_DATA
|
||||||
|
"""
|
||||||
|
|
||||||
|
private var env: TestAppEnvironment!
|
||||||
|
private var workflow: TargetWorkflowViewModel!
|
||||||
|
private var media: MediaLibraryViewModel!
|
||||||
|
|
||||||
|
override func setUp() async throws {
|
||||||
|
env = try TestAppEnvironment.make()
|
||||||
|
try installMockCups()
|
||||||
|
workflow = TargetWorkflowViewModel(environment: env.environment)
|
||||||
|
media = workflow.media
|
||||||
|
await media.reloadAsync()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() async throws {
|
||||||
|
env?.cleanup()
|
||||||
|
env = nil
|
||||||
|
workflow = nil
|
||||||
|
media = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mock `lpstat`/`lpoptions` inside the env's `cups-bin` (the
|
||||||
|
/// `CupsService.binaryDir` `TestAppEnvironment` points at). Queues:
|
||||||
|
/// `Mock_Queue` (default) and `Other_Queue`.
|
||||||
|
private func installMockCups() throws {
|
||||||
|
let bin = env.root.appendingPathComponent("cups-bin")
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: bin, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
let lpstat = """
|
||||||
|
#!/bin/sh
|
||||||
|
case "$1" in
|
||||||
|
-e) printf 'Mock_Queue\\nOther_Queue\\n' ;;
|
||||||
|
-p) printf 'printer Mock_Queue is idle.\\nprinter Other_Queue is idle.\\n' ;;
|
||||||
|
-d) printf 'system default destination: Mock_Queue\\n' ;;
|
||||||
|
esac
|
||||||
|
exit 0
|
||||||
|
"""
|
||||||
|
let lpoptions = """
|
||||||
|
#!/bin/sh
|
||||||
|
list=0
|
||||||
|
queue=""
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
-l) list=1 ;;
|
||||||
|
-p) ;;
|
||||||
|
*) queue="$arg" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
if [ "$list" = "1" ]; then
|
||||||
|
printf 'PageSize/Media Size: *A4 Letter\\n'
|
||||||
|
printf 'MediaType/Media Type: *Stationery Glossy\\n'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
printf "printer-info='Mock %s' printer-type=42\\n" "$queue"
|
||||||
|
exit 0
|
||||||
|
"""
|
||||||
|
for (name, body) in ["lpstat": lpstat, "lpoptions": lpoptions] {
|
||||||
|
let url = bin.appendingPathComponent(name)
|
||||||
|
try body.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
try FileManager.default.setAttributes(
|
||||||
|
[.posixPermissions: 0o755], ofItemAtPath: url.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func writeCal(
|
||||||
|
_ contents: String = MediaLibraryViewModelTests.sampleCal,
|
||||||
|
named name: String = "recipe.cal"
|
||||||
|
) throws -> String {
|
||||||
|
let url = env.root.appendingPathComponent(name)
|
||||||
|
try contents.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
return url.path
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeRecipe(
|
||||||
|
id: String = "recipe-t1",
|
||||||
|
printerID: String = "Mock_Queue",
|
||||||
|
colourSpace: String = "rgb",
|
||||||
|
presetID: String = "preset-std-rgb",
|
||||||
|
calibrationURL: String? = nil,
|
||||||
|
applyCalibration: Bool = false
|
||||||
|
) -> MediaRecipe {
|
||||||
|
MediaRecipe(
|
||||||
|
id: id,
|
||||||
|
name: "Test Recipe",
|
||||||
|
printerID: printerID,
|
||||||
|
printerDisplayName: "Mock Queue Display",
|
||||||
|
paperName: "Rag",
|
||||||
|
inkSet: "PK",
|
||||||
|
colourSpace: colourSpace,
|
||||||
|
presetID: presetID,
|
||||||
|
calibrationURL: calibrationURL,
|
||||||
|
applyCalibration: applyCalibration)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func seed(_ recipe: MediaRecipe) async throws {
|
||||||
|
try await env.environment.mediaStore.upsert(recipe)
|
||||||
|
await media.reloadAsync()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Apply
|
||||||
|
|
||||||
|
func testApplyHappyPath() async throws {
|
||||||
|
let calPath = try writeCal()
|
||||||
|
let r = makeRecipe(
|
||||||
|
calibrationURL: calPath, applyCalibration: true)
|
||||||
|
try await seed(r)
|
||||||
|
|
||||||
|
let applied = await media.apply(r)
|
||||||
|
|
||||||
|
XCTAssertTrue(applied)
|
||||||
|
XCTAssertEqual(workflow.print.selectedPrinter, "Mock_Queue")
|
||||||
|
XCTAssertEqual(workflow.wizard.printerName, "Mock Queue Display")
|
||||||
|
XCTAssertTrue(workflow.profile.applyCalibration)
|
||||||
|
XCTAssertEqual(workflow.profile.calibrationFile, calPath)
|
||||||
|
XCTAssertEqual(media.selectedRecipeID, r.id)
|
||||||
|
XCTAssertEqual(workflow.selectedPresetID, "preset-std-rgb")
|
||||||
|
XCTAssertEqual(
|
||||||
|
workflow.buildPrinttargConfig().calibrationFile, calPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testApplyMissingPresetRefuses() async throws {
|
||||||
|
let r = makeRecipe(presetID: "preset-nonexistent")
|
||||||
|
try await seed(r)
|
||||||
|
|
||||||
|
let applied = await media.apply(r)
|
||||||
|
|
||||||
|
XCTAssertFalse(applied)
|
||||||
|
XCTAssertEqual(media.selectedRecipeID, "none")
|
||||||
|
XCTAssertEqual(workflow.selectedPresetID, "none")
|
||||||
|
XCTAssertTrue(
|
||||||
|
workflow.wizard.notice?.text.contains("no longer exists") == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testApplyColourSpaceMismatchRefuses() async throws {
|
||||||
|
let r = makeRecipe(colourSpace: "cmyk", presetID: "preset-std-rgb")
|
||||||
|
try await seed(r)
|
||||||
|
|
||||||
|
let applied = await media.apply(r)
|
||||||
|
|
||||||
|
XCTAssertFalse(applied)
|
||||||
|
XCTAssertEqual(media.selectedRecipeID, "none")
|
||||||
|
XCTAssertTrue(
|
||||||
|
workflow.wizard.notice?.text.contains("colour space") == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testApplyMissingCalFileFails() async throws {
|
||||||
|
let missing = env.root.appendingPathComponent("gone.cal").path
|
||||||
|
let r = makeRecipe(
|
||||||
|
calibrationURL: missing, applyCalibration: true)
|
||||||
|
try await seed(r)
|
||||||
|
|
||||||
|
let applied = await media.apply(r)
|
||||||
|
|
||||||
|
XCTAssertFalse(applied)
|
||||||
|
XCTAssertFalse(workflow.profile.applyCalibration)
|
||||||
|
XCTAssertEqual(workflow.profile.calibrationFile, missing)
|
||||||
|
XCTAssertEqual(workflow.wizard.notice?.kind, .error)
|
||||||
|
XCTAssertEqual(media.selectedRecipeID, "none")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testApplyCalPrefixedCalCannotArmK() async throws {
|
||||||
|
let calPath = try writeCal(named: "CAL_target.cal")
|
||||||
|
let r = makeRecipe(
|
||||||
|
calibrationURL: calPath, applyCalibration: true)
|
||||||
|
try await seed(r)
|
||||||
|
|
||||||
|
let applied = await media.apply(r)
|
||||||
|
|
||||||
|
// Success with warning — the refusal is permanent, re-clicking
|
||||||
|
// cannot unstick it (decision 6).
|
||||||
|
XCTAssertTrue(applied)
|
||||||
|
XCTAssertFalse(workflow.profile.applyCalibration)
|
||||||
|
XCTAssertEqual(workflow.profile.calibrationFile, calPath)
|
||||||
|
XCTAssertNil(workflow.buildPrinttargConfig().calibrationFile)
|
||||||
|
XCTAssertEqual(media.selectedRecipeID, r.id)
|
||||||
|
XCTAssertEqual(workflow.wizard.notice?.kind, .warning)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testApplyLiveCalBasenameBlocks() async throws {
|
||||||
|
let calPath = try writeCal()
|
||||||
|
let r = makeRecipe(
|
||||||
|
calibrationURL: calPath, applyCalibration: true)
|
||||||
|
try await seed(r)
|
||||||
|
workflow.wizard.basename = "CAL_live"
|
||||||
|
|
||||||
|
let applied = await media.apply(r)
|
||||||
|
|
||||||
|
XCTAssertTrue(applied)
|
||||||
|
XCTAssertFalse(workflow.profile.applyCalibration)
|
||||||
|
XCTAssertNil(workflow.buildPrinttargConfig().calibrationFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testApplyMissingQueueLeavesQueueUntouched() async throws {
|
||||||
|
workflow.print.selectedPrinter = "Other_Queue"
|
||||||
|
let r = makeRecipe(printerID: "No_Such_Queue")
|
||||||
|
try await seed(r)
|
||||||
|
|
||||||
|
let applied = await media.apply(r)
|
||||||
|
|
||||||
|
XCTAssertFalse(applied)
|
||||||
|
XCTAssertEqual(workflow.print.selectedPrinter, "Other_Queue")
|
||||||
|
XCTAssertTrue(
|
||||||
|
workflow.wizard.notice?.text.contains("is not installed") == true)
|
||||||
|
XCTAssertEqual(media.selectedRecipeID, "none")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Capture
|
||||||
|
|
||||||
|
func testCaptureCopiesPrinterAndPreset() async throws {
|
||||||
|
workflow.print.printers = [
|
||||||
|
Printer(name: "Mock_Queue", displayName: "Mock Queue Display")
|
||||||
|
]
|
||||||
|
workflow.print.selectedPrinter = "Mock_Queue"
|
||||||
|
workflow.selectedPresetID = "preset-std-rgb"
|
||||||
|
media.saveMediaName = "My Recipe"
|
||||||
|
media.saveMediaPaper = "Rag"
|
||||||
|
media.saveMediaInk = "PK"
|
||||||
|
|
||||||
|
let saved = await media.captureFromSession()
|
||||||
|
|
||||||
|
XCTAssertTrue(saved)
|
||||||
|
let stored = await env.environment.mediaStore.all()
|
||||||
|
XCTAssertEqual(stored.count, 1)
|
||||||
|
XCTAssertEqual(stored[0].printerID, "Mock_Queue")
|
||||||
|
XCTAssertEqual(stored[0].printerDisplayName, "Mock Queue Display")
|
||||||
|
XCTAssertEqual(stored[0].presetID, "preset-std-rgb")
|
||||||
|
XCTAssertEqual(stored[0].colourSpace, "rgb")
|
||||||
|
XCTAssertEqual(media.selectedRecipeID, stored[0].id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCaptureNoPresetAutoSnapshots() async throws {
|
||||||
|
workflow.print.printers = [
|
||||||
|
Printer(name: "Mock_Queue", displayName: "Mock Queue Display")
|
||||||
|
]
|
||||||
|
workflow.print.selectedPrinter = "Mock_Queue"
|
||||||
|
workflow.selectedPresetID = "none"
|
||||||
|
media.saveMediaName = "Snap"
|
||||||
|
media.saveMediaPaper = "Rag"
|
||||||
|
media.saveMediaInk = "MK"
|
||||||
|
|
||||||
|
let saved = await media.captureFromSession()
|
||||||
|
|
||||||
|
XCTAssertTrue(saved)
|
||||||
|
let stored = await env.environment.mediaStore.all()
|
||||||
|
XCTAssertEqual(stored.count, 1)
|
||||||
|
XCTAssertTrue(stored[0].presetID.hasPrefix("custom-"))
|
||||||
|
XCTAssertTrue(
|
||||||
|
env.environment.presetStore.customs()
|
||||||
|
.contains { $0.id == stored[0].presetID })
|
||||||
|
XCTAssertEqual(workflow.selectedPresetID, stored[0].presetID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCaptureRequiresPrinter() async {
|
||||||
|
workflow.print.selectedPrinter = ""
|
||||||
|
media.saveMediaName = "n"
|
||||||
|
media.saveMediaPaper = "p"
|
||||||
|
media.saveMediaInk = "i"
|
||||||
|
|
||||||
|
let saved = await media.captureFromSession()
|
||||||
|
|
||||||
|
XCTAssertFalse(saved)
|
||||||
|
XCTAssertNotNil(media.saveMediaError)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCaptureForcesOffCalToggleForCalFile() async throws {
|
||||||
|
let calPath = try writeCal(named: "CAL_target.cal")
|
||||||
|
workflow.profile.calibrationFile = calPath
|
||||||
|
workflow.profile.applyCalibration = true
|
||||||
|
workflow.print.printers = [Printer(name: "Mock_Queue")]
|
||||||
|
workflow.print.selectedPrinter = "Mock_Queue"
|
||||||
|
workflow.selectedPresetID = "preset-std-rgb"
|
||||||
|
media.saveMediaName = "n"
|
||||||
|
media.saveMediaPaper = "p"
|
||||||
|
media.saveMediaInk = "i"
|
||||||
|
media.saveMediaApplyCal = true // forced off by calApplyable
|
||||||
|
|
||||||
|
XCTAssertFalse(media.calApplyable)
|
||||||
|
let saved = await media.captureFromSession()
|
||||||
|
|
||||||
|
XCTAssertTrue(saved)
|
||||||
|
let stored = await env.environment.mediaStore.all()
|
||||||
|
XCTAssertEqual(stored[0].calibrationURL, calPath) // verbatim
|
||||||
|
XCTAssertFalse(stored[0].applyCalibration)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Staleness
|
||||||
|
|
||||||
|
func testStaleCalFlagged() async throws {
|
||||||
|
let calPath = try writeCal(
|
||||||
|
MediaLibraryViewModelTests.staleCal, named: "old.cal")
|
||||||
|
let r = makeRecipe(calibrationURL: calPath)
|
||||||
|
try await seed(r)
|
||||||
|
workflow.print.printers = [Printer(name: "Mock_Queue")]
|
||||||
|
|
||||||
|
await media.refreshStalenessAsync()
|
||||||
|
|
||||||
|
XCTAssertTrue(
|
||||||
|
media.staleReasons[r.id]?.contains(.calibration) == true)
|
||||||
|
XCTAssertNotNil(media.calAgeDays[r.id])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAbsentQueueFlaggedOnlyWhenListNonEmpty() async throws {
|
||||||
|
let r = makeRecipe(printerID: "No_Such_Queue")
|
||||||
|
try await seed(r)
|
||||||
|
|
||||||
|
// Un-enumerated list is indeterminate → no flag.
|
||||||
|
workflow.print.printers = []
|
||||||
|
await media.refreshStalenessAsync()
|
||||||
|
XCTAssertNil(media.staleReasons[r.id])
|
||||||
|
|
||||||
|
// Absent from a non-empty list → .printer.
|
||||||
|
workflow.print.printers = [Printer(name: "Other_Queue")]
|
||||||
|
await media.refreshStalenessAsync()
|
||||||
|
XCTAssertTrue(
|
||||||
|
media.staleReasons[r.id]?.contains(.printer) == true)
|
||||||
|
|
||||||
|
// Present but unselected → no flag.
|
||||||
|
workflow.print.printers = [
|
||||||
|
Printer(name: "Other_Queue"), Printer(name: "No_Such_Queue"),
|
||||||
|
]
|
||||||
|
workflow.print.selectedPrinter = "Other_Queue"
|
||||||
|
await media.refreshStalenessAsync()
|
||||||
|
XCTAssertNil(media.staleReasons[r.id])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Delete
|
||||||
|
|
||||||
|
func testDeleteResetsSelection() async throws {
|
||||||
|
let r = makeRecipe()
|
||||||
|
try await seed(r)
|
||||||
|
media.selectedRecipeID = r.id
|
||||||
|
|
||||||
|
media.delete(r)
|
||||||
|
try await Task.sleep(nanoseconds: 200_000_000)
|
||||||
|
|
||||||
|
XCTAssertEqual(media.selectedRecipeID, "none")
|
||||||
|
let stored = await env.environment.mediaStore.all()
|
||||||
|
XCTAssertTrue(stored.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Corrupt library
|
||||||
|
|
||||||
|
func testCorruptLibraryKeepsFileAndWarns() async throws {
|
||||||
|
// Fresh environment so the store's `loaded` flag is still false.
|
||||||
|
let env2 = try TestAppEnvironment.make()
|
||||||
|
defer { env2.cleanup() }
|
||||||
|
try "garbage".write(
|
||||||
|
to: env2.mediaLibraryURL, atomically: true, encoding: .utf8)
|
||||||
|
let workflow2 = TargetWorkflowViewModel(
|
||||||
|
environment: env2.environment)
|
||||||
|
await workflow2.media.reloadAsync()
|
||||||
|
|
||||||
|
XCTAssertTrue(workflow2.media.recipes.isEmpty)
|
||||||
|
XCTAssertEqual(workflow2.media.selectedRecipeID, "none")
|
||||||
|
XCTAssertEqual(workflow2.wizard.notice?.kind, .warning)
|
||||||
|
XCTAssertEqual(try Data(contentsOf: env2.mediaLibraryURL),
|
||||||
|
"garbage".data(using: .utf8))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import Foundation
|
||||||
|
import XCTest
|
||||||
|
@testable import ICCeryCore
|
||||||
|
|
||||||
|
/// Issue #146 — `MediaRecipe` Codable + validation contract.
|
||||||
|
final class MediaRecipeTests: XCTestCase {
|
||||||
|
|
||||||
|
private func makeRecipe() -> MediaRecipe {
|
||||||
|
MediaRecipe(
|
||||||
|
id: "recipe-abc",
|
||||||
|
name: "Epson Rag",
|
||||||
|
notes: "notes",
|
||||||
|
printerID: "epson_p900",
|
||||||
|
printerDisplayName: "Epson SureColor P900",
|
||||||
|
paperName: "Rag Photographique",
|
||||||
|
driverMediaType: "PhotographicGlossy",
|
||||||
|
inkSet: "PK",
|
||||||
|
colourSpace: "rgb",
|
||||||
|
presetID: "preset-std-rgb",
|
||||||
|
calibrationURL: "/tmp/prof.cal",
|
||||||
|
applyCalibration: true,
|
||||||
|
created: Date(timeIntervalSince1970: 1_700_000_000),
|
||||||
|
updated: Date(timeIntervalSince1970: 1_700_000_100)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRoundTripSnakeCase() throws {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.dateEncodingStrategy = .iso8601
|
||||||
|
let data = try encoder.encode(makeRecipe())
|
||||||
|
let object = try JSONSerialization.jsonObject(with: data) as! [String: Any]
|
||||||
|
|
||||||
|
for key in [
|
||||||
|
"printer_id", "printer_display_name", "paper_name",
|
||||||
|
"driver_media_type", "ink_set", "colour_space", "preset_id",
|
||||||
|
"calibration_url", "apply_calibration", "created", "updated",
|
||||||
|
"id", "name", "notes",
|
||||||
|
] {
|
||||||
|
XCTAssertNotNil(object[key], "missing key \(key)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
decoder.dateDecodingStrategy = .iso8601
|
||||||
|
let decoded = try decoder.decode(MediaRecipe.self, from: data)
|
||||||
|
XCTAssertEqual(decoded, makeRecipe())
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMissingRequiredKeyThrows() {
|
||||||
|
for key in ["id", "name", "printer_id", "colour_space", "preset_id"] {
|
||||||
|
var dict: [String: Any] = [
|
||||||
|
"id": "r1", "name": "n", "printer_id": "q",
|
||||||
|
"colour_space": "rgb", "preset_id": "p",
|
||||||
|
]
|
||||||
|
dict.removeValue(forKey: key)
|
||||||
|
let data = try! JSONSerialization.data(withJSONObject: dict)
|
||||||
|
XCTAssertThrowsError(
|
||||||
|
try JSONDecoder().decode(MediaRecipe.self, from: data),
|
||||||
|
"expected throw without \(key)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUnknownKeysIgnored() throws {
|
||||||
|
let dict: [String: Any] = [
|
||||||
|
"id": "r1", "name": "n", "printer_id": "q",
|
||||||
|
"colour_space": "rgb", "preset_id": "p",
|
||||||
|
"future_field": "ignored",
|
||||||
|
]
|
||||||
|
let data = try JSONSerialization.data(withJSONObject: dict)
|
||||||
|
let recipe = try JSONDecoder().decode(MediaRecipe.self, from: data)
|
||||||
|
XCTAssertEqual(recipe.id, "r1")
|
||||||
|
XCTAssertEqual(recipe.notes, "")
|
||||||
|
XCTAssertFalse(recipe.applyCalibration)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testValidatedGoldens() {
|
||||||
|
var r = makeRecipe()
|
||||||
|
|
||||||
|
r.name = " "
|
||||||
|
XCTAssertThrowsError(try r.validated()) {
|
||||||
|
XCTAssertEqual($0 as? MediaRecipe.ValidationError, .emptyName)
|
||||||
|
}
|
||||||
|
|
||||||
|
r = makeRecipe()
|
||||||
|
r.printerID = ""
|
||||||
|
XCTAssertThrowsError(try r.validated()) {
|
||||||
|
XCTAssertEqual($0 as? MediaRecipe.ValidationError, .emptyPrinterID)
|
||||||
|
}
|
||||||
|
|
||||||
|
r = makeRecipe()
|
||||||
|
r.presetID = ""
|
||||||
|
XCTAssertThrowsError(try r.validated()) {
|
||||||
|
XCTAssertEqual($0 as? MediaRecipe.ValidationError, .emptyPresetID)
|
||||||
|
}
|
||||||
|
|
||||||
|
r = makeRecipe()
|
||||||
|
r.colourSpace = "lab"
|
||||||
|
XCTAssertThrowsError(try r.validated()) {
|
||||||
|
XCTAssertEqual(
|
||||||
|
$0 as? MediaRecipe.ValidationError, .invalidColourSpace("lab"))
|
||||||
|
}
|
||||||
|
|
||||||
|
for bad in ["../evil.cal", "rel/path.cal", "/tmp/a\0b.cal"] {
|
||||||
|
r = makeRecipe()
|
||||||
|
r.calibrationURL = bad
|
||||||
|
XCTAssertThrowsError(try r.validated(), "expected throw for \(bad)") {
|
||||||
|
XCTAssertEqual(
|
||||||
|
$0 as? MediaRecipe.ValidationError,
|
||||||
|
.invalidCalibrationURL(bad))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testColourSpaceNormalisedToLowercase() throws {
|
||||||
|
var r = makeRecipe()
|
||||||
|
r.colourSpace = "RGB"
|
||||||
|
let validated = try r.validated()
|
||||||
|
XCTAssertEqual(validated.colourSpace, "rgb")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCalPrefixedCalNameIsSchemaValid() throws {
|
||||||
|
var r = makeRecipe()
|
||||||
|
// CAL_ refusal is an apply-time policy, not a schema error.
|
||||||
|
r.calibrationURL = "/tmp/CAL_target.cal"
|
||||||
|
XCTAssertNoThrow(try r.validated())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,116 +1,98 @@
|
|||||||
import Testing
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import XCTest
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
@Suite("ProfilingPreset")
|
final class ProfilingPresetTests: XCTestCase {
|
||||||
struct ProfilingPresetTests {
|
|
||||||
|
|
||||||
@Test("snake_case keys round-trip through Codable")
|
func testRoundTrip() throws {
|
||||||
func roundTrip() throws {
|
|
||||||
var p = PresetCatalog.highQualityCMYK
|
var p = PresetCatalog.highQualityCMYK
|
||||||
p.colprofInputViewingCond = "D50_2"
|
p.colprofInputViewingCond = "D50_2"
|
||||||
let data = try JSONEncoder().encode(p)
|
let data = try JSONEncoder().encode(p)
|
||||||
let decoded = try JSONDecoder().decode(ProfilingPreset.self, from: data)
|
let decoded = try JSONDecoder().decode(ProfilingPreset.self, from: data)
|
||||||
#expect(decoded == p)
|
XCTAssertEqual(decoded, p)
|
||||||
// Spot-check the wire format.
|
// Spot-check the wire format.
|
||||||
let obj = try JSONSerialization.jsonObject(with: data) as! [String: Any]
|
let obj = try JSONSerialization.jsonObject(with: data) as! [String: Any]
|
||||||
#expect(obj["colour_space"] as? String == "cmyk")
|
XCTAssertEqual(obj["colour_space"] as? String, "cmyk")
|
||||||
#expect(obj["patch_count"] as? Int == 1500)
|
XCTAssertEqual(obj["patch_count"] as? Int, 1500)
|
||||||
#expect(obj["total_ink_limit"] as? Int == 320)
|
XCTAssertEqual(obj["total_ink_limit"] as? Int, 320)
|
||||||
#expect(obj["bit_depth"] as? Int == 16)
|
XCTAssertEqual(obj["bit_depth"] as? Int, 16)
|
||||||
#expect(obj["colprof_input_viewing_cond"] as? String == "D50_2")
|
XCTAssertEqual(obj["colprof_input_viewing_cond"] as? String, "D50_2")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Unknown keys ignored; missing required field fails")
|
func testSchemaTolerance() throws {
|
||||||
func schemaTolerance() throws {
|
|
||||||
let json = """
|
let json = """
|
||||||
{"id":"x","name":"N","colour_space":"rgb","patch_count":10,
|
{"id":"x","name":"N","colour_space":"rgb","patch_count":10,
|
||||||
"white_patches":1,"black_patches":1,"instrument":"i1",
|
"white_patches":1,"black_patches":1,"instrument":"i1",
|
||||||
"page_size":"A4","bit_depth":8,"dpi":300,"future_key":42}
|
"page_size":"A4","bit_depth":8,"dpi":300,"future_key":42}
|
||||||
""".data(using: .utf8)!
|
""".data(using: .utf8)!
|
||||||
let ok = try JSONDecoder().decode(ProfilingPreset.self, from: json)
|
let ok = try JSONDecoder().decode(ProfilingPreset.self, from: json)
|
||||||
#expect(ok.id == "x")
|
XCTAssertEqual(ok.id, "x")
|
||||||
|
|
||||||
let missing = """
|
let missing = """
|
||||||
{"id":"x","name":"N","colour_space":"rgb"}
|
{"id":"x","name":"N","colour_space":"rgb"}
|
||||||
""".data(using: .utf8)!
|
""".data(using: .utf8)!
|
||||||
#expect(throws: DecodingError.self) {
|
XCTAssertThrowsError(try JSONDecoder().decode(ProfilingPreset.self, from: missing)) { error in XCTAssertTrue(error is DecodingError) }
|
||||||
try JSONDecoder().decode(ProfilingPreset.self, from: missing)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Validation rejects bad colour space / dpi / bit depth")
|
func testValidation() {
|
||||||
func validation() {
|
XCTAssertThrowsError(try ProfilingPreset(id: "a", name: "n", colourSpace: "lab").validated()) { error in XCTAssertTrue(error is ProfilingPreset.ValidationError) }
|
||||||
#expect(throws: ProfilingPreset.ValidationError.self) {
|
XCTAssertThrowsError(try ProfilingPreset(id: "a", name: "n", dpi: 10).validated()) { error in XCTAssertTrue(error is ProfilingPreset.ValidationError) }
|
||||||
try ProfilingPreset(id: "a", name: "n", colourSpace: "lab").validated()
|
XCTAssertThrowsError(try ProfilingPreset(id: "a", name: "n", bitDepth: 12).validated()) { error in XCTAssertTrue(error is ProfilingPreset.ValidationError) }
|
||||||
}
|
XCTAssertThrowsError(try ProfilingPreset(id: "a", name: "n", patchCount: 0).validated()) { error in XCTAssertTrue(error is ProfilingPreset.ValidationError) }
|
||||||
#expect(throws: ProfilingPreset.ValidationError.self) {
|
|
||||||
try ProfilingPreset(id: "a", name: "n", dpi: 10).validated()
|
|
||||||
}
|
|
||||||
#expect(throws: ProfilingPreset.ValidationError.self) {
|
|
||||||
try ProfilingPreset(id: "a", name: "n", bitDepth: 12).validated()
|
|
||||||
}
|
|
||||||
#expect(throws: ProfilingPreset.ValidationError.self) {
|
|
||||||
try ProfilingPreset(id: "a", name: "n", patchCount: 0).validated()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suite("PresetCatalog")
|
final class PresetCatalogTests: XCTestCase {
|
||||||
struct PresetCatalogTests {
|
|
||||||
|
|
||||||
@Test("Four built-ins with the documented values")
|
func testBuiltIns() {
|
||||||
func builtIns() {
|
XCTAssertEqual(PresetCatalog.builtIns.count, 4)
|
||||||
#expect(PresetCatalog.builtIns.count == 4)
|
|
||||||
let byID = Dictionary(uniqueKeysWithValues: PresetCatalog.builtIns.map { ($0.id, $0) })
|
let byID = Dictionary(uniqueKeysWithValues: PresetCatalog.builtIns.map { ($0.id, $0) })
|
||||||
|
|
||||||
let std = byID["preset-std-rgb"]!
|
let std = byID["preset-std-rgb"]!
|
||||||
#expect(std.colourSpace == "rgb" && std.patchCount == 800
|
XCTAssertTrue(std.colourSpace == "rgb" && std.patchCount == 800
|
||||||
&& std.pageSize == "A4" && std.bitDepth == 8
|
&& std.pageSize == "A4" && std.bitDepth == 8
|
||||||
&& std.dpi == 300 && std.colprofQuality == "m"
|
&& std.dpi == 300 && std.colprofQuality == "m"
|
||||||
&& std.whitePatches == 4 && std.blackPatches == 4)
|
&& std.whitePatches == 4 && std.blackPatches == 4)
|
||||||
|
|
||||||
let hq = byID["preset-hq-cmyk"]!
|
let hq = byID["preset-hq-cmyk"]!
|
||||||
#expect(hq.colourSpace == "cmyk" && hq.patchCount == 1500
|
XCTAssertTrue(hq.colourSpace == "cmyk" && hq.patchCount == 1500
|
||||||
&& hq.pageSize == "A3" && hq.bitDepth == 16
|
&& hq.pageSize == "A3" && hq.bitDepth == 16
|
||||||
&& hq.dpi == 300 && hq.colprofQuality == "h"
|
&& hq.dpi == 300 && hq.colprofQuality == "h"
|
||||||
&& hq.totalInkLimit == 320 && hq.blackPatches == 8)
|
&& hq.totalInkLimit == 320 && hq.blackPatches == 8)
|
||||||
|
|
||||||
let draft = byID["preset-draft-rgb"]!
|
let draft = byID["preset-draft-rgb"]!
|
||||||
#expect(draft.colourSpace == "rgb" && draft.patchCount == 400
|
XCTAssertTrue(draft.colourSpace == "rgb" && draft.patchCount == 400
|
||||||
&& draft.pageSize == "A4" && draft.bitDepth == 8
|
&& draft.pageSize == "A4" && draft.bitDepth == 8
|
||||||
&& draft.dpi == 150 && draft.colprofQuality == "l")
|
&& draft.dpi == 150 && draft.colprofQuality == "l")
|
||||||
|
|
||||||
let ultra = byID["preset-ultra-rgb"]!
|
let ultra = byID["preset-ultra-rgb"]!
|
||||||
#expect(ultra.colourSpace == "rgb" && ultra.patchCount == 2500
|
XCTAssertTrue(ultra.colourSpace == "rgb" && ultra.patchCount == 2500
|
||||||
&& ultra.pageSize == "A3" && ultra.bitDepth == 16
|
&& ultra.pageSize == "A3" && ultra.bitDepth == 16
|
||||||
&& ultra.dpi == 300 && ultra.colprofQuality == "u"
|
&& ultra.dpi == 300 && ultra.colprofQuality == "u"
|
||||||
&& ultra.ofpsHighQuality == true
|
&& ultra.ofpsHighQuality == true
|
||||||
&& ultra.whitePatches == 6 && ultra.blackPatches == 6)
|
&& ultra.whitePatches == 6 && ultra.blackPatches == 6)
|
||||||
|
|
||||||
for p in PresetCatalog.builtIns {
|
for p in PresetCatalog.builtIns {
|
||||||
#expect(p.instrument == "i1")
|
XCTAssertEqual(p.instrument, "i1")
|
||||||
#expect(p.colprofFwa == "D50")
|
XCTAssertEqual(p.colprofFwa, "D50")
|
||||||
#expect(p.randomSeed == 1)
|
XCTAssertEqual(p.randomSeed, 1)
|
||||||
#expect(p.noRandomize == false)
|
XCTAssertEqual(p.noRandomize, false)
|
||||||
#expect(p.colprofAlgorithm == "l")
|
XCTAssertEqual(p.colprofAlgorithm, "l")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Custom presets overlay by id; built-ins are not deletable")
|
func testOverlay() {
|
||||||
func overlay() {
|
|
||||||
let custom = ProfilingPreset(
|
let custom = ProfilingPreset(
|
||||||
id: "preset-std-rgb", name: "Shadowed", patchCount: 42)
|
id: "preset-std-rgb", name: "Shadowed", patchCount: 42)
|
||||||
let all = PresetCatalog.all(custom: [custom])
|
let all = PresetCatalog.all(custom: [custom])
|
||||||
#expect(all.count == 4)
|
XCTAssertEqual(all.count, 4)
|
||||||
#expect(all.first { $0.id == "preset-std-rgb" }?.patchCount == 42)
|
XCTAssertEqual(all.first { $0.id == "preset-std-rgb" }?.patchCount, 42)
|
||||||
#expect(PresetCatalog.isBuiltIn("preset-std-rgb"))
|
XCTAssertTrue(PresetCatalog.isBuiltIn("preset-std-rgb"))
|
||||||
#expect(!PresetCatalog.isBuiltIn("custom-1"))
|
XCTAssertFalse(PresetCatalog.isBuiltIn("custom-1"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suite("PresetStore")
|
final class PresetStoreTests: XCTestCase {
|
||||||
struct PresetStoreTests {
|
|
||||||
|
|
||||||
private func tempSettingsURL() throws -> URL {
|
private func tempSettingsURL() throws -> URL {
|
||||||
let dir = FileManager.default.temporaryDirectory
|
let dir = FileManager.default.temporaryDirectory
|
||||||
@@ -119,58 +101,52 @@ struct PresetStoreTests {
|
|||||||
return dir.appendingPathComponent("settings.json")
|
return dir.appendingPathComponent("settings.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("CRUD + export/import round-trip")
|
func testCrud() throws {
|
||||||
func crud() throws {
|
|
||||||
let url = try tempSettingsURL()
|
let url = try tempSettingsURL()
|
||||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||||
let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
|
let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
|
||||||
|
|
||||||
var p = ProfilingPreset(id: "custom-x", name: "Mine", patchCount: 999, dpi: 150)
|
var p = ProfilingPreset(id: "custom-x", name: "Mine", patchCount: 999, dpi: 150)
|
||||||
try store.saveCustom(p)
|
try store.saveCustom(p)
|
||||||
#expect(store.customs().count == 1)
|
XCTAssertEqual(store.customs().count, 1)
|
||||||
#expect(store.all().count == 5)
|
XCTAssertEqual(store.all().count, 5)
|
||||||
|
|
||||||
p.name = "Renamed"
|
p.name = "Renamed"
|
||||||
try store.saveCustom(p)
|
try store.saveCustom(p)
|
||||||
#expect(store.customs().count == 1)
|
XCTAssertEqual(store.customs().count, 1)
|
||||||
#expect(store.customs()[0].name == "Renamed")
|
XCTAssertEqual(store.customs()[0].name, "Renamed")
|
||||||
|
|
||||||
let data = try store.export(p)
|
let data = try store.export(p)
|
||||||
let imported = try store.import(data)
|
let imported = try store.import(data)
|
||||||
#expect(imported.name == "Renamed")
|
XCTAssertEqual(imported.name, "Renamed")
|
||||||
#expect(imported.dpi == 150)
|
XCTAssertEqual(imported.dpi, 150)
|
||||||
|
|
||||||
#expect(try store.deleteCustom(id: "custom-x"))
|
XCTAssertTrue(try store.deleteCustom(id: "custom-x"))
|
||||||
#expect(store.customs().isEmpty)
|
XCTAssertTrue(store.customs().isEmpty)
|
||||||
#expect(try !store.deleteCustom(id: "preset-std-rgb"))
|
XCTAssertFalse(try store.deleteCustom(id: "preset-std-rgb"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Import rewrites a built-in id to a fresh custom id")
|
func testImportBuiltinCollision() throws {
|
||||||
func importBuiltinCollision() throws {
|
|
||||||
let url = try tempSettingsURL()
|
let url = try tempSettingsURL()
|
||||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||||
let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
|
let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
|
||||||
let data = try store.export(PresetCatalog.standardRGB)
|
let data = try store.export(PresetCatalog.standardRGB)
|
||||||
let imported = try store.import(data)
|
let imported = try store.import(data)
|
||||||
#expect(imported.id.hasPrefix("custom-"))
|
XCTAssertTrue(imported.id.hasPrefix("custom-"))
|
||||||
#expect(!PresetCatalog.isBuiltIn(imported.id))
|
XCTAssertFalse(PresetCatalog.isBuiltIn(imported.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Built-ins are immutable through saveCustom")
|
func testBuiltInImmutable() throws {
|
||||||
func builtInImmutable() throws {
|
|
||||||
let url = try tempSettingsURL()
|
let url = try tempSettingsURL()
|
||||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||||
let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
|
let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
|
||||||
var shadowed = PresetCatalog.standardRGB
|
var shadowed = PresetCatalog.standardRGB
|
||||||
shadowed.name = "Hacked"
|
shadowed.name = "Hacked"
|
||||||
#expect(throws: PresetStore.PresetStoreError.self) {
|
XCTAssertThrowsError(try store.saveCustom(shadowed)) { error in XCTAssertTrue(error is PresetStore.PresetStoreError) }
|
||||||
try store.saveCustom(shadowed)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suite("AppSettings preset migration")
|
final class PresetMigrationTests: XCTestCase {
|
||||||
struct PresetMigrationTests {
|
|
||||||
|
|
||||||
private func tempSettingsURL() throws -> URL {
|
private func tempSettingsURL() throws -> URL {
|
||||||
let dir = FileManager.default.temporaryDirectory
|
let dir = FileManager.default.temporaryDirectory
|
||||||
@@ -179,8 +155,7 @@ struct PresetMigrationTests {
|
|||||||
return dir.appendingPathComponent("settings.json")
|
return dir.appendingPathComponent("settings.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Legacy M1 custom_presets migrate to typed schema")
|
func testLegacyMigration() throws {
|
||||||
func legacyMigration() throws {
|
|
||||||
let url = try tempSettingsURL()
|
let url = try tempSettingsURL()
|
||||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||||
let legacy = """
|
let legacy = """
|
||||||
@@ -194,20 +169,19 @@ struct PresetMigrationTests {
|
|||||||
try legacy.write(to: url)
|
try legacy.write(to: url)
|
||||||
|
|
||||||
let settings = SettingsStore(fileURL: url).load()
|
let settings = SettingsStore(fileURL: url).load()
|
||||||
#expect(settings.customPresets.count == 1)
|
XCTAssertEqual(settings.customPresets.count, 1)
|
||||||
let p = settings.customPresets[0]
|
let p = settings.customPresets[0]
|
||||||
#expect(p.name == "Old One")
|
XCTAssertEqual(p.name, "Old One")
|
||||||
#expect(p.id.hasPrefix("custom-0-"))
|
XCTAssertTrue(p.id.hasPrefix("custom-0-"))
|
||||||
#expect(p.colourSpace == "cmyk")
|
XCTAssertEqual(p.colourSpace, "cmyk")
|
||||||
#expect(p.patchCount == 900)
|
XCTAssertEqual(p.patchCount, 900)
|
||||||
#expect(p.dpi == 150)
|
XCTAssertEqual(p.dpi, 150)
|
||||||
#expect(p.bitDepth == 16)
|
XCTAssertEqual(p.bitDepth, 16)
|
||||||
#expect(p.instrument == "p3")
|
XCTAssertEqual(p.instrument, "p3")
|
||||||
#expect(p.pageSize == "A3")
|
XCTAssertEqual(p.pageSize, "A3")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Typed presets load and re-save as the typed schema")
|
func testTypedRoundTrip() throws {
|
||||||
func typedRoundTrip() throws {
|
|
||||||
let url = try tempSettingsURL()
|
let url = try tempSettingsURL()
|
||||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||||
let store = SettingsStore(fileURL: url)
|
let store = SettingsStore(fileURL: url)
|
||||||
@@ -215,50 +189,45 @@ struct PresetMigrationTests {
|
|||||||
s.customPresets = [ProfilingPreset(id: "c1", name: "C1", patchCount: 700)]
|
s.customPresets = [ProfilingPreset(id: "c1", name: "C1", patchCount: 700)]
|
||||||
try store.save(s)
|
try store.save(s)
|
||||||
let loaded = store.load()
|
let loaded = store.load()
|
||||||
#expect(loaded.customPresets.first?.patchCount == 700)
|
XCTAssertEqual(loaded.customPresets.first?.patchCount, 700)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Draft preset dpi=150 survives Codable + settings round-trip")
|
func testDraftDPI() throws {
|
||||||
func draftDPI() throws {
|
|
||||||
let url = try tempSettingsURL()
|
let url = try tempSettingsURL()
|
||||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||||
let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
|
let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
|
||||||
let data = try store.export(PresetCatalog.draftRGB)
|
let data = try store.export(PresetCatalog.draftRGB)
|
||||||
let obj = try JSONSerialization.jsonObject(with: data) as! [String: Any]
|
let obj = try JSONSerialization.jsonObject(with: data) as! [String: Any]
|
||||||
#expect(obj["dpi"] as? Int == 150)
|
XCTAssertEqual(obj["dpi"] as? Int, 150)
|
||||||
let back = try store.import(data)
|
let back = try store.import(data)
|
||||||
#expect(back.dpi == 150)
|
XCTAssertEqual(back.dpi, 150)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suite("Preset mapping")
|
final class PresetMappingTests: XCTestCase {
|
||||||
struct PresetMappingTests {
|
func testDraftDpi() {
|
||||||
@Test("Draft 150 DPI maps into PrinttargConfig")
|
|
||||||
func draftDpi() {
|
|
||||||
let cfg = PrinttargConfig(
|
let cfg = PrinttargConfig(
|
||||||
preset: PresetCatalog.draftRGB,
|
preset: PresetCatalog.draftRGB,
|
||||||
basename: "t",
|
basename: "t",
|
||||||
workingDirectory: nil,
|
workingDirectory: nil,
|
||||||
calibrationFile: nil
|
calibrationFile: nil
|
||||||
)
|
)
|
||||||
#expect(cfg.dpi == 150)
|
XCTAssertEqual(cfg.dpi, 150)
|
||||||
#expect(cfg.layoutOrder == .deterministic)
|
XCTAssertEqual(cfg.layoutOrder, .deterministic)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Nil optional targen fields stay nil")
|
func testOptionalNil() {
|
||||||
func optionalNil() {
|
|
||||||
let preset = ProfilingPreset(id: "x", name: "n", patchCount: 800)
|
let preset = ProfilingPreset(id: "x", name: "n", patchCount: 800)
|
||||||
let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
|
let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
|
||||||
#expect(cfg.greySteps == nil)
|
XCTAssertNil(cfg.greySteps)
|
||||||
#expect(cfg.singleChannelSteps == nil)
|
XCTAssertNil(cfg.singleChannelSteps)
|
||||||
#expect(cfg.neutralSteps == nil)
|
XCTAssertNil(cfg.neutralSteps)
|
||||||
#expect(cfg.totalInkLimit == nil)
|
XCTAssertNil(cfg.totalInkLimit)
|
||||||
#expect(cfg.darkEmphasis == nil)
|
XCTAssertNil(cfg.darkEmphasis)
|
||||||
#expect(cfg.devicePower == nil)
|
XCTAssertNil(cfg.devicePower)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Custom page and FWA survive a config round-trip")
|
func testRoundTripConfigs() {
|
||||||
func roundTripConfigs() {
|
|
||||||
var preset = PresetCatalog.highQualityCMYK
|
var preset = PresetCatalog.highQualityCMYK
|
||||||
preset.pageSize = "210x297"
|
preset.pageSize = "210x297"
|
||||||
preset.colprofFwa = "D50"
|
preset.colprofFwa = "D50"
|
||||||
@@ -268,9 +237,9 @@ struct PresetMappingTests {
|
|||||||
preset: preset, basename: "job", workingDirectory: nil, calibrationFile: nil
|
preset: preset, basename: "job", workingDirectory: nil, calibrationFile: nil
|
||||||
)
|
)
|
||||||
let colprof = ColprofConfig(preset: preset, basename: "job", workingDirectory: nil)
|
let colprof = ColprofConfig(preset: preset, basename: "job", workingDirectory: nil)
|
||||||
#expect(printtarg.pageSize == .custom)
|
XCTAssertEqual(printtarg.pageSize, .custom)
|
||||||
#expect(printtarg.customPageWidth == 210)
|
XCTAssertEqual(printtarg.customPageWidth, 210)
|
||||||
#expect(colprof.fwa == "D50")
|
XCTAssertEqual(colprof.fwa, "D50")
|
||||||
let back = ProfilingPreset(
|
let back = ProfilingPreset(
|
||||||
id: preset.id,
|
id: preset.id,
|
||||||
name: preset.name,
|
name: preset.name,
|
||||||
@@ -281,15 +250,14 @@ struct PresetMappingTests {
|
|||||||
calibrationFile: preset.calibrationFile,
|
calibrationFile: preset.calibrationFile,
|
||||||
applyCalibration: preset.applyCalibration
|
applyCalibration: preset.applyCalibration
|
||||||
)
|
)
|
||||||
#expect(back.dpi == preset.dpi)
|
XCTAssertEqual(back.dpi, preset.dpi)
|
||||||
#expect(back.colourSpace == "cmyk")
|
XCTAssertEqual(back.colourSpace, "cmyk")
|
||||||
#expect(back.pageSize == "210x297")
|
XCTAssertEqual(back.pageSize, "210x297")
|
||||||
#expect(back.colprofFwa == "D50")
|
XCTAssertEqual(back.colprofFwa, "D50")
|
||||||
#expect(back.greySteps == nil)
|
XCTAssertNil(back.greySteps)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Full preset round-trips through all three configs with every field asserted")
|
func testFullRoundTrip() {
|
||||||
func fullRoundTrip() {
|
|
||||||
let preset = ProfilingPreset(
|
let preset = ProfilingPreset(
|
||||||
id: "custom-full",
|
id: "custom-full",
|
||||||
name: "Full",
|
name: "Full",
|
||||||
@@ -328,21 +296,21 @@ struct PresetMappingTests {
|
|||||||
)
|
)
|
||||||
|
|
||||||
let targen = TargenConfig(preset: preset, basename: "j", workingDirectory: nil)
|
let targen = TargenConfig(preset: preset, basename: "j", workingDirectory: nil)
|
||||||
#expect(targen.colourSpace == .cmyk)
|
XCTAssertEqual(targen.colourSpace, .cmyk)
|
||||||
#expect(targen.patchCount == 1500)
|
XCTAssertEqual(targen.patchCount, 1500)
|
||||||
#expect(targen.whitePatches == 6)
|
XCTAssertEqual(targen.whitePatches, 6)
|
||||||
#expect(targen.blackPatches == 8)
|
XCTAssertEqual(targen.blackPatches, 8)
|
||||||
#expect(targen.greySteps == 9)
|
XCTAssertEqual(targen.greySteps, 9)
|
||||||
#expect(targen.singleChannelSteps == 7)
|
XCTAssertEqual(targen.singleChannelSteps, 7)
|
||||||
#expect(targen.neutralSteps == 4)
|
XCTAssertEqual(targen.neutralSteps, 4)
|
||||||
#expect(targen.neutralConcentration == 0.7)
|
XCTAssertEqual(targen.neutralConcentration, 0.7)
|
||||||
#expect(targen.preconditioningProfile == "/tmp/pre.icm")
|
XCTAssertEqual(targen.preconditioningProfile, "/tmp/pre.icm")
|
||||||
#expect(targen.ofpsHighQuality == true)
|
XCTAssertEqual(targen.ofpsHighQuality, true)
|
||||||
#expect(targen.ofpsAdaptation == 0.2)
|
XCTAssertEqual(targen.ofpsAdaptation, 0.2)
|
||||||
#expect(targen.fullSpreadAlgorithm == .uniformRandom)
|
XCTAssertEqual(targen.fullSpreadAlgorithm, .uniformRandom)
|
||||||
#expect(targen.totalInkLimit == 280)
|
XCTAssertEqual(targen.totalInkLimit, 280)
|
||||||
#expect(targen.darkEmphasis == 1.3)
|
XCTAssertEqual(targen.darkEmphasis, 1.3)
|
||||||
#expect(targen.devicePower == 1.2)
|
XCTAssertEqual(targen.devicePower, 1.2)
|
||||||
|
|
||||||
let printtarg = PrinttargConfig(
|
let printtarg = PrinttargConfig(
|
||||||
preset: preset,
|
preset: preset,
|
||||||
@@ -350,25 +318,25 @@ struct PresetMappingTests {
|
|||||||
workingDirectory: nil,
|
workingDirectory: nil,
|
||||||
calibrationFile: preset.calibrationFile
|
calibrationFile: preset.calibrationFile
|
||||||
)
|
)
|
||||||
#expect(printtarg.instrument == .p3)
|
XCTAssertEqual(printtarg.instrument, .p3)
|
||||||
#expect(printtarg.pageSize == .custom)
|
XCTAssertEqual(printtarg.pageSize, .custom)
|
||||||
#expect(printtarg.customPageWidth == 250)
|
XCTAssertEqual(printtarg.customPageWidth, 250)
|
||||||
#expect(printtarg.customPageHeight == 300)
|
XCTAssertEqual(printtarg.customPageHeight, 300)
|
||||||
#expect(printtarg.bitDepth == .sixteen)
|
XCTAssertEqual(printtarg.bitDepth, .sixteen)
|
||||||
#expect(printtarg.dpi == 360)
|
XCTAssertEqual(printtarg.dpi, 360)
|
||||||
#expect(printtarg.layoutOrder == .customSeed)
|
XCTAssertEqual(printtarg.layoutOrder, .customSeed)
|
||||||
#expect(printtarg.customSeed == 42)
|
XCTAssertEqual(printtarg.customSeed, 42)
|
||||||
#expect(printtarg.calibrationFile == "/tmp/a.cal")
|
XCTAssertEqual(printtarg.calibrationFile, "/tmp/a.cal")
|
||||||
|
|
||||||
let colprof = ColprofConfig(preset: preset, basename: "j", workingDirectory: nil)
|
let colprof = ColprofConfig(preset: preset, basename: "j", workingDirectory: nil)
|
||||||
#expect(colprof.algorithm == "x")
|
XCTAssertEqual(colprof.algorithm, "x")
|
||||||
#expect(colprof.quality == "u")
|
XCTAssertEqual(colprof.quality, "u")
|
||||||
#expect(colprof.intent == "p")
|
XCTAssertEqual(colprof.intent, "p")
|
||||||
#expect(colprof.fwa == "D65")
|
XCTAssertEqual(colprof.fwa, "D65")
|
||||||
#expect(colprof.illuminant == "D65")
|
XCTAssertEqual(colprof.illuminant, "D65")
|
||||||
#expect(colprof.observer == "1931_2")
|
XCTAssertEqual(colprof.observer, "1931_2")
|
||||||
#expect(colprof.inputViewingCond == "D50_2")
|
XCTAssertEqual(colprof.inputViewingCond, "D50_2")
|
||||||
#expect(colprof.outputViewingCond == "D65_2")
|
XCTAssertEqual(colprof.outputViewingCond, "D65_2")
|
||||||
|
|
||||||
let back = ProfilingPreset(
|
let back = ProfilingPreset(
|
||||||
id: preset.id,
|
id: preset.id,
|
||||||
@@ -380,117 +348,126 @@ struct PresetMappingTests {
|
|||||||
calibrationFile: preset.calibrationFile,
|
calibrationFile: preset.calibrationFile,
|
||||||
applyCalibration: preset.applyCalibration
|
applyCalibration: preset.applyCalibration
|
||||||
)
|
)
|
||||||
#expect(back == preset)
|
XCTAssertEqual(back, preset)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Every full-spread algorithm round-trips", arguments: [
|
func testFullSpreadAlgorithms() {
|
||||||
("ofps", FullSpreadAlgorithm.ofps),
|
let cases: [(String, FullSpreadAlgorithm)] = [
|
||||||
("t", .target),
|
("ofps", .ofps),
|
||||||
("r", .random),
|
("t", .target),
|
||||||
("R", .uniformRandom),
|
("r", .random),
|
||||||
("q", .quasiRandom),
|
("R", .uniformRandom),
|
||||||
("Q", .uniformQuasiRandom),
|
("q", .quasiRandom),
|
||||||
("i", .invertedQuasiRandom),
|
("Q", .uniformQuasiRandom),
|
||||||
("I", .invertedUniformQuasiRandom)
|
("i", .invertedQuasiRandom),
|
||||||
])
|
("I", .invertedUniformQuasiRandom)
|
||||||
func fullSpreadAlgorithms(value: String, expected: FullSpreadAlgorithm) {
|
]
|
||||||
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
|
for (value, expected) in cases {
|
||||||
preset.fullSpreadAlgorithm = value
|
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
|
||||||
let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
|
preset.fullSpreadAlgorithm = value
|
||||||
if expected == .ofps {
|
let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
|
||||||
// ofps is the default — no flag emitted, stored value is nil.
|
if expected == .ofps {
|
||||||
#expect(cfg.fullSpreadAlgorithm == nil)
|
// ofps is the default — no flag emitted, stored value is nil.
|
||||||
} else {
|
XCTAssertNil(cfg.fullSpreadAlgorithm)
|
||||||
#expect(cfg.fullSpreadAlgorithm == expected)
|
} else {
|
||||||
|
XCTAssertEqual(cfg.fullSpreadAlgorithm, expected)
|
||||||
|
}
|
||||||
|
let back = ProfilingPreset(
|
||||||
|
id: "x", name: "n", description: "",
|
||||||
|
targen: cfg,
|
||||||
|
printtarg: PrinttargConfig(
|
||||||
|
preset: preset, basename: "t",
|
||||||
|
workingDirectory: nil, calibrationFile: nil
|
||||||
|
),
|
||||||
|
colprof: ColprofConfig(preset: preset, basename: "t", workingDirectory: nil),
|
||||||
|
calibrationFile: nil,
|
||||||
|
applyCalibration: nil
|
||||||
|
)
|
||||||
|
XCTAssertEqual(back.fullSpreadAlgorithm, value)
|
||||||
}
|
}
|
||||||
let back = ProfilingPreset(
|
|
||||||
id: "x", name: "n", description: "",
|
|
||||||
targen: cfg,
|
|
||||||
printtarg: PrinttargConfig(
|
|
||||||
preset: preset, basename: "t",
|
|
||||||
workingDirectory: nil, calibrationFile: nil
|
|
||||||
),
|
|
||||||
colprof: ColprofConfig(preset: preset, basename: "t", workingDirectory: nil),
|
|
||||||
calibrationFile: nil,
|
|
||||||
applyCalibration: nil
|
|
||||||
)
|
|
||||||
#expect(back.fullSpreadAlgorithm == value)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Explicit ofpsHighQuality=false is preserved, distinct from nil")
|
func testOfpsHighQualityFalse() {
|
||||||
func ofpsHighQualityFalse() {
|
|
||||||
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
|
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
|
||||||
preset.ofpsHighQuality = false
|
preset.ofpsHighQuality = false
|
||||||
let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
|
let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
|
||||||
#expect(cfg.ofpsHighQuality == false)
|
XCTAssertEqual(cfg.ofpsHighQuality, false)
|
||||||
|
|
||||||
preset.ofpsHighQuality = nil
|
preset.ofpsHighQuality = nil
|
||||||
let nilCfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
|
let nilCfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
|
||||||
#expect(nilCfg.ofpsHighQuality == nil)
|
XCTAssertNil(nilCfg.ofpsHighQuality)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("noRandomize/seed layout mapping rules", arguments: [
|
func testLayoutMapping() {
|
||||||
(true, nil, LayoutOrder.raster, 1),
|
let cases: [(Bool?, Int?, LayoutOrder, Int)] = [
|
||||||
(true, 7, .raster, 7),
|
(true, nil, .raster, 1),
|
||||||
(false, nil, .deterministic, 1),
|
(true, 7, .raster, 7),
|
||||||
(false, 1, .deterministic, 1),
|
(false, nil, .deterministic, 1),
|
||||||
(nil, 1, .deterministic, 1),
|
(false, 1, .deterministic, 1),
|
||||||
(false, 5, .customSeed, 5)
|
(nil, 1, .deterministic, 1),
|
||||||
] as [(Bool?, Int?, LayoutOrder, Int)])
|
(false, 5, .customSeed, 5)
|
||||||
func layoutMapping(noRandomize: Bool?, seed: Int?, layout: LayoutOrder, expectedSeed: Int) {
|
]
|
||||||
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
|
for (noRandomize, seed, layout, expectedSeed) in cases {
|
||||||
preset.noRandomize = noRandomize
|
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
|
||||||
preset.randomSeed = seed
|
preset.noRandomize = noRandomize
|
||||||
let cfg = PrinttargConfig(
|
preset.randomSeed = seed
|
||||||
preset: preset, basename: "t",
|
let cfg = PrinttargConfig(
|
||||||
workingDirectory: nil, calibrationFile: nil
|
preset: preset, basename: "t",
|
||||||
)
|
workingDirectory: nil, calibrationFile: nil
|
||||||
#expect(cfg.layoutOrder == layout)
|
)
|
||||||
#expect(cfg.customSeed == expectedSeed)
|
XCTAssertEqual(cfg.layoutOrder, layout)
|
||||||
|
XCTAssertEqual(cfg.customSeed, expectedSeed)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Custom page fallback matrix", arguments: [
|
func testCustomPageFallback() {
|
||||||
("250x300", PageSize.custom, 250.0, 300.0),
|
let cases: [(String, PageSize, Double, Double)] = [
|
||||||
("50x50", .custom, 50.0, 50.0),
|
("250x300", .custom, 250.0, 300.0),
|
||||||
("foo", .a4, 210.0, 297.0),
|
("50x50", .custom, 50.0, 50.0),
|
||||||
("30x40", .a4, 210.0, 297.0),
|
("foo", .a4, 210.0, 297.0),
|
||||||
("210x", .a4, 210.0, 297.0)
|
("30x40", .a4, 210.0, 297.0),
|
||||||
] as [(String, PageSize, Double, Double)])
|
("210x", .a4, 210.0, 297.0)
|
||||||
func customPageFallback(raw: String, page: PageSize, w: Double, h: Double) {
|
]
|
||||||
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
|
for (raw, page, w, h) in cases {
|
||||||
preset.pageSize = raw
|
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
|
||||||
let cfg = PrinttargConfig(
|
preset.pageSize = raw
|
||||||
preset: preset, basename: "t",
|
let cfg = PrinttargConfig(
|
||||||
workingDirectory: nil, calibrationFile: nil
|
preset: preset, basename: "t",
|
||||||
)
|
workingDirectory: nil, calibrationFile: nil
|
||||||
#expect(cfg.pageSize == page)
|
)
|
||||||
#expect(cfg.customPageWidth == w)
|
XCTAssertEqual(cfg.pageSize, page)
|
||||||
#expect(cfg.customPageHeight == h)
|
XCTAssertEqual(cfg.customPageWidth, w)
|
||||||
|
XCTAssertEqual(cfg.customPageHeight, h)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("FWA preset value → selection matrix", arguments: [
|
func testFwaToSelection() {
|
||||||
(nil, ColprofFwaSelection.none),
|
let cases: [(String?, ColprofFwaSelection)] = [
|
||||||
("none", .none),
|
(nil, .none),
|
||||||
("NONE", .none),
|
("none", .none),
|
||||||
("", .empty),
|
("NONE", .none),
|
||||||
("D50", .D50),
|
("", .empty),
|
||||||
("d50", .D50),
|
("D50", .D50),
|
||||||
("D65", .D65),
|
("d50", .D50),
|
||||||
("d65", .D65),
|
("D65", .D65),
|
||||||
("/tmp/fwa.sp", .custom)
|
("d65", .D65),
|
||||||
] as [(String?, ColprofFwaSelection)])
|
("/tmp/fwa.sp", .custom)
|
||||||
func fwaToSelection(raw: String?, expected: ColprofFwaSelection) {
|
]
|
||||||
#expect(ColprofFwaSelection(presetValue: raw) == expected)
|
for (raw, expected) in cases {
|
||||||
|
XCTAssertEqual(ColprofFwaSelection(presetValue: raw), expected)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("FWA selection → preset value matrix", arguments: [
|
func testFwaToPresetValue() {
|
||||||
(ColprofFwaSelection.none, nil),
|
let cases: [(ColprofFwaSelection, String?)] = [
|
||||||
(.empty, ""),
|
(.none, nil),
|
||||||
(.D50, "D50"),
|
(.empty, ""),
|
||||||
(.D65, "D65"),
|
(.D50, "D50"),
|
||||||
(.custom, "/tmp/fwa.sp")
|
(.D65, "D65"),
|
||||||
] as [(ColprofFwaSelection, String?)])
|
(.custom, "/tmp/fwa.sp")
|
||||||
func fwaToPresetValue(selection: ColprofFwaSelection, expected: String?) {
|
]
|
||||||
#expect(selection.presetValue(customPath: "/tmp/fwa.sp") == expected)
|
for (selection, expected) in cases {
|
||||||
|
XCTAssertEqual(selection.presetValue(customPath: "/tmp/fwa.sp"), expected)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,19 @@
|
|||||||
import Testing
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import XCTest
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
@testable import ICCery
|
@testable import ICCery
|
||||||
|
|
||||||
/// Issue #82 — preset application through the live view models, under an
|
/// Issue #82 — preset application through the live view models, under an
|
||||||
/// isolated `TestAppEnvironment` (temp stores, fresh ProcessManager).
|
/// isolated `TestAppEnvironment` (temp stores, fresh ProcessManager).
|
||||||
@Suite("PresetViewModelMapping")
|
|
||||||
@MainActor
|
@MainActor
|
||||||
struct PresetViewModelMappingTests {
|
final class PresetViewModelMappingTests: XCTestCase {
|
||||||
|
|
||||||
private func makeWorkflow() throws -> (TestAppEnvironment, TargetWorkflowViewModel) {
|
private func makeWorkflow() throws -> (TestAppEnvironment, TargetWorkflowViewModel) {
|
||||||
let env = try TestAppEnvironment.make()
|
let env = try TestAppEnvironment.make()
|
||||||
return (env, TargetWorkflowViewModel(environment: env.environment))
|
return (env, TargetWorkflowViewModel(environment: env.environment))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Applying a nil-FWA preset after a custom FWA clears the stale path")
|
func testNilFwaClearsCustomPath() throws {
|
||||||
func nilFwaClearsCustomPath() throws {
|
|
||||||
let (env, vm) = try makeWorkflow()
|
let (env, vm) = try makeWorkflow()
|
||||||
defer { env.cleanup() }
|
defer { env.cleanup() }
|
||||||
|
|
||||||
@@ -24,18 +22,17 @@ struct PresetViewModelMappingTests {
|
|||||||
colprofFwa: "/tmp/fwa.sp"
|
colprofFwa: "/tmp/fwa.sp"
|
||||||
)
|
)
|
||||||
vm.applyPreset(customPreset)
|
vm.applyPreset(customPreset)
|
||||||
#expect(vm.profile.fwaSelection == .custom)
|
XCTAssertEqual(vm.profile.fwaSelection, .custom)
|
||||||
#expect(vm.profile.fwaCustomPath == "/tmp/fwa.sp")
|
XCTAssertEqual(vm.profile.fwaCustomPath, "/tmp/fwa.sp")
|
||||||
|
|
||||||
customPreset.colprofFwa = nil
|
customPreset.colprofFwa = nil
|
||||||
vm.applyPreset(customPreset)
|
vm.applyPreset(customPreset)
|
||||||
#expect(vm.profile.fwaSelection == .none)
|
XCTAssertEqual(vm.profile.fwaSelection, .none)
|
||||||
#expect(vm.profile.fwaCustomPath == "")
|
XCTAssertEqual(vm.profile.fwaCustomPath, "")
|
||||||
#expect(vm.profile.fwaValue == nil)
|
XCTAssertNil(vm.profile.fwaValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Custom FWA preset path survives the round-trip to colprof_fwa")
|
func testCustomFwaRoundTrip() throws {
|
||||||
func customFwaRoundTrip() throws {
|
|
||||||
let (env, vm) = try makeWorkflow()
|
let (env, vm) = try makeWorkflow()
|
||||||
defer { env.cleanup() }
|
defer { env.cleanup() }
|
||||||
|
|
||||||
@@ -44,13 +41,12 @@ struct PresetViewModelMappingTests {
|
|||||||
colprofFwa: "/tmp/other.sp"
|
colprofFwa: "/tmp/other.sp"
|
||||||
)
|
)
|
||||||
vm.applyPreset(preset)
|
vm.applyPreset(preset)
|
||||||
#expect(vm.profile.fwaSelection == .custom)
|
XCTAssertEqual(vm.profile.fwaSelection, .custom)
|
||||||
#expect(vm.profile.fwaCustomPath == "/tmp/other.sp")
|
XCTAssertEqual(vm.profile.fwaCustomPath, "/tmp/other.sp")
|
||||||
#expect(vm.profile.fwaValue == "/tmp/other.sp")
|
XCTAssertEqual(vm.profile.fwaValue, "/tmp/other.sp")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Preset calibration reaches Stage 2 instead of stale live state")
|
func testPresetCalibrationReachesStage2() throws {
|
||||||
func presetCalibrationReachesStage2() throws {
|
|
||||||
let (env, vm) = try makeWorkflow()
|
let (env, vm) = try makeWorkflow()
|
||||||
defer { env.cleanup() }
|
defer { env.cleanup() }
|
||||||
|
|
||||||
@@ -65,13 +61,12 @@ struct PresetViewModelMappingTests {
|
|||||||
)
|
)
|
||||||
vm.applyPreset(preset)
|
vm.applyPreset(preset)
|
||||||
|
|
||||||
#expect(vm.profile.applyCalibration)
|
XCTAssertTrue(vm.profile.applyCalibration)
|
||||||
#expect(vm.profile.calibrationFile == "/tmp/preset.cal")
|
XCTAssertEqual(vm.profile.calibrationFile, "/tmp/preset.cal")
|
||||||
#expect(vm.buildPrinttargConfig().calibrationFile == "/tmp/preset.cal")
|
XCTAssertEqual(vm.buildPrinttargConfig().calibrationFile, "/tmp/preset.cal")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Preset with calibration disabled clears Stage 2 calibration")
|
func testDisabledCalibrationClearsStage2() throws {
|
||||||
func disabledCalibrationClearsStage2() throws {
|
|
||||||
let (env, vm) = try makeWorkflow()
|
let (env, vm) = try makeWorkflow()
|
||||||
defer { env.cleanup() }
|
defer { env.cleanup() }
|
||||||
|
|
||||||
@@ -85,12 +80,11 @@ struct PresetViewModelMappingTests {
|
|||||||
)
|
)
|
||||||
vm.applyPreset(preset)
|
vm.applyPreset(preset)
|
||||||
|
|
||||||
#expect(!vm.profile.applyCalibration)
|
XCTAssertFalse(vm.profile.applyCalibration)
|
||||||
#expect(vm.buildPrinttargConfig().calibrationFile == nil)
|
XCTAssertNil(vm.buildPrinttargConfig().calibrationFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Preset Stage 1/2 form fields apply to the live form")
|
func testFormFieldsApply() throws {
|
||||||
func formFieldsApply() throws {
|
|
||||||
let (env, vm) = try makeWorkflow()
|
let (env, vm) = try makeWorkflow()
|
||||||
defer { env.cleanup() }
|
defer { env.cleanup() }
|
||||||
|
|
||||||
@@ -106,22 +100,22 @@ struct PresetViewModelMappingTests {
|
|||||||
)
|
)
|
||||||
vm.applyPreset(preset)
|
vm.applyPreset(preset)
|
||||||
|
|
||||||
#expect(vm.colourSpace == .cmyk)
|
XCTAssertEqual(vm.colourSpace, .cmyk)
|
||||||
#expect(vm.effectivePatchCount == 1500)
|
XCTAssertEqual(vm.effectivePatchCount, 1500)
|
||||||
#expect(vm.whitePatches == 6)
|
XCTAssertEqual(vm.whitePatches, 6)
|
||||||
#expect(vm.blackPatches == 8)
|
XCTAssertEqual(vm.blackPatches, 8)
|
||||||
#expect(vm.greyStepsEnabled && vm.greySteps == 9)
|
XCTAssertTrue(vm.greyStepsEnabled && vm.greySteps == 9)
|
||||||
#expect(vm.algorithm == .random)
|
XCTAssertEqual(vm.algorithm, .random)
|
||||||
#expect(vm.tiffDpi == 150)
|
XCTAssertEqual(vm.tiffDpi, 150)
|
||||||
#expect(vm.pageSize == .custom)
|
XCTAssertEqual(vm.pageSize, .custom)
|
||||||
#expect(vm.customPageW == 250 && vm.customPageH == 300)
|
XCTAssertTrue(vm.customPageW == 250 && vm.customPageH == 300)
|
||||||
#expect(vm.selectedPresetID == "c-form")
|
XCTAssertEqual(vm.selectedPresetID, "c-form")
|
||||||
|
|
||||||
// Disabled advanced controls stay nil in the snapshot, not
|
// Disabled advanced controls stay nil in the snapshot, not
|
||||||
// numeric sentinels.
|
// numeric sentinels.
|
||||||
preset.greySteps = nil
|
preset.greySteps = nil
|
||||||
vm.applyPreset(preset)
|
vm.applyPreset(preset)
|
||||||
#expect(!vm.greyStepsEnabled)
|
XCTAssertFalse(vm.greyStepsEnabled)
|
||||||
#expect(vm.buildTargenConfig().greySteps == nil)
|
XCTAssertNil(vm.buildTargenConfig().greySteps)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import Testing
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import XCTest
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
@testable import ICCery
|
@testable import ICCery
|
||||||
|
|
||||||
/// Issue 13 — panel outcome mapping (cancel → nil, ok → result).
|
/// Issue 13 — panel outcome mapping (cancel → nil, ok → result).
|
||||||
/// The real `NSPrintPanel` is never run in tests; these exercise the
|
/// The real `NSPrintPanel` is never run in tests; these exercise the
|
||||||
/// `UITestHooks` seam the UI tests rely on.
|
/// `UITestHooks` seam the UI tests rely on.
|
||||||
@Suite("PrintPanelStub")
|
final class PrintPanelStubTests: XCTestCase {
|
||||||
struct PrintPanelStubTests {
|
|
||||||
|
|
||||||
private func withEnv(
|
private func withEnv(
|
||||||
_ vars: [String: String?],
|
_ vars: [String: String?],
|
||||||
@@ -28,19 +27,17 @@ struct PrintPanelStubTests {
|
|||||||
try body()
|
try body()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Cancel returns nil — not an error")
|
func testCancelIsNil() throws {
|
||||||
func cancelIsNil() throws {
|
|
||||||
try withEnv([
|
try withEnv([
|
||||||
"ICCERY_UI_TESTING": "1",
|
"ICCERY_UI_TESTING": "1",
|
||||||
"ICCERY_TEST_PRINT_PANEL": "cancel",
|
"ICCERY_TEST_PRINT_PANEL": "cancel",
|
||||||
]) {
|
]) {
|
||||||
#expect(UITestHooks.printPanelStubbed)
|
XCTAssertTrue(UITestHooks.printPanelStubbed)
|
||||||
#expect(UITestHooks.printPanelResult(forQueue: "q") == nil)
|
XCTAssertNil(UITestHooks.printPanelResult(forQueue: "q"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("OK returns captured options + selected printer")
|
func testOkResult() throws {
|
||||||
func okResult() throws {
|
|
||||||
try withEnv([
|
try withEnv([
|
||||||
"ICCERY_UI_TESTING": "1",
|
"ICCERY_UI_TESTING": "1",
|
||||||
"ICCERY_TEST_PRINT_PANEL": "ok",
|
"ICCERY_TEST_PRINT_PANEL": "ok",
|
||||||
@@ -48,15 +45,14 @@ struct PrintPanelStubTests {
|
|||||||
"ICCERY_TEST_PANEL_PRINTER": "Other_Queue",
|
"ICCERY_TEST_PANEL_PRINTER": "Other_Queue",
|
||||||
]) {
|
]) {
|
||||||
let result = UITestHooks.printPanelResult(forQueue: "q")
|
let result = UITestHooks.printPanelResult(forQueue: "q")
|
||||||
#expect(result?.selectedPrinter == "Other_Queue")
|
XCTAssertEqual(result?.selectedPrinter, "Other_Queue")
|
||||||
#expect(result?.options.cupsOptions == "MediaType=Photo InputSlot=Rear")
|
XCTAssertEqual(result?.options.cupsOptions, "MediaType=Photo InputSlot=Rear")
|
||||||
#expect(result?.options.mediaType == "Photo")
|
XCTAssertEqual(result?.options.mediaType, "Photo")
|
||||||
#expect(result?.options.ppdUncorrectedPassthrough == true)
|
XCTAssertEqual(result?.options.ppdUncorrectedPassthrough, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("OK defaults selected printer to the opened queue")
|
func testOkDefaultsPrinter() throws {
|
||||||
func okDefaultsPrinter() throws {
|
|
||||||
try withEnv([
|
try withEnv([
|
||||||
"ICCERY_UI_TESTING": "1",
|
"ICCERY_UI_TESTING": "1",
|
||||||
"ICCERY_TEST_PRINT_PANEL": "ok",
|
"ICCERY_TEST_PRINT_PANEL": "ok",
|
||||||
@@ -64,8 +60,8 @@ struct PrintPanelStubTests {
|
|||||||
"ICCERY_TEST_PANEL_PRINTER": nil,
|
"ICCERY_TEST_PANEL_PRINTER": nil,
|
||||||
]) {
|
]) {
|
||||||
let result = UITestHooks.printPanelResult(forQueue: "My_Queue")
|
let result = UITestHooks.printPanelResult(forQueue: "My_Queue")
|
||||||
#expect(result?.selectedPrinter == "My_Queue")
|
XCTAssertEqual(result?.selectedPrinter, "My_Queue")
|
||||||
#expect(result?.options.cupsOptions == nil)
|
XCTAssertNil(result?.options.cupsOptions)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import Testing
|
|
||||||
import XCTest
|
import XCTest
|
||||||
import Foundation
|
import Foundation
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
@@ -257,8 +256,7 @@ final class PrinttargManifestTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suite("ArgyllRunner Printtarg")
|
final class ArgyllRunnerPrinttargTests: XCTestCase {
|
||||||
struct ArgyllRunnerPrinttargTests {
|
|
||||||
|
|
||||||
private func makeFixture(_ body: String, name: String = "printtarg") throws -> URL {
|
private func makeFixture(_ body: String, name: String = "printtarg") throws -> URL {
|
||||||
let dir = FileManager.default.temporaryDirectory
|
let dir = FileManager.default.temporaryDirectory
|
||||||
@@ -310,8 +308,7 @@ struct ArgyllRunnerPrinttargTests {
|
|||||||
try Data(bytes).write(to: url)
|
try Data(bytes).write(to: url)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Successful printtarg emits .ti2 + manifest + PNG previews")
|
func testSuccess() async throws {
|
||||||
func success() async throws {
|
|
||||||
let dir = try makeFixture("""
|
let dir = try makeFixture("""
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
last=""
|
last=""
|
||||||
@@ -330,18 +327,17 @@ struct ArgyllRunnerPrinttargTests {
|
|||||||
processManager: ProcessManager(), binaryResolver: resolver)
|
processManager: ProcessManager(), binaryResolver: resolver)
|
||||||
let config = PrinttargConfig(basename: "pt", workingDirectory: dir)
|
let config = PrinttargConfig(basename: "pt", workingDirectory: dir)
|
||||||
let result = try await runner.runPrinttarg(config: config)
|
let result = try await runner.runPrinttarg(config: config)
|
||||||
#expect(result.ti2URL.lastPathComponent == "pt.ti2")
|
XCTAssertEqual(result.ti2URL.lastPathComponent, "pt.ti2")
|
||||||
#expect(result.manifest.pages.count == 1)
|
XCTAssertEqual(result.manifest.pages.count, 1)
|
||||||
#expect(result.pages.count == 1)
|
XCTAssertEqual(result.pages.count, 1)
|
||||||
let png = result.pages[0].previewPNG
|
let png = result.pages[0].previewPNG
|
||||||
#expect(png != nil)
|
XCTAssertNotNil(png)
|
||||||
if let png {
|
if let png {
|
||||||
#expect(png.prefix(8) == Data([0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A]))
|
XCTAssertEqual(png.prefix(8), Data([0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Non-zero exit throws toolFailed and stays on stage")
|
func testFailure() async throws {
|
||||||
func failure() async throws {
|
|
||||||
let dir = try makeFixture("""
|
let dir = try makeFixture("""
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
echo "oops" >&2
|
echo "oops" >&2
|
||||||
@@ -351,15 +347,16 @@ struct ArgyllRunnerPrinttargTests {
|
|||||||
let runner = ArgyllRunner(
|
let runner = ArgyllRunner(
|
||||||
processManager: ProcessManager(),
|
processManager: ProcessManager(),
|
||||||
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir))
|
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir))
|
||||||
await #expect(throws: ArgyllRunnerError.toolFailed(
|
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
|
||||||
tool: "printtarg", code: 3, logs: ["oops"])) {
|
|
||||||
try await runner.runPrinttarg(
|
try await runner.runPrinttarg(
|
||||||
config: PrinttargConfig(basename: "x", workingDirectory: dir))
|
config: PrinttargConfig(basename: "x", workingDirectory: dir))
|
||||||
|
} errorHandler: { error in
|
||||||
|
XCTAssertEqual(error, .toolFailed(
|
||||||
|
tool: "printtarg", code: 3, logs: ["oops"]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Exit 0 without manifest → malformedManifest")
|
func testNoManifest() async throws {
|
||||||
func noManifest() async throws {
|
|
||||||
let dir = try makeFixture("""
|
let dir = try makeFixture("""
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
last=""
|
last=""
|
||||||
@@ -372,14 +369,13 @@ struct ArgyllRunnerPrinttargTests {
|
|||||||
let runner = ArgyllRunner(
|
let runner = ArgyllRunner(
|
||||||
processManager: ProcessManager(),
|
processManager: ProcessManager(),
|
||||||
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir))
|
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir))
|
||||||
await #expect(throws: ArgyllRunnerError.self) {
|
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
|
||||||
try await runner.runPrinttarg(
|
try await runner.runPrinttarg(
|
||||||
config: PrinttargConfig(basename: "x", workingDirectory: dir))
|
config: PrinttargConfig(basename: "x", workingDirectory: dir))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Exit 0 without .ti2 → missingArtefact")
|
func testNoTi2() async throws {
|
||||||
func noTi2() async throws {
|
|
||||||
let dir = try makeFixture("""
|
let dir = try makeFixture("""
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
printf '{\\n"event":"manifest",\\n"pages":[]\\n}\\n'
|
printf '{\\n"event":"manifest",\\n"pages":[]\\n}\\n'
|
||||||
@@ -389,14 +385,13 @@ struct ArgyllRunnerPrinttargTests {
|
|||||||
let runner = ArgyllRunner(
|
let runner = ArgyllRunner(
|
||||||
processManager: ProcessManager(),
|
processManager: ProcessManager(),
|
||||||
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir))
|
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir))
|
||||||
await #expect(throws: ArgyllRunnerError.self) {
|
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
|
||||||
try await runner.runPrinttarg(
|
try await runner.runPrinttarg(
|
||||||
config: PrinttargConfig(basename: "x", workingDirectory: dir))
|
config: PrinttargConfig(basename: "x", workingDirectory: dir))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Deterministic config produces byte-identical .ti2")
|
func testDeterminism() async throws {
|
||||||
func determinism() async throws {
|
|
||||||
let dir = try makeFixture("""
|
let dir = try makeFixture("""
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
last=""
|
last=""
|
||||||
@@ -416,6 +411,6 @@ struct ArgyllRunnerPrinttargTests {
|
|||||||
config: PrinttargConfig(basename: "b", workingDirectory: dir))
|
config: PrinttargConfig(basename: "b", workingDirectory: dir))
|
||||||
let d1 = try Data(contentsOf: dir.appendingPathComponent("a.ti2"))
|
let d1 = try Data(contentsOf: dir.appendingPathComponent("a.ti2"))
|
||||||
let d2 = try Data(contentsOf: dir.appendingPathComponent("b.ti2"))
|
let d2 = try Data(contentsOf: dir.appendingPathComponent("b.ti2"))
|
||||||
#expect(d1 == d2)
|
XCTAssertEqual(d1, d2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import Testing
|
|
||||||
import XCTest
|
import XCTest
|
||||||
import Foundation
|
import Foundation
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
/// Helpers shared across ProcessManager tests. Fixture binaries are shell
|
/// Helpers shared across ProcessManager tests. Fixture binaries are shell
|
||||||
/// scripts written to a temp dir — no resource bundling required.
|
/// scripts written to a temp dir — no resource bundling required.
|
||||||
@Suite("ProcessManager", .serialized)
|
/// XCTest executes test methods serially by default.
|
||||||
struct ProcessManagerTests {
|
final class ProcessManagerTests: XCTestCase {
|
||||||
|
|
||||||
// MARK: - Fixture plumbing
|
// MARK: - Fixture plumbing
|
||||||
|
|
||||||
@@ -44,7 +43,7 @@ struct ProcessManagerTests {
|
|||||||
if box.finish() { cont.resume(returning: box.events) }
|
if box.finish() { cont.resume(returning: box.events) }
|
||||||
}
|
}
|
||||||
Task {
|
Task {
|
||||||
try? await Task.sleep(for: .seconds(timeout))
|
try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
|
||||||
if box.finish() { cont.resume(returning: box.events) }
|
if box.finish() { cont.resume(returning: box.events) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,7 +84,7 @@ struct ProcessManagerTests {
|
|||||||
let deadline = Date().addingTimeInterval(timeout)
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
while Date() < deadline {
|
while Date() < deadline {
|
||||||
if exitCount(in: box) > 0 { return true }
|
if exitCount(in: box) > 0 { return true }
|
||||||
try? await Task.sleep(for: .milliseconds(10))
|
try? await Task.sleep(nanoseconds: 10_000_000)
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -94,7 +93,7 @@ struct ProcessManagerTests {
|
|||||||
let deadline = Date().addingTimeInterval(timeout)
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
while Date() < deadline {
|
while Date() < deadline {
|
||||||
if FileManager.default.fileExists(atPath: url.path) { return true }
|
if FileManager.default.fileExists(atPath: url.path) { return true }
|
||||||
try? await Task.sleep(for: .milliseconds(10))
|
try? await Task.sleep(nanoseconds: 10_000_000)
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -107,14 +106,14 @@ struct ProcessManagerTests {
|
|||||||
let deadline = Date().addingTimeInterval(timeout)
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
while Date() < deadline {
|
while Date() < deadline {
|
||||||
if await manager.isRunning(id) { return true }
|
if await manager.isRunning(id) { return true }
|
||||||
try? await Task.sleep(for: .milliseconds(10))
|
try? await Task.sleep(nanoseconds: 10_000_000)
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Tests
|
// MARK: - Tests
|
||||||
|
|
||||||
@Test func streamsStdoutAndEmitsExit() async throws {
|
func testStreamsStdoutAndEmitsExit() async throws {
|
||||||
let pm = ProcessManager()
|
let pm = ProcessManager()
|
||||||
let bin = try script("lines.sh", "#!/bin/sh\necho hello\necho world\n")
|
let bin = try script("lines.sh", "#!/bin/sh\necho hello\necho world\n")
|
||||||
async let events = collect(pm, id: "t1")
|
async let events = collect(pm, id: "t1")
|
||||||
@@ -123,21 +122,21 @@ struct ProcessManagerTests {
|
|||||||
let lines = evs.compactMap { e -> String? in
|
let lines = evs.compactMap { e -> String? in
|
||||||
if case .stdout(_, let l) = e { return l }; return nil
|
if case .stdout(_, let l) = e { return l }; return nil
|
||||||
}
|
}
|
||||||
#expect(lines == ["hello", "world"])
|
XCTAssertEqual(lines, ["hello", "world"])
|
||||||
#expect(evs.contains(.exit(id: "t1", code: 0)))
|
XCTAssertTrue(evs.contains(.exit(id: "t1", code: 0)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func routesStderrSeparately() async throws {
|
func testRoutesStderrSeparately() async throws {
|
||||||
let pm = ProcessManager()
|
let pm = ProcessManager()
|
||||||
let bin = try script("err.sh", "#!/bin/sh\necho out\necho oops 1>&2\n")
|
let bin = try script("err.sh", "#!/bin/sh\necho out\necho oops 1>&2\n")
|
||||||
async let evs = collect(pm, id: "t2")
|
async let evs = collect(pm, id: "t2")
|
||||||
try await pm.runStreaming(id: "t2", binary: bin, arguments: [])
|
try await pm.runStreaming(id: "t2", binary: bin, arguments: [])
|
||||||
let events = await evs
|
let events = await evs
|
||||||
#expect(events.contains(.stdout(id: "t2", line: "out")))
|
XCTAssertTrue(events.contains(.stdout(id: "t2", line: "out")))
|
||||||
#expect(events.contains(.stderr(id: "t2", line: "oops")))
|
XCTAssertTrue(events.contains(.stderr(id: "t2", line: "oops")))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func stripsRowColorsJSONPrefix() async throws {
|
func testStripsRowColorsJSONPrefix() async throws {
|
||||||
let pm = ProcessManager()
|
let pm = ProcessManager()
|
||||||
let bin = try script(
|
let bin = try script(
|
||||||
"rows.sh",
|
"rows.sh",
|
||||||
@@ -150,21 +149,22 @@ struct ProcessManagerTests {
|
|||||||
if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) }
|
if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) }
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
#expect(rows == ["{\"row\":1}"])
|
XCTAssertEqual(rows, ["{\"row\":1}"])
|
||||||
#expect(events.contains(.stdout(id: "t3", line: "plain")))
|
XCTAssertTrue(events.contains(.stdout(id: "t3", line: "plain")))
|
||||||
// Prefixed lines must not leak into stdout.
|
// Prefixed lines must not leak into stdout.
|
||||||
#expect(!events.contains(.stdout(id: "t3", line: "ROW_COLORS_JSON: {\"row\":1}")))
|
XCTAssertFalse(events.contains(.stdout(id: "t3", line: "ROW_COLORS_JSON: {\"row\":1}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func unterminatedTailFlushesOnExit() async throws {
|
func testUnterminatedTailFlushesOnExit() async throws {
|
||||||
let pm = ProcessManager()
|
let pm = ProcessManager()
|
||||||
let bin = try script("tail.sh", "#!/bin/sh\nprintf 'no-newline'\n")
|
let bin = try script("tail.sh", "#!/bin/sh\nprintf 'no-newline'\n")
|
||||||
async let evs = collect(pm, id: "t4")
|
async let evs = collect(pm, id: "t4")
|
||||||
try await pm.runStreaming(id: "t4", binary: bin, arguments: [])
|
try await pm.runStreaming(id: "t4", binary: bin, arguments: [])
|
||||||
#expect(await evs.contains(.stdout(id: "t4", line: "no-newline")))
|
let t4SawTail = await evs.contains(.stdout(id: "t4", line: "no-newline"))
|
||||||
|
XCTAssertTrue(t4SawTail)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func stdinRoundTrip() async throws {
|
func testStdinRoundTrip() async throws {
|
||||||
let pm = ProcessManager()
|
let pm = ProcessManager()
|
||||||
// Read two lines then exit naturally — a killed sh would lose its
|
// Read two lines then exit naturally — a killed sh would lose its
|
||||||
// buffered stdio output, which is exactly the chartread pattern.
|
// buffered stdio output, which is exactly the chartread pattern.
|
||||||
@@ -177,21 +177,23 @@ struct ProcessManagerTests {
|
|||||||
try await pm.sendStdin(id: "t5", text: " \n")
|
try await pm.sendStdin(id: "t5", text: " \n")
|
||||||
try await pm.sendStdin(id: "t5", text: "d\n")
|
try await pm.sendStdin(id: "t5", text: "d\n")
|
||||||
let events = await evs
|
let events = await evs
|
||||||
#expect(events.contains(.stdout(id: "t5", line: "got: ")))
|
XCTAssertTrue(events.contains(.stdout(id: "t5", line: "got: ")))
|
||||||
#expect(events.contains(.stdout(id: "t5", line: "got:d")))
|
XCTAssertTrue(events.contains(.stdout(id: "t5", line: "got:d")))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func duplicateIDRejected() async throws {
|
func testDuplicateIDRejected() async throws {
|
||||||
let pm = ProcessManager()
|
let pm = ProcessManager()
|
||||||
let bin = try script("slow.sh", "#!/bin/sh\nsleep 30\n")
|
let bin = try script("slow.sh", "#!/bin/sh\nsleep 30\n")
|
||||||
try await pm.runStreaming(id: "t6", binary: bin, arguments: [])
|
try await pm.runStreaming(id: "t6", binary: bin, arguments: [])
|
||||||
await #expect(throws: ProcessError.duplicateID("t6")) {
|
await assertAsyncThrows(expectedType: ProcessError.self) {
|
||||||
try await pm.runStreaming(id: "t6", binary: bin, arguments: [])
|
try await pm.runStreaming(id: "t6", binary: bin, arguments: [])
|
||||||
|
} errorHandler: { error in
|
||||||
|
XCTAssertEqual(error, .duplicateID("t6"))
|
||||||
}
|
}
|
||||||
await pm.kill(id: "t6")
|
await pm.kill(id: "t6")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func killEmitsExitAndClosesStdin() async throws {
|
func testKillEmitsExitAndClosesStdin() async throws {
|
||||||
let pm = ProcessManager()
|
let pm = ProcessManager()
|
||||||
let bin = try script("slow2.sh", "#!/bin/sh\ncat\n")
|
let bin = try script("slow2.sh", "#!/bin/sh\ncat\n")
|
||||||
async let evs = collect(pm, id: "t7")
|
async let evs = collect(pm, id: "t7")
|
||||||
@@ -200,49 +202,51 @@ struct ProcessManagerTests {
|
|||||||
let events = await evs
|
let events = await evs
|
||||||
// exit emitted exactly once
|
// exit emitted exactly once
|
||||||
let exits = events.filter { if case .exit = $0 { return true }; return false }
|
let exits = events.filter { if case .exit = $0 { return true }; return false }
|
||||||
#expect(exits.count == 1)
|
XCTAssertEqual(exits.count, 1)
|
||||||
await #expect(throws: ProcessError.unknownID("t7")) {
|
await assertAsyncThrows(expectedType: ProcessError.self) {
|
||||||
try await pm.sendStdin(id: "t7", text: "d\n")
|
try await pm.sendStdin(id: "t7", text: "d\n")
|
||||||
|
} errorHandler: { error in
|
||||||
|
XCTAssertEqual(error, .unknownID("t7"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func killAllCountsSignaled() async throws {
|
func testKillAllCountsSignaled() async throws {
|
||||||
let pm = ProcessManager()
|
let pm = ProcessManager()
|
||||||
let bin = try script("slow3.sh", "#!/bin/sh\nsleep 30\n")
|
let bin = try script("slow3.sh", "#!/bin/sh\nsleep 30\n")
|
||||||
try await pm.runStreaming(id: "a", binary: bin, arguments: [])
|
try await pm.runStreaming(id: "a", binary: bin, arguments: [])
|
||||||
try await pm.runStreaming(id: "b", binary: bin, arguments: [])
|
try await pm.runStreaming(id: "b", binary: bin, arguments: [])
|
||||||
let count = await pm.killAll()
|
let count = await pm.killAll()
|
||||||
#expect(count == 2)
|
XCTAssertEqual(count, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func capturedRunReturnsBothStreams() async throws {
|
func testCapturedRunReturnsBothStreams() async throws {
|
||||||
let pm = ProcessManager()
|
let pm = ProcessManager()
|
||||||
let bin = try script("cap.sh", "#!/bin/sh\necho out-data\necho err-data 1>&2\nexit 3\n")
|
let bin = try script("cap.sh", "#!/bin/sh\necho out-data\necho err-data 1>&2\nexit 3\n")
|
||||||
let result = try await pm.runCaptured(id: "cap", binary: bin, arguments: [])
|
let result = try await pm.runCaptured(id: "cap", binary: bin, arguments: [])
|
||||||
#expect(result.stdout.contains("out-data"))
|
XCTAssertTrue(result.stdout.contains("out-data"))
|
||||||
#expect(result.stderr.contains("err-data"))
|
XCTAssertTrue(result.stderr.contains("err-data"))
|
||||||
#expect(result.exitCode == 3)
|
XCTAssertEqual(result.exitCode, 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func capturedRunFastExit() async throws {
|
func testCapturedRunFastExit() async throws {
|
||||||
let pm = ProcessManager()
|
let pm = ProcessManager()
|
||||||
let bin = try script("fast.sh", "#!/bin/sh\nexit 7\n")
|
let bin = try script("fast.sh", "#!/bin/sh\nexit 7\n")
|
||||||
let result = try await pm.runCaptured(id: "fast", binary: bin, arguments: [])
|
let result = try await pm.runCaptured(id: "fast", binary: bin, arguments: [])
|
||||||
#expect(result.exitCode == 7)
|
XCTAssertEqual(result.exitCode, 7)
|
||||||
#expect(result.stdout == "")
|
XCTAssertEqual(result.stdout, "")
|
||||||
#expect(result.stderr == "")
|
XCTAssertEqual(result.stderr, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func capturedRunStderrOnly() async throws {
|
func testCapturedRunStderrOnly() async throws {
|
||||||
let pm = ProcessManager()
|
let pm = ProcessManager()
|
||||||
let bin = try script("stderr-only.sh", "#!/bin/sh\necho 'mock lp failure' 1>&2\nexit 1\n")
|
let bin = try script("stderr-only.sh", "#!/bin/sh\necho 'mock lp failure' 1>&2\nexit 1\n")
|
||||||
let result = try await pm.runCaptured(id: "stderr-only", binary: bin, arguments: [])
|
let result = try await pm.runCaptured(id: "stderr-only", binary: bin, arguments: [])
|
||||||
#expect(result.exitCode == 1)
|
XCTAssertEqual(result.exitCode, 1)
|
||||||
#expect(result.stdout == "")
|
XCTAssertEqual(result.stdout, "")
|
||||||
#expect(result.stderr.contains("mock lp failure"))
|
XCTAssertTrue(result.stderr.contains("mock lp failure"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func capturedRunDoesNotDeadlockOnLargeOutput() async throws {
|
func testCapturedRunDoesNotDeadlockOnLargeOutput() async throws {
|
||||||
let pm = ProcessManager()
|
let pm = ProcessManager()
|
||||||
// 5000 lines each stream exceeds the 64 KiB pipe buffer.
|
// 5000 lines each stream exceeds the 64 KiB pipe buffer.
|
||||||
let bin = try script(
|
let bin = try script(
|
||||||
@@ -250,26 +254,29 @@ struct ProcessManagerTests {
|
|||||||
"#!/bin/sh\ni=0; while [ $i -lt 5000 ]; do echo \"out-$i\"; echo \"err-$i\" 1>&2; i=$((i+1)); done\n"
|
"#!/bin/sh\ni=0; while [ $i -lt 5000 ]; do echo \"out-$i\"; echo \"err-$i\" 1>&2; i=$((i+1)); done\n"
|
||||||
)
|
)
|
||||||
let result = try await pm.runCaptured(id: "big", binary: bin, arguments: [])
|
let result = try await pm.runCaptured(id: "big", binary: bin, arguments: [])
|
||||||
#expect(result.stdout.contains("out-4999"))
|
XCTAssertTrue(result.stdout.contains("out-4999"))
|
||||||
#expect(result.stderr.contains("err-4999"))
|
XCTAssertTrue(result.stderr.contains("err-4999"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func argyllEnvVarIsSet() async throws {
|
func testArgyllEnvVarIsSet() async throws {
|
||||||
let pm = ProcessManager()
|
let pm = ProcessManager()
|
||||||
let bin = try script("env.sh", "#!/bin/sh\necho \"ANI=$ARGYLL_NOT_INTERACTIVE\"\n")
|
let bin = try script("env.sh", "#!/bin/sh\necho \"ANI=$ARGYLL_NOT_INTERACTIVE\"\n")
|
||||||
async let evs = collect(pm, id: "t10")
|
async let evs = collect(pm, id: "t10")
|
||||||
try await pm.runStreaming(id: "t10", binary: bin, arguments: [])
|
try await pm.runStreaming(id: "t10", binary: bin, arguments: [])
|
||||||
#expect(await evs.contains(.stdout(id: "t10", line: "ANI=1")))
|
let t10SawEnv = await evs.contains(.stdout(id: "t10", line: "ANI=1"))
|
||||||
|
XCTAssertTrue(t10SawEnv)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func unknownIDStdinThrows() async throws {
|
func testUnknownIDStdinThrows() async throws {
|
||||||
let pm = ProcessManager()
|
let pm = ProcessManager()
|
||||||
await #expect(throws: ProcessError.unknownID("nope")) {
|
await assertAsyncThrows(expectedType: ProcessError.self) {
|
||||||
try await pm.sendStdin(id: "nope", text: "d\n")
|
try await pm.sendStdin(id: "nope", text: "d\n")
|
||||||
|
} errorHandler: { error in
|
||||||
|
XCTAssertEqual(error, .unknownID("nope"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func explicitPartialFlushEmitsRowColorsJSON() async throws {
|
func testExplicitPartialFlushEmitsRowColorsJSON() async throws {
|
||||||
let pm = ProcessManager()
|
let pm = ProcessManager()
|
||||||
let marker = Self.fixtureDir
|
let marker = Self.fixtureDir
|
||||||
.appendingPathComponent("partial-row-ready-\(UUID().uuidString)")
|
.appendingPathComponent("partial-row-ready-\(UUID().uuidString)")
|
||||||
@@ -280,7 +287,8 @@ struct ProcessManagerTests {
|
|||||||
let box = Box()
|
let box = Box()
|
||||||
let observer = observe(pm, id: "t11", into: box)
|
let observer = observe(pm, id: "t11", into: box)
|
||||||
try await pm.runStreaming(id: "t11", binary: bin, arguments: [marker.path])
|
try await pm.runStreaming(id: "t11", binary: bin, arguments: [marker.path])
|
||||||
#expect(await waitForFile(marker))
|
let markerReady = await waitForFile(marker)
|
||||||
|
XCTAssertTrue(markerReady)
|
||||||
// Retry the flush so the pipe-ingest task can win the actor race
|
// Retry the flush so the pipe-ingest task can win the actor race
|
||||||
// on a loaded host; the first successful flush emits the row.
|
// on a loaded host; the first successful flush emits the row.
|
||||||
var flushed = false
|
var flushed = false
|
||||||
@@ -290,24 +298,25 @@ struct ProcessManagerTests {
|
|||||||
flushed = true
|
flushed = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
try await Task.sleep(for: .milliseconds(20))
|
try await Task.sleep(nanoseconds: 20_000_000)
|
||||||
}
|
}
|
||||||
#expect(flushed)
|
XCTAssertTrue(flushed)
|
||||||
await pm.kill(id: "t11")
|
await pm.kill(id: "t11")
|
||||||
#expect(await waitForExit(in: box))
|
let sawExit = await waitForExit(in: box)
|
||||||
|
XCTAssertTrue(sawExit)
|
||||||
observer.cancel()
|
observer.cancel()
|
||||||
let events = box.events
|
let events = box.events
|
||||||
let rows = events.compactMap { e -> String? in
|
let rows = events.compactMap { e -> String? in
|
||||||
if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) }
|
if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) }
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
#expect(rows == ["{\"row\":9}"])
|
XCTAssertEqual(rows, ["{\"row\":9}"])
|
||||||
// Prefixed tails must not leak into stdout, even via finalize.
|
// Prefixed tails must not leak into stdout, even via finalize.
|
||||||
#expect(!events.contains(.stdout(id: "t11", line: "ROW_COLORS_JSON: {\"row\":9}")))
|
XCTAssertFalse(events.contains(.stdout(id: "t11", line: "ROW_COLORS_JSON: {\"row\":9}")))
|
||||||
#expect(exitCount(in: box) == 1)
|
XCTAssertEqual(exitCount(in: box), 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func unterminatedRowTailFinalizesAsJSONRow() async throws {
|
func testUnterminatedRowTailFinalizesAsJSONRow() async throws {
|
||||||
let pm = ProcessManager()
|
let pm = ProcessManager()
|
||||||
let bin = try script(
|
let bin = try script(
|
||||||
"row-tail.sh",
|
"row-tail.sh",
|
||||||
@@ -316,68 +325,70 @@ struct ProcessManagerTests {
|
|||||||
let box = Box()
|
let box = Box()
|
||||||
let observer = observe(pm, id: "t12", into: box)
|
let observer = observe(pm, id: "t12", into: box)
|
||||||
try await pm.runStreaming(id: "t12", binary: bin, arguments: [])
|
try await pm.runStreaming(id: "t12", binary: bin, arguments: [])
|
||||||
#expect(await waitForExit(in: box))
|
let sawExit = await waitForExit(in: box)
|
||||||
|
XCTAssertTrue(sawExit)
|
||||||
observer.cancel()
|
observer.cancel()
|
||||||
let events = box.events
|
let events = box.events
|
||||||
let rows = events.compactMap { e -> String? in
|
let rows = events.compactMap { e -> String? in
|
||||||
if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) }
|
if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) }
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
#expect(rows == ["{\"row\":42}"])
|
XCTAssertEqual(rows, ["{\"row\":42}"])
|
||||||
#expect(!events.contains(.stdout(id: "t12", line: "ROW_COLORS_JSON: {\"row\":42}")))
|
XCTAssertFalse(events.contains(.stdout(id: "t12", line: "ROW_COLORS_JSON: {\"row\":42}")))
|
||||||
let rowIndex = events.firstIndex {
|
let rowIndex = events.firstIndex {
|
||||||
if case .jsonRow = $0 { return true }; return false
|
if case .jsonRow = $0 { return true }; return false
|
||||||
}
|
}
|
||||||
let exitIndexes = events.indices.filter {
|
let exitIndexes = events.indices.filter {
|
||||||
if case .exit = events[$0] { return true }; return false
|
if case .exit = events[$0] { return true }; return false
|
||||||
}
|
}
|
||||||
#expect(exitIndexes.count == 1)
|
XCTAssertEqual(exitIndexes.count, 1)
|
||||||
if let rowIndex, let exitIndex = exitIndexes.first {
|
if let rowIndex, let exitIndex = exitIndexes.first {
|
||||||
#expect(rowIndex < exitIndex)
|
XCTAssertTrue(rowIndex < exitIndex)
|
||||||
} else {
|
} else {
|
||||||
Issue.record("expected a jsonRow before the exit event")
|
XCTFail("expected a jsonRow before the exit event")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func fastStreamingExitEmitsExactlyOneExit() async throws {
|
func testFastStreamingExitEmitsExactlyOneExit() async throws {
|
||||||
let pm = ProcessManager()
|
let pm = ProcessManager()
|
||||||
let bin = try script("fast-stream.sh", "#!/bin/sh\nexit 0\n")
|
let bin = try script("fast-stream.sh", "#!/bin/sh\nexit 0\n")
|
||||||
let box = Box()
|
let box = Box()
|
||||||
let observer = observe(pm, id: "t13", into: box)
|
let observer = observe(pm, id: "t13", into: box)
|
||||||
try await pm.runStreaming(id: "t13", binary: bin, arguments: [])
|
try await pm.runStreaming(id: "t13", binary: bin, arguments: [])
|
||||||
#expect(await waitForExit(in: box))
|
let sawExit = await waitForExit(in: box)
|
||||||
|
XCTAssertTrue(sawExit)
|
||||||
// The grace window must outlast the 2 s finalize watchdog so a
|
// The grace window must outlast the 2 s finalize watchdog so a
|
||||||
// duplicate emission from it would be observed.
|
// duplicate emission from it would be observed.
|
||||||
try await Task.sleep(for: .milliseconds(2500))
|
try await Task.sleep(nanoseconds: 2_500_000_000)
|
||||||
observer.cancel()
|
observer.cancel()
|
||||||
#expect(box.events == [.exit(id: "t13", code: 0)])
|
XCTAssertEqual(box.events, [.exit(id: "t13", code: 0)])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func fastCapturedExitEmitsExactlyOneExit() async throws {
|
func testFastCapturedExitEmitsExactlyOneExit() async throws {
|
||||||
let pm = ProcessManager()
|
let pm = ProcessManager()
|
||||||
let bin = try script("fast-cap.sh", "#!/bin/sh\nexit 7\n")
|
let bin = try script("fast-cap.sh", "#!/bin/sh\nexit 7\n")
|
||||||
let box = Box()
|
let box = Box()
|
||||||
let observer = observe(pm, id: "t14", into: box)
|
let observer = observe(pm, id: "t14", into: box)
|
||||||
let result = try await pm.runCaptured(id: "t14", binary: bin, arguments: [])
|
let result = try await pm.runCaptured(id: "t14", binary: bin, arguments: [])
|
||||||
#expect(result.exitCode == 7)
|
XCTAssertEqual(result.exitCode, 7)
|
||||||
// Both the termination handler and the waitUntilExit watchdog
|
// Both the termination handler and the waitUntilExit watchdog
|
||||||
// resume the same box; give the slower path time to fire.
|
// resume the same box; give the slower path time to fire.
|
||||||
try await Task.sleep(for: .milliseconds(500))
|
try await Task.sleep(nanoseconds: 500_000_000)
|
||||||
observer.cancel()
|
observer.cancel()
|
||||||
#expect(box.events == [.exit(id: "t14", code: 7)])
|
XCTAssertEqual(box.events, [.exit(id: "t14", code: 7)])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func capturedRunSetsArgyllNotInteractive() async throws {
|
func testCapturedRunSetsArgyllNotInteractive() async throws {
|
||||||
let pm = ProcessManager()
|
let pm = ProcessManager()
|
||||||
let bin = try script(
|
let bin = try script(
|
||||||
"cap-env.sh",
|
"cap-env.sh",
|
||||||
"#!/bin/sh\necho \"ANI=$ARGYLL_NOT_INTERACTIVE\"\n"
|
"#!/bin/sh\necho \"ANI=$ARGYLL_NOT_INTERACTIVE\"\n"
|
||||||
)
|
)
|
||||||
let result = try await pm.runCaptured(id: "t15", binary: bin, arguments: [])
|
let result = try await pm.runCaptured(id: "t15", binary: bin, arguments: [])
|
||||||
#expect(result.stdout == "ANI=1\n")
|
XCTAssertEqual(result.stdout, "ANI=1\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func killAllTerminatesStreamingAndCapturedChildren() async throws {
|
func testKillAllTerminatesStreamingAndCapturedChildren() async throws {
|
||||||
let pm = ProcessManager()
|
let pm = ProcessManager()
|
||||||
let marker = Self.fixtureDir
|
let marker = Self.fixtureDir
|
||||||
.appendingPathComponent("mixed-cap-ready-\(UUID().uuidString)")
|
.appendingPathComponent("mixed-cap-ready-\(UUID().uuidString)")
|
||||||
@@ -391,21 +402,29 @@ struct ProcessManagerTests {
|
|||||||
let capTask = Task {
|
let capTask = Task {
|
||||||
try await pm.runCaptured(id: "t17", binary: capBin, arguments: [marker.path])
|
try await pm.runCaptured(id: "t17", binary: capBin, arguments: [marker.path])
|
||||||
}
|
}
|
||||||
#expect(await waitForFile(marker))
|
let markerReady = await waitForFile(marker)
|
||||||
#expect(await waitForRunning(pm, id: "t16"))
|
XCTAssertTrue(markerReady)
|
||||||
#expect(await waitForRunning(pm, id: "t17"))
|
let t16Running = await waitForRunning(pm, id: "t16")
|
||||||
#expect(await pm.killAll() == 2)
|
XCTAssertTrue(t16Running)
|
||||||
|
let t17Running = await waitForRunning(pm, id: "t17")
|
||||||
|
XCTAssertTrue(t17Running)
|
||||||
|
let killed = await pm.killAll()
|
||||||
|
XCTAssertEqual(killed, 2)
|
||||||
_ = try await capTask.value
|
_ = try await capTask.value
|
||||||
#expect(await waitForExit(in: streamBox))
|
let streamExit = await waitForExit(in: streamBox)
|
||||||
#expect(await waitForExit(in: capBox))
|
XCTAssertTrue(streamExit)
|
||||||
|
let capExit = await waitForExit(in: capBox)
|
||||||
|
XCTAssertTrue(capExit)
|
||||||
// Grace window outlasts the streaming finalize watchdog.
|
// Grace window outlasts the streaming finalize watchdog.
|
||||||
try await Task.sleep(for: .milliseconds(2500))
|
try await Task.sleep(nanoseconds: 2_500_000_000)
|
||||||
streamObserver.cancel()
|
streamObserver.cancel()
|
||||||
capObserver.cancel()
|
capObserver.cancel()
|
||||||
#expect(!(await pm.isRunning("t16")))
|
let t16RunningAfter = await pm.isRunning("t16")
|
||||||
#expect(!(await pm.isRunning("t17")))
|
XCTAssertFalse(t16RunningAfter)
|
||||||
#expect(exitCount(in: streamBox) == 1)
|
let t17RunningAfter = await pm.isRunning("t17")
|
||||||
#expect(exitCount(in: capBox) == 1)
|
XCTAssertFalse(t17RunningAfter)
|
||||||
|
XCTAssertEqual(exitCount(in: streamBox), 1)
|
||||||
|
XCTAssertEqual(exitCount(in: capBox), 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import XCTest
|
||||||
@testable import ICCery
|
@testable import ICCery
|
||||||
|
|
||||||
/// Direct contracts for the shared logged-run helper (issue #80).
|
/// Direct contracts for the shared logged-run helper (issue #80).
|
||||||
@@ -7,14 +7,12 @@ import Testing
|
|||||||
/// `runLogged` owns the running-flag transition (`false → true → false`)
|
/// `runLogged` owns the running-flag transition (`false → true → false`)
|
||||||
/// and the log-reset decision; these tests pin both sides of the
|
/// and the log-reset decision; these tests pin both sides of the
|
||||||
/// contract plus the coalesced `@MainActor` log hop.
|
/// contract plus the coalesced `@MainActor` log hop.
|
||||||
@Suite("ProcessRunSupport runLogged")
|
|
||||||
@MainActor
|
@MainActor
|
||||||
struct ProcessRunSupportTests {
|
final class ProcessRunSupportTests: XCTestCase {
|
||||||
|
|
||||||
private struct SentinelError: Error {}
|
private struct SentinelError: Error {}
|
||||||
|
|
||||||
@Test("Success: running transitions [true, false], log resets once, batches reach the main actor, value preserved")
|
func testSuccessTransitions() async throws {
|
||||||
func successTransitions() async throws {
|
|
||||||
var running: [Bool] = []
|
var running: [Bool] = []
|
||||||
var resets = 0
|
var resets = 0
|
||||||
var received: [String] = []
|
var received: [String] = []
|
||||||
@@ -23,7 +21,9 @@ struct ProcessRunSupportTests {
|
|||||||
setRunning: { running.append($0) },
|
setRunning: { running.append($0) },
|
||||||
resetLog: { resets += 1 },
|
resetLog: { resets += 1 },
|
||||||
onLog: { batch in
|
onLog: { batch in
|
||||||
MainActor.assertIsolated()
|
// MainActor.assertIsolated() needs Swift 5.9; the runner is
|
||||||
|
// on Xcode 14.2 (Swift 5.7) (#115).
|
||||||
|
XCTAssertTrue(Thread.isMainThread)
|
||||||
received.append(contentsOf: batch)
|
received.append(contentsOf: batch)
|
||||||
}
|
}
|
||||||
) { onLog in
|
) { onLog in
|
||||||
@@ -31,20 +31,19 @@ struct ProcessRunSupportTests {
|
|||||||
return 42
|
return 42
|
||||||
}
|
}
|
||||||
|
|
||||||
#expect(result == 42)
|
XCTAssertEqual(result, 42)
|
||||||
#expect(running == [true, false])
|
XCTAssertEqual(running, [true, false])
|
||||||
#expect(resets == 1)
|
XCTAssertEqual(resets, 1)
|
||||||
|
|
||||||
// The sink hops back through a main-actor Task; yield until the
|
// The sink hops back through a main-actor Task; yield until the
|
||||||
// coalesced batch lands.
|
// coalesced batch lands.
|
||||||
for _ in 0..<200 where received.isEmpty {
|
for _ in 0..<200 where received.isEmpty {
|
||||||
try await Task.sleep(for: .milliseconds(10))
|
try await Task.sleep(nanoseconds: 10_000_000)
|
||||||
}
|
}
|
||||||
#expect(received == ["alpha", "beta"])
|
XCTAssertEqual(received, ["alpha", "beta"])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Failure: running still transitions [true, false], log resets once, error is rethrown")
|
func testFailureTransitions() async throws {
|
||||||
func failureTransitions() async throws {
|
|
||||||
var running: [Bool] = []
|
var running: [Bool] = []
|
||||||
var resets = 0
|
var resets = 0
|
||||||
|
|
||||||
@@ -56,12 +55,12 @@ struct ProcessRunSupportTests {
|
|||||||
) { _ -> Int in
|
) { _ -> Int in
|
||||||
throw SentinelError()
|
throw SentinelError()
|
||||||
}
|
}
|
||||||
Issue.record("Expected runLogged to rethrow")
|
XCTFail("Expected runLogged to rethrow")
|
||||||
} catch is SentinelError {
|
} catch is SentinelError {
|
||||||
// Expected path.
|
// Expected path.
|
||||||
}
|
}
|
||||||
|
|
||||||
#expect(running == [true, false])
|
XCTAssertEqual(running, [true, false])
|
||||||
#expect(resets == 1)
|
XCTAssertEqual(resets, 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import XCTest
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
/// A `FileManager` subclass that reports a temporary directory as the
|
/// A `FileManager` subclass that reports a temporary directory as the
|
||||||
@@ -18,8 +18,7 @@ private final class TestFileManager: FileManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suite("ProfileInstaller")
|
final class ProfileInstallerTests: XCTestCase {
|
||||||
struct ProfileInstallerTests {
|
|
||||||
|
|
||||||
private func makeTempDir() throws -> URL {
|
private func makeTempDir() throws -> URL {
|
||||||
let fm = FileManager.default
|
let fm = FileManager.default
|
||||||
@@ -39,8 +38,7 @@ struct ProfileInstallerTests {
|
|||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Installs .icc to user ColorSync folder")
|
func testUserInstall() throws {
|
||||||
func userInstall() throws {
|
|
||||||
let fm = FileManager.default
|
let fm = FileManager.default
|
||||||
let tmp = try makeTempDir()
|
let tmp = try makeTempDir()
|
||||||
let testFM = TestFileManager(home: tmp)
|
let testFM = TestFileManager(home: tmp)
|
||||||
@@ -51,15 +49,14 @@ struct ProfileInstallerTests {
|
|||||||
fileManager: testFM
|
fileManager: testFM
|
||||||
)
|
)
|
||||||
|
|
||||||
#expect(result.registered)
|
XCTAssertTrue(result.registered)
|
||||||
#expect(!result.overwritten)
|
XCTAssertFalse(result.overwritten)
|
||||||
#expect(!result.renamed)
|
XCTAssertFalse(result.renamed)
|
||||||
#expect(result.destPath.hasSuffix("test.icc"))
|
XCTAssertTrue(result.destPath.hasSuffix("test.icc"))
|
||||||
#expect(fm.fileExists(atPath: result.destPath))
|
XCTAssertTrue(fm.fileExists(atPath: result.destPath))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Overwrite succeeds and replaces the existing file")
|
func testOverwriteSucceeds() throws {
|
||||||
func overwriteSucceeds() throws {
|
|
||||||
let fm = FileManager.default
|
let fm = FileManager.default
|
||||||
let tmp = try makeTempDir()
|
let tmp = try makeTempDir()
|
||||||
let testFM = TestFileManager(home: tmp)
|
let testFM = TestFileManager(home: tmp)
|
||||||
@@ -70,7 +67,7 @@ struct ProfileInstallerTests {
|
|||||||
config: InstallProfileConfig(sourceURL: source),
|
config: InstallProfileConfig(sourceURL: source),
|
||||||
fileManager: testFM
|
fileManager: testFM
|
||||||
)
|
)
|
||||||
#expect(!first.overwritten)
|
XCTAssertFalse(first.overwritten)
|
||||||
|
|
||||||
// Change the source contents.
|
// Change the source contents.
|
||||||
let newBytes: [UInt8] = (0..<256).map { UInt8(($0 + 100) % 256) }
|
let newBytes: [UInt8] = (0..<256).map { UInt8(($0 + 100) % 256) }
|
||||||
@@ -87,15 +84,14 @@ struct ProfileInstallerTests {
|
|||||||
fileManager: testFM
|
fileManager: testFM
|
||||||
)
|
)
|
||||||
|
|
||||||
#expect(second.overwritten)
|
XCTAssertTrue(second.overwritten)
|
||||||
#expect(!second.renamed)
|
XCTAssertFalse(second.renamed)
|
||||||
#expect(fm.fileExists(atPath: second.destPath))
|
XCTAssertTrue(fm.fileExists(atPath: second.destPath))
|
||||||
let installed = try Data(contentsOf: URL(fileURLWithPath: second.destPath))
|
let installed = try Data(contentsOf: URL(fileURLWithPath: second.destPath))
|
||||||
#expect(Array(installed) == newBytes)
|
XCTAssertEqual(Array(installed), newBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Preserves .icm source extension")
|
func testPreservesIcmExtension() throws {
|
||||||
func preservesIcmExtension() throws {
|
|
||||||
let tmp = try makeTempDir()
|
let tmp = try makeTempDir()
|
||||||
let testFM = TestFileManager(home: tmp)
|
let testFM = TestFileManager(home: tmp)
|
||||||
let source = try makeSource(at: tmp, name: "m5_profile.icm")
|
let source = try makeSource(at: tmp, name: "m5_profile.icm")
|
||||||
@@ -105,12 +101,11 @@ struct ProfileInstallerTests {
|
|||||||
fileManager: testFM
|
fileManager: testFM
|
||||||
)
|
)
|
||||||
|
|
||||||
#expect(URL(fileURLWithPath: result.destPath).pathExtension == "icm")
|
XCTAssertEqual(URL(fileURLWithPath: result.destPath).pathExtension, "icm")
|
||||||
#expect(result.destPath.hasSuffix("m5_profile.icm"))
|
XCTAssertTrue(result.destPath.hasSuffix("m5_profile.icm"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Rejects parent traversal in source path")
|
func testRejectsParentTraversal() throws {
|
||||||
func rejectsParentTraversal() throws {
|
|
||||||
let fm = FileManager.default
|
let fm = FileManager.default
|
||||||
let tmp = try makeTempDir()
|
let tmp = try makeTempDir()
|
||||||
|
|
||||||
@@ -125,20 +120,19 @@ struct ProfileInstallerTests {
|
|||||||
let sourceURL = tmp
|
let sourceURL = tmp
|
||||||
.appendingPathComponent("..")
|
.appendingPathComponent("..")
|
||||||
.appendingPathComponent(naughtyName)
|
.appendingPathComponent(naughtyName)
|
||||||
#expect(fm.fileExists(atPath: sourceURL.path))
|
XCTAssertTrue(fm.fileExists(atPath: sourceURL.path))
|
||||||
|
|
||||||
do {
|
do {
|
||||||
_ = try ProfileInstaller.install(config: InstallProfileConfig(sourceURL: sourceURL))
|
_ = try ProfileInstaller.install(config: InstallProfileConfig(sourceURL: sourceURL))
|
||||||
Issue.record("Expected unsafeStem error")
|
XCTFail("Expected unsafeStem error")
|
||||||
} catch let error as ProfileInstallError {
|
} catch let error as ProfileInstallError {
|
||||||
if case .unsafeStem = error { } else { Issue.record("Expected unsafeStem, got \(error)") }
|
if case .unsafeStem = error { } else { XCTFail("Expected unsafeStem, got \(error)") }
|
||||||
} catch {
|
} catch {
|
||||||
Issue.record("Unexpected error type: \(error)")
|
XCTFail("Unexpected error type: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Allows stems with consecutive dots like foo..bar")
|
func testAllowsDoubleDotStem() throws {
|
||||||
func allowsDoubleDotStem() throws {
|
|
||||||
let fm = FileManager.default
|
let fm = FileManager.default
|
||||||
let tmp = try makeTempDir()
|
let tmp = try makeTempDir()
|
||||||
let testFM = TestFileManager(home: tmp)
|
let testFM = TestFileManager(home: tmp)
|
||||||
@@ -149,23 +143,22 @@ struct ProfileInstallerTests {
|
|||||||
fileManager: testFM
|
fileManager: testFM
|
||||||
)
|
)
|
||||||
|
|
||||||
#expect(result.destPath.hasSuffix("foo..bar.icc"))
|
XCTAssertTrue(result.destPath.hasSuffix("foo..bar.icc"))
|
||||||
#expect(fm.fileExists(atPath: result.destPath))
|
XCTAssertTrue(fm.fileExists(atPath: result.destPath))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Rejects source files that are too small")
|
func testRejectsSmallSource() throws {
|
||||||
func rejectsSmallSource() throws {
|
|
||||||
let tmp = try makeTempDir()
|
let tmp = try makeTempDir()
|
||||||
let source = tmp.appendingPathComponent("tiny.icc")
|
let source = tmp.appendingPathComponent("tiny.icc")
|
||||||
try Data(repeating: 0, count: 64).write(to: source)
|
try Data(repeating: 0, count: 64).write(to: source)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
_ = try ProfileInstaller.install(config: InstallProfileConfig(sourceURL: source))
|
_ = try ProfileInstaller.install(config: InstallProfileConfig(sourceURL: source))
|
||||||
Issue.record("Expected sourceTooSmall error")
|
XCTFail("Expected sourceTooSmall error")
|
||||||
} catch let error as ProfileInstallError {
|
} catch let error as ProfileInstallError {
|
||||||
if case .sourceTooSmall = error { } else { Issue.record("Expected sourceTooSmall, got \(error)") }
|
if case .sourceTooSmall = error { } else { XCTFail("Expected sourceTooSmall, got \(error)") }
|
||||||
} catch {
|
} catch {
|
||||||
Issue.record("Unexpected error type: \(error)")
|
XCTFail("Unexpected error type: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Testing
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import XCTest
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
private func tempStoreURL() -> URL {
|
private func tempStoreURL() -> URL {
|
||||||
@@ -8,92 +8,90 @@ private func tempStoreURL() -> URL {
|
|||||||
.appendingPathComponent("settings.json")
|
.appendingPathComponent("settings.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suite("AppSettings")
|
final class AppSettingsTests: XCTestCase {
|
||||||
struct AppSettingsTests {
|
func testDefaults() {
|
||||||
@Test func defaults() {
|
|
||||||
let s = AppSettings.default
|
let s = AppSettings.default
|
||||||
#expect(s.argyllBinaryDir == nil)
|
XCTAssertNil(s.argyllBinaryDir)
|
||||||
#expect(s.defaultInstrument == nil)
|
XCTAssertNil(s.defaultInstrument)
|
||||||
#expect(s.logLevel == nil)
|
XCTAssertNil(s.logLevel)
|
||||||
#expect(s.deltaEGoodMax == 2.0)
|
XCTAssertEqual(s.deltaEGoodMax, 2.0)
|
||||||
#expect(s.deltaEWarningMax == 5.0)
|
XCTAssertEqual(s.deltaEWarningMax, 5.0)
|
||||||
#expect(s.customPresets.isEmpty)
|
XCTAssertTrue(s.customPresets.isEmpty)
|
||||||
#expect(!s.enableI1Pro2Leds)
|
XCTAssertFalse(s.enableI1Pro2Leds)
|
||||||
#expect(s.calibrationStaleDays == 30)
|
XCTAssertEqual(s.calibrationStaleDays, 30)
|
||||||
#expect(s.defaultInstallLocation == .user)
|
XCTAssertEqual(s.defaultInstallLocation, .user)
|
||||||
#expect(s.askBeforeOverwriteProfile)
|
XCTAssertTrue(s.askBeforeOverwriteProfile)
|
||||||
#expect(!s.openColorPanelAfterInstall)
|
XCTAssertFalse(s.openColorPanelAfterInstall)
|
||||||
#expect(s.isValid)
|
XCTAssertTrue(s.isValid)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func negativeThresholds() {
|
func testNegativeThresholds() {
|
||||||
var s = AppSettings.default
|
var s = AppSettings.default
|
||||||
s.deltaEGoodMax = -1
|
s.deltaEGoodMax = -1
|
||||||
#expect(s.validate() == [AppSettings.errorNegativeDeltaE])
|
XCTAssertEqual(s.validate(), [AppSettings.errorNegativeDeltaE])
|
||||||
s.deltaEGoodMax = 2.0
|
s.deltaEGoodMax = 2.0
|
||||||
s.deltaEWarningMax = -0.5
|
s.deltaEWarningMax = -0.5
|
||||||
// -0.5 < 0 → negative error; good(2.0) >= warn(-0.5) → order error too
|
// -0.5 < 0 → negative error; good(2.0) >= warn(-0.5) → order error too
|
||||||
#expect(s.validate() == [
|
XCTAssertTrue(s.validate() == [
|
||||||
AppSettings.errorNegativeDeltaE,
|
AppSettings.errorNegativeDeltaE,
|
||||||
AppSettings.errorThresholdOrder,
|
AppSettings.errorThresholdOrder,
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func goodMustBeStrictlyLessThanWarning() {
|
func testGoodMustBeStrictlyLessThanWarning() {
|
||||||
var s = AppSettings.default
|
var s = AppSettings.default
|
||||||
s.deltaEGoodMax = 5.0
|
s.deltaEGoodMax = 5.0
|
||||||
#expect(s.validate() == [AppSettings.errorThresholdOrder])
|
XCTAssertEqual(s.validate(), [AppSettings.errorThresholdOrder])
|
||||||
s.deltaEGoodMax = 6.0
|
s.deltaEGoodMax = 6.0
|
||||||
#expect(s.validate() == [AppSettings.errorThresholdOrder])
|
XCTAssertEqual(s.validate(), [AppSettings.errorThresholdOrder])
|
||||||
s.deltaEGoodMax = 4.9
|
s.deltaEGoodMax = 4.9
|
||||||
#expect(s.isValid)
|
XCTAssertTrue(s.isValid)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func snakeCaseKeys() throws {
|
func testSnakeCaseKeys() throws {
|
||||||
let s = AppSettings.default
|
let s = AppSettings.default
|
||||||
let data = try JSONEncoder().encode(s)
|
let data = try JSONEncoder().encode(s)
|
||||||
let json = String(data: data, encoding: .utf8)!
|
let json = String(data: data, encoding: .utf8)!
|
||||||
#expect(json.contains("\"delta_e_good_max\""))
|
XCTAssertTrue(json.contains("\"delta_e_good_max\""))
|
||||||
#expect(json.contains("\"default_install_location\""))
|
XCTAssertTrue(json.contains("\"default_install_location\""))
|
||||||
#expect(json.contains("\"enable_i1pro2_leds\""))
|
XCTAssertTrue(json.contains("\"enable_i1pro2_leds\""))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suite("SettingsStore")
|
final class SettingsStoreTests: XCTestCase {
|
||||||
struct SettingsStoreTests {
|
func testRoundTrip() throws {
|
||||||
@Test func roundTrip() throws {
|
|
||||||
let url = tempStoreURL()
|
let url = tempStoreURL()
|
||||||
let store = SettingsStore(fileURL: url)
|
let store = SettingsStore(fileURL: url)
|
||||||
var s = AppSettings.default
|
var s = AppSettings.default
|
||||||
s.deltaEGoodMax = 1.5
|
s.deltaEGoodMax = 1.5
|
||||||
s.defaultInstrument = "p3"
|
s.defaultInstrument = "p3"
|
||||||
try store.save(s)
|
try store.save(s)
|
||||||
#expect(store.load() == s)
|
XCTAssertEqual(store.load(), s)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func corruptJsonFallsBackToDefaults() throws {
|
func testCorruptJsonFallsBackToDefaults() throws {
|
||||||
let url = tempStoreURL()
|
let url = tempStoreURL()
|
||||||
try FileManager.default.createDirectory(
|
try FileManager.default.createDirectory(
|
||||||
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||||
)
|
)
|
||||||
try "{ not json".write(to: url, atomically: true, encoding: .utf8)
|
try "{ not json".write(to: url, atomically: true, encoding: .utf8)
|
||||||
#expect(SettingsStore(fileURL: url).load() == .default)
|
XCTAssertEqual(SettingsStore(fileURL: url).load(), .default)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func missingFileReturnsDefaults() {
|
func testMissingFileReturnsDefaults() {
|
||||||
#expect(SettingsStore(fileURL: tempStoreURL()).load() == .default)
|
XCTAssertEqual(SettingsStore(fileURL: tempStoreURL()).load(), .default)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func invalidSettingsNotPersisted() throws {
|
func testInvalidSettingsNotPersisted() throws {
|
||||||
let url = tempStoreURL()
|
let url = tempStoreURL()
|
||||||
let store = SettingsStore(fileURL: url)
|
let store = SettingsStore(fileURL: url)
|
||||||
var s = AppSettings.default
|
var s = AppSettings.default
|
||||||
s.deltaEGoodMax = 9.0 // >= warning 5.0
|
s.deltaEGoodMax = 9.0 // >= warning 5.0
|
||||||
#expect(throws: SettingsStore.SettingsError.self) { try store.save(s) }
|
XCTAssertThrowsError(try store.save(s)) { error in XCTAssertTrue(error is SettingsStore.SettingsError) }
|
||||||
#expect(!FileManager.default.fileExists(atPath: url.path))
|
XCTAssertFalse(FileManager.default.fileExists(atPath: url.path))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func invalidSaveOverValidFilePreservesBytesAndPostsNothing() throws {
|
func testInvalidSaveOverValidFilePreservesBytesAndPostsNothing() throws {
|
||||||
let url = tempStoreURL()
|
let url = tempStoreURL()
|
||||||
let store = SettingsStore(fileURL: url)
|
let store = SettingsStore(fileURL: url)
|
||||||
var valid = AppSettings.default
|
var valid = AppSettings.default
|
||||||
@@ -109,13 +107,13 @@ struct SettingsStoreTests {
|
|||||||
|
|
||||||
var invalid = AppSettings.default
|
var invalid = AppSettings.default
|
||||||
invalid.deltaEGoodMax = 9.0
|
invalid.deltaEGoodMax = 9.0
|
||||||
#expect(throws: SettingsStore.SettingsError.self) { try store.save(invalid) }
|
XCTAssertThrowsError(try store.save(invalid)) { error in XCTAssertTrue(error is SettingsStore.SettingsError) }
|
||||||
#expect(try Data(contentsOf: url) == originalBytes)
|
XCTAssertEqual(try Data(contentsOf: url), originalBytes)
|
||||||
#expect(!fired)
|
XCTAssertFalse(fired)
|
||||||
#expect(store.load() == valid)
|
XCTAssertEqual(store.load(), valid)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func savePostsNotification() async throws {
|
func testSavePostsNotification() async throws {
|
||||||
let url = tempStoreURL()
|
let url = tempStoreURL()
|
||||||
let store = SettingsStore(fileURL: url)
|
let store = SettingsStore(fileURL: url)
|
||||||
var fired = false
|
var fired = false
|
||||||
@@ -124,12 +122,11 @@ struct SettingsStoreTests {
|
|||||||
) { _ in fired = true }
|
) { _ in fired = true }
|
||||||
defer { NotificationCenter.default.removeObserver(token) }
|
defer { NotificationCenter.default.removeObserver(token) }
|
||||||
try store.save(.default)
|
try store.save(.default)
|
||||||
#expect(fired)
|
XCTAssertTrue(fired)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suite("LogSink")
|
final class LogSinkTests: XCTestCase {
|
||||||
struct LogSinkTests {
|
|
||||||
private func tempLog() -> (URL, LogSink) {
|
private func tempLog() -> (URL, LogSink) {
|
||||||
let url = FileManager.default.temporaryDirectory
|
let url = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("iccery-log-\(UUID().uuidString)")
|
.appendingPathComponent("iccery-log-\(UUID().uuidString)")
|
||||||
@@ -137,26 +134,26 @@ struct LogSinkTests {
|
|||||||
return (url, LogSink(fileURL: url))
|
return (url, LogSink(fileURL: url))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func writesFormattedLines() {
|
func testWritesFormattedLines() {
|
||||||
let (url, sink) = tempLog()
|
let (url, sink) = tempLog()
|
||||||
sink.setLevel(.debug)
|
sink.setLevel(.debug)
|
||||||
sink.write(level: .info, category: "test", message: "hello")
|
sink.write(level: .info, category: "test", message: "hello")
|
||||||
let content = (try? String(contentsOf: url, encoding: .utf8)) ?? ""
|
let content = (try? String(contentsOf: url, encoding: .utf8)) ?? ""
|
||||||
#expect(content.contains("[INFO] test: hello"))
|
XCTAssertTrue(content.contains("[INFO] test: hello"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func levelFilteringIsLive() {
|
func testLevelFilteringIsLive() {
|
||||||
let (url, sink) = tempLog()
|
let (url, sink) = tempLog()
|
||||||
sink.setLevel(.error)
|
sink.setLevel(.error)
|
||||||
sink.write(level: .info, category: "t", message: "hidden")
|
sink.write(level: .info, category: "t", message: "hidden")
|
||||||
sink.setLevel(.info) // runtime change, no restart (#158)
|
sink.setLevel(.info) // runtime change, no restart (#158)
|
||||||
sink.write(level: .info, category: "t", message: "shown")
|
sink.write(level: .info, category: "t", message: "shown")
|
||||||
let content = (try? String(contentsOf: url, encoding: .utf8)) ?? ""
|
let content = (try? String(contentsOf: url, encoding: .utf8)) ?? ""
|
||||||
#expect(!content.contains("hidden"))
|
XCTAssertFalse(content.contains("hidden"))
|
||||||
#expect(content.contains("shown"))
|
XCTAssertTrue(content.contains("shown"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func rotatesAt5MiBKeeping5Segments() throws {
|
func testRotatesAt5MiBKeeping5Segments() throws {
|
||||||
let (url, sink) = tempLog()
|
let (url, sink) = tempLog()
|
||||||
sink.setLevel(.trace)
|
sink.setLevel(.trace)
|
||||||
// Pre-fill the active log just under the cap, then cross it.
|
// Pre-fill the active log just under the cap, then cross it.
|
||||||
@@ -167,20 +164,20 @@ struct LogSinkTests {
|
|||||||
try big.write(to: url, atomically: true, encoding: .utf8)
|
try big.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
|
||||||
sink.write(level: .info, category: "t", message: "trigger rotation")
|
sink.write(level: .info, category: "t", message: "trigger rotation")
|
||||||
#expect(FileManager.default.fileExists(
|
XCTAssertTrue(FileManager.default.fileExists(
|
||||||
atPath: url.appendingPathExtension("1").path
|
atPath: url.appendingPathExtension("1").path
|
||||||
))
|
))
|
||||||
// Active log is small again.
|
// Active log is small again.
|
||||||
let size = try FileManager.default.attributesOfItem(
|
let size = try FileManager.default.attributesOfItem(
|
||||||
atPath: url.path
|
atPath: url.path
|
||||||
)[.size] as? UInt64
|
)[.size] as? UInt64
|
||||||
#expect((size ?? 0) < 1024)
|
XCTAssertTrue((size ?? 0) < 1024)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func tailExcerptCaps() throws {
|
func testTailExcerptCaps() throws {
|
||||||
let (url, sink) = tempLog()
|
let (url, sink) = tempLog()
|
||||||
sink.setLevel(.debug)
|
sink.setLevel(.debug)
|
||||||
sink.write(level: .info, category: "t", message: "line")
|
sink.write(level: .info, category: "t", message: "line")
|
||||||
#expect(sink.tailExcerpt(maxBytes: 8).count <= 8)
|
XCTAssertTrue(sink.tailExcerpt(maxBytes: 8).count <= 8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import ICCeryCore
|
||||||
|
|
||||||
|
/// `SpotReadArgs` goldens (issue #148):
|
||||||
|
/// `spotread -v -e [-c port] [-Y l]` — never `-u`, never a basename,
|
||||||
|
/// `-c` only for ports > 1, `-Y l` only when the LED setting is on.
|
||||||
|
final class SpotReadArgsTests: XCTestCase {
|
||||||
|
|
||||||
|
func testAutoOmitsPort() {
|
||||||
|
let args = SpotReadArgs.build(config: SpotReadConfig())
|
||||||
|
XCTAssertEqual(args, ["-v", "-e"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPort1OmitsC() {
|
||||||
|
let args = SpotReadArgs.build(config: SpotReadConfig(selectedPort: 1))
|
||||||
|
XCTAssertEqual(args, ["-v", "-e"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPort2IncludesC() {
|
||||||
|
let args = SpotReadArgs.build(config: SpotReadConfig(selectedPort: 2))
|
||||||
|
XCTAssertEqual(args, ["-v", "-e", "-c", "2"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLedFlag() {
|
||||||
|
let args = SpotReadArgs.build(config: SpotReadConfig(enableLEDs: true))
|
||||||
|
XCTAssertEqual(args, ["-v", "-e", "-Y", "l"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPortAndLeds() {
|
||||||
|
let args = SpotReadArgs.build(
|
||||||
|
config: SpotReadConfig(selectedPort: 2, enableLEDs: true))
|
||||||
|
XCTAssertEqual(args, ["-v", "-e", "-c", "2", "-Y", "l"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNeverU() {
|
||||||
|
for config in [
|
||||||
|
SpotReadConfig(),
|
||||||
|
SpotReadConfig(selectedPort: 2),
|
||||||
|
SpotReadConfig(enableLEDs: true),
|
||||||
|
SpotReadConfig(selectedPort: 3, enableLEDs: true),
|
||||||
|
] {
|
||||||
|
XCTAssertFalse(SpotReadArgs.build(config: config).contains("-u"))
|
||||||
|
XCTAssertFalse(SpotReadArgs.build(config: config).contains("-d"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import ICCeryCore
|
||||||
|
|
||||||
|
/// `SpotReadClassifier` / `SpotReadParser` against real `spotread`
|
||||||
|
/// phrasing (issue #148). The calibration-tile line classifies through
|
||||||
|
/// `ChartreadClassifier`; the spot prompt and its continuation lines
|
||||||
|
/// need the spot-specific matchers.
|
||||||
|
final class SpotReadClassifierTests: XCTestCase {
|
||||||
|
|
||||||
|
// Real `spotread` stdout (calibration then spot prompt).
|
||||||
|
private let calibrateLines = [
|
||||||
|
"Spot read needs a calibration before continuing",
|
||||||
|
"Place instrument on spot reading white calibration tile,",
|
||||||
|
" and then hit any key to continue,",
|
||||||
|
"or hit Esc or Q to abort:",
|
||||||
|
]
|
||||||
|
private let spotPromptLines = [
|
||||||
|
"Place instrument on a spot to be measured,",
|
||||||
|
" and hit a key to take a reading,",
|
||||||
|
"or hit Esc or Q to abort:",
|
||||||
|
]
|
||||||
|
|
||||||
|
func testCalibrationPrompt() {
|
||||||
|
var state = ChartreadState.idle
|
||||||
|
for line in calibrateLines {
|
||||||
|
state = SpotReadClassifier.classify(line: line, previousState: state).state
|
||||||
|
}
|
||||||
|
XCTAssertEqual(state, .calibrating)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSpotPromptIsAwaitingTrigger() {
|
||||||
|
var state = ChartreadState.calibrating
|
||||||
|
for line in spotPromptLines {
|
||||||
|
state = SpotReadClassifier.classify(line: line, previousState: state).state
|
||||||
|
}
|
||||||
|
XCTAssertEqual(state, .awaitingStrip)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAbortLineDoesNotBecomeWarning() {
|
||||||
|
// "or hit Esc or Q to abort:" contains no '?' but does contain
|
||||||
|
// "abort" — it must stay on the current prompt, never flip to
|
||||||
|
// a warning.
|
||||||
|
let r = SpotReadClassifier.classify(
|
||||||
|
line: "or hit Esc or Q to abort:", previousState: .awaitingStrip)
|
||||||
|
XCTAssertEqual(r.state, .awaitingStrip)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testParseResultLine() throws {
|
||||||
|
let parsed = SpotReadParser.parse(
|
||||||
|
line: "Result is XYZ: 18.51 20.05 15.71, D50 Lab: 51.9 -8.3 12.2")
|
||||||
|
let lab = try XCTUnwrap(parsed?.lab)
|
||||||
|
XCTAssertEqual(lab.l, 51.9, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(lab.a, -8.3, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(lab.b, 12.2, accuracy: 0.001)
|
||||||
|
let xyz = try XCTUnwrap(parsed?.xyz)
|
||||||
|
XCTAssertEqual(xyz.x, 18.51, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(xyz.y, 20.05, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(xyz.z, 15.71, accuracy: 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testParseLabOnlyLine() throws {
|
||||||
|
let parsed = SpotReadParser.parse(line: "Result is Lab: 40.0 1.2 -3.4")
|
||||||
|
let lab = try XCTUnwrap(parsed?.lab)
|
||||||
|
XCTAssertEqual(lab.l, 40.0, accuracy: 0.001)
|
||||||
|
XCTAssertNil(parsed?.xyz)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNonSampleLineParsesNil() {
|
||||||
|
XCTAssertNil(SpotReadParser.parse(line: "Place instrument on a spot to be measured,"))
|
||||||
|
XCTAssertNil(SpotReadParser.parse(line: "Calibration successful."))
|
||||||
|
XCTAssertNil(SpotReadParser.parse(line: ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDeltaEBetweenFixtures() {
|
||||||
|
let a = SpotReadParser.parse(
|
||||||
|
line: "Result is XYZ: 18.51 20.05 15.71, D50 Lab: 51.9 -8.3 12.2")!.lab
|
||||||
|
let b = SpotReadParser.parse(
|
||||||
|
line: "Result is XYZ: 19.00 20.50 16.00, D50 Lab: 52.3 -8.0 12.6")!.lab
|
||||||
|
XCTAssertEqual(ColorDifference.deltaE00(a, a), 0, accuracy: 0.0001)
|
||||||
|
XCTAssertGreaterThan(ColorDifference.deltaE00(a, b), 0)
|
||||||
|
XCTAssertEqual(
|
||||||
|
ColorDifference.classify(deltaE: 1.0, goodMax: 2.0, warningMax: 5.0),
|
||||||
|
.good)
|
||||||
|
XCTAssertEqual(
|
||||||
|
ColorDifference.classify(deltaE: 3.0, goodMax: 2.0, warningMax: 5.0),
|
||||||
|
.warning)
|
||||||
|
XCTAssertEqual(
|
||||||
|
ColorDifference.classify(deltaE: 6.0, goodMax: 2.0, warningMax: 5.0),
|
||||||
|
.bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import Foundation
|
||||||
|
import XCTest
|
||||||
|
@testable import ICCeryCore
|
||||||
|
@testable import ICCery
|
||||||
|
|
||||||
|
/// Issue #148 — `SpotReadViewModel` under an isolated
|
||||||
|
/// `TestAppEnvironment` with per-test mock `spotread`/`instlist`
|
||||||
|
/// sidecars in a temp bin dir.
|
||||||
|
@MainActor
|
||||||
|
final class SpotReadViewModelTests: XCTestCase {
|
||||||
|
|
||||||
|
private var env: TestAppEnvironment!
|
||||||
|
private var workflow: TargetWorkflowViewModel!
|
||||||
|
private var spot: SpotReadViewModel!
|
||||||
|
private var binDir: URL!
|
||||||
|
|
||||||
|
override func setUp() async throws {
|
||||||
|
binDir = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("spot-bin-\(UUID().uuidString)")
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: binDir, withIntermediateDirectories: true)
|
||||||
|
// `bundledArgyllRoot` also points at the temp bin dir so the
|
||||||
|
// real sidecars copied into the host app by the build phase do
|
||||||
|
// not mask a missing `spotread` in the override dir.
|
||||||
|
env = try TestAppEnvironment.make(
|
||||||
|
argyllBinDir: binDir, bundledArgyllRoot: binDir)
|
||||||
|
workflow = TargetWorkflowViewModel(environment: env.environment)
|
||||||
|
spot = workflow.spotRead
|
||||||
|
workflow.wizard.setTarget(basename: "spot", workingDirectory: env.root)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() async throws {
|
||||||
|
spot?.stopIfNeeded()
|
||||||
|
try? await Task.sleep(nanoseconds: 700_000_000)
|
||||||
|
env?.cleanup()
|
||||||
|
try? FileManager.default.removeItem(at: binDir)
|
||||||
|
env = nil
|
||||||
|
workflow = nil
|
||||||
|
spot = nil
|
||||||
|
binDir = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func writeMock(_ name: String, _ body: String) throws {
|
||||||
|
let url = binDir.appendingPathComponent(name)
|
||||||
|
try body.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
try FileManager.default.setAttributes(
|
||||||
|
[.posixPermissions: 0o755], ofItemAtPath: url.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func installInstlist(_ devicesJson: String) throws {
|
||||||
|
try writeMock("instlist", """
|
||||||
|
#!/bin/sh
|
||||||
|
printf '%s' '\(devicesJson)'
|
||||||
|
exit 0
|
||||||
|
""")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func installSpotread(lab: String = "51.9 -8.3 12.2") throws {
|
||||||
|
try writeMock("spotread", """
|
||||||
|
#!/bin/sh
|
||||||
|
echo "Spot read needs a calibration before continuing"
|
||||||
|
echo "Place instrument on spot reading white calibration tile,"
|
||||||
|
echo " and then hit any key to continue,"
|
||||||
|
echo "or hit Esc or Q to abort:"
|
||||||
|
IFS= read -r line || exit 0
|
||||||
|
echo "Calibration successful."
|
||||||
|
while true; do
|
||||||
|
echo "Place instrument on a spot to be measured,"
|
||||||
|
echo " and hit a key to take a reading,"
|
||||||
|
echo "or hit Esc or Q to abort:"
|
||||||
|
IFS= read -r line || exit 0
|
||||||
|
case "$line" in
|
||||||
|
q*|Q*) exit 0 ;;
|
||||||
|
esac
|
||||||
|
echo "Result is XYZ: 18.51 20.05 15.71, D50 Lab: \(lab)"
|
||||||
|
done
|
||||||
|
""")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitFor(
|
||||||
|
_ predicate: @escaping () async -> Bool,
|
||||||
|
timeout: TimeInterval = 10
|
||||||
|
) async -> Bool {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
if await predicate() { return true }
|
||||||
|
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||||
|
}
|
||||||
|
return await predicate()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitForSync(
|
||||||
|
_ predicate: @escaping () -> Bool,
|
||||||
|
timeout: TimeInterval = 10
|
||||||
|
) async -> Bool {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
if predicate() { return true }
|
||||||
|
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||||
|
}
|
||||||
|
return predicate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Missing sidecar
|
||||||
|
|
||||||
|
func testMissingSidecarNoSpawn() async throws {
|
||||||
|
// bin dir has no spotread → resolver override misses and the
|
||||||
|
// bundled path does not exist either.
|
||||||
|
XCTAssertFalse(spot.sidecarAvailable)
|
||||||
|
spot.sheetOpened()
|
||||||
|
XCTAssertEqual(workflow.wizard.notice?.kind, .error)
|
||||||
|
|
||||||
|
spot.start()
|
||||||
|
XCTAssertEqual(spot.lastError, "spotread sidecar missing — run fetch-argyll")
|
||||||
|
XCTAssertFalse(spot.isRunning)
|
||||||
|
let running = await env.environment.runner.processManager.isRunning(ProcessID.spotread)
|
||||||
|
XCTAssertFalse(running)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - defaultInstrument seeding
|
||||||
|
|
||||||
|
func testDefaultInstrumentSeedsPicker() async throws {
|
||||||
|
try installSpotread()
|
||||||
|
try installInstlist("""
|
||||||
|
{"event":"instruments","devices":[
|
||||||
|
{"port":1,"name":"X-Rite i1Pro","type":"i1"},
|
||||||
|
{"port":2,"name":"ColorMunki Photo","type":"CM"}]}
|
||||||
|
""")
|
||||||
|
var settings = env.environment.settingsStore.load()
|
||||||
|
settings.defaultInstrument = "CM"
|
||||||
|
try env.environment.settingsStore.save(settings)
|
||||||
|
|
||||||
|
spot.sheetOpened()
|
||||||
|
let ok1 = await waitFor { !self.spot.isDetecting && !self.spot.instruments.isEmpty }
|
||||||
|
XCTAssertTrue(ok1)
|
||||||
|
guard case .device(let device) = spot.selectedInstrument else {
|
||||||
|
XCTFail("Expected device selection, got .auto")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
XCTAssertEqual(device.port, 2)
|
||||||
|
XCTAssertFalse(spot.defaultMissing)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDefaultInstrumentNotPresent() async throws {
|
||||||
|
try installSpotread()
|
||||||
|
try installInstlist("""
|
||||||
|
{"event":"instruments","devices":[
|
||||||
|
{"port":1,"name":"X-Rite i1Pro","type":"i1"}]}
|
||||||
|
""")
|
||||||
|
var settings = env.environment.settingsStore.load()
|
||||||
|
settings.defaultInstrument = "51" // Spyder X — absent
|
||||||
|
try env.environment.settingsStore.save(settings)
|
||||||
|
|
||||||
|
spot.sheetOpened()
|
||||||
|
let ok2 = await waitFor { !self.spot.isDetecting }
|
||||||
|
XCTAssertTrue(ok2)
|
||||||
|
XCTAssertEqual(spot.selectedInstrument, .auto)
|
||||||
|
XCTAssertTrue(spot.defaultMissing)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSetDefaultToggleWritesSettingsOnly() async throws {
|
||||||
|
try installSpotread()
|
||||||
|
try installInstlist("""
|
||||||
|
{"event":"instruments","devices":[
|
||||||
|
{"port":2,"name":"ColorMunki Photo","type":"CM"}]}
|
||||||
|
""")
|
||||||
|
spot.sheetOpened()
|
||||||
|
let ok3 = await waitFor { !self.spot.instruments.isEmpty }
|
||||||
|
XCTAssertTrue(ok3)
|
||||||
|
spot.selectedInstrument = .device(spot.instruments[0])
|
||||||
|
spot.applyDefaultToggle(true)
|
||||||
|
XCTAssertEqual(env.environment.settingsStore.load().defaultInstrument, "CM")
|
||||||
|
// printtarg instrument is untouched (R15).
|
||||||
|
XCTAssertEqual(workflow.instrument, .i1)
|
||||||
|
spot.applyDefaultToggle(false)
|
||||||
|
XCTAssertNil(env.environment.settingsStore.load().defaultInstrument)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Exclusive lease
|
||||||
|
|
||||||
|
func testDuplicateSpotreadIdRejected() async throws {
|
||||||
|
try writeMock("spotread", "#!/bin/sh\nsleep 30\n")
|
||||||
|
let pm = env.environment.runner.processManager
|
||||||
|
let bin = env.environment.runner.binaryResolver.resolve("spotread")
|
||||||
|
try await pm.runStreaming(id: ProcessID.spotread, binary: bin, arguments: [])
|
||||||
|
let ok4 = await pm.isRunning(ProcessID.spotread)
|
||||||
|
XCTAssertTrue(ok4)
|
||||||
|
do {
|
||||||
|
try await pm.runStreaming(id: ProcessID.spotread, binary: bin, arguments: [])
|
||||||
|
XCTFail("Expected duplicateID")
|
||||||
|
} catch let error as ProcessError {
|
||||||
|
guard case .duplicateID(let id) = error else {
|
||||||
|
XCTFail("Expected duplicateID, got \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
XCTAssertEqual(id, "spotread")
|
||||||
|
}
|
||||||
|
await pm.kill(id: ProcessID.spotread)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Session
|
||||||
|
|
||||||
|
func testMockSpotreadProducesSample() async throws {
|
||||||
|
try installSpotread()
|
||||||
|
spot.sheetOpened()
|
||||||
|
spot.start()
|
||||||
|
let ok5 = await waitFor { self.spot.state == .calibrating }
|
||||||
|
XCTAssertTrue(ok5, "expected calibrating prompt")
|
||||||
|
|
||||||
|
spot.calibrate()
|
||||||
|
let ok6 = await waitFor { self.spot.state == .awaitingStrip }
|
||||||
|
XCTAssertTrue(ok6, "expected read prompt")
|
||||||
|
|
||||||
|
spot.trigger()
|
||||||
|
let ok7 = await waitFor { !self.spot.samples.isEmpty }
|
||||||
|
XCTAssertTrue(ok7, "expected a sample")
|
||||||
|
let sample = try XCTUnwrap(spot.samples.first)
|
||||||
|
XCTAssertEqual(sample.lab.l, 51.9, accuracy: 0.001)
|
||||||
|
XCTAssertNotNil(sample.xyz)
|
||||||
|
XCTAssertNil(spot.displayedDeltaE) // first sample hides ΔE
|
||||||
|
|
||||||
|
spot.trigger()
|
||||||
|
let ok8 = await waitFor { self.spot.samples.count >= 2 }
|
||||||
|
XCTAssertTrue(ok8, "expected a second sample")
|
||||||
|
XCTAssertNotNil(spot.displayedDeltaE)
|
||||||
|
XCTAssertEqual(spot.displayedDeltaE ?? -1, 0, accuracy: 0.0001) // identical Lab
|
||||||
|
|
||||||
|
spot.stopIfNeeded()
|
||||||
|
XCTAssertFalse(spot.isRunning)
|
||||||
|
let deadline = Date().addingTimeInterval(5)
|
||||||
|
var alive = await env.environment.runner.processManager.isRunning(ProcessID.spotread)
|
||||||
|
while alive && Date() < deadline {
|
||||||
|
try await Task.sleep(nanoseconds: 100_000_000)
|
||||||
|
alive = await env.environment.runner.processManager.isRunning(ProcessID.spotread)
|
||||||
|
}
|
||||||
|
XCTAssertFalse(alive, "spotread child must not outlive Stop")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStartBlockedWhileChartreadRunning() async throws {
|
||||||
|
try installSpotread()
|
||||||
|
// Simulate a live Stage 3 chartread child.
|
||||||
|
workflow.measurement.isChartreadRunning = true
|
||||||
|
spot.sheetOpened()
|
||||||
|
spot.start()
|
||||||
|
XCTAssertEqual(spot.lastError, "Stop the Stage 3 chart read first.")
|
||||||
|
XCTAssertFalse(spot.isRunning)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import Testing
|
|
||||||
import XCTest
|
import XCTest
|
||||||
import Foundation
|
import Foundation
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
@@ -220,11 +219,9 @@ final class TargenArgsTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suite("ArgyllRunner Targen")
|
final class ArgyllRunnerTargenTests: XCTestCase {
|
||||||
struct ArgyllRunnerTargenTests {
|
|
||||||
|
|
||||||
@Test("Successful targen execution creates .ti1 and returns URL")
|
func testSuccessfulTargenExecution() async throws {
|
||||||
func successfulTargenExecution() async throws {
|
|
||||||
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||||
@@ -270,14 +267,13 @@ struct ArgyllRunnerTargenTests {
|
|||||||
box.append(batch)
|
box.append(batch)
|
||||||
}
|
}
|
||||||
logLines = box.lines
|
logLines = box.lines
|
||||||
#expect(logLines.contains("Generating patches..."))
|
XCTAssertTrue(logLines.contains("Generating patches..."))
|
||||||
|
|
||||||
#expect(FileManager.default.fileExists(atPath: ti1URL.path))
|
XCTAssertTrue(FileManager.default.fileExists(atPath: ti1URL.path))
|
||||||
#expect(ti1URL.lastPathComponent == "mock_test.ti1")
|
XCTAssertEqual(ti1URL.lastPathComponent, "mock_test.ti1")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Failed targen execution throws toolFailed")
|
func testFailedTargenExecution() async throws {
|
||||||
func failedTargenExecution() async throws {
|
|
||||||
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||||
@@ -304,14 +300,15 @@ struct ArgyllRunnerTargenTests {
|
|||||||
workingDirectory: tempDir
|
workingDirectory: tempDir
|
||||||
)
|
)
|
||||||
|
|
||||||
await #expect(throws: ArgyllRunnerError.toolFailed(
|
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
|
||||||
tool: "targen", code: 1, logs: ["Error: something went wrong"])) {
|
|
||||||
try await runner.runTargen(config: config)
|
try await runner.runTargen(config: config)
|
||||||
|
} errorHandler: { error in
|
||||||
|
XCTAssertEqual(error, .toolFailed(
|
||||||
|
tool: "targen", code: 1, logs: ["Error: something went wrong"]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Targen exit 0 without .ti1 throws missingArtefact")
|
func testMissingArtefactThrows() async throws {
|
||||||
func missingArtefactThrows() async throws {
|
|
||||||
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||||
@@ -338,9 +335,11 @@ struct ArgyllRunnerTargenTests {
|
|||||||
workingDirectory: tempDir
|
workingDirectory: tempDir
|
||||||
)
|
)
|
||||||
|
|
||||||
await #expect(throws: ArgyllRunnerError.missingArtefact(
|
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
|
||||||
tempDir.appendingPathComponent("no_file.ti1").path)) {
|
|
||||||
try await runner.runTargen(config: config)
|
try await runner.runTargen(config: config)
|
||||||
|
} errorHandler: { error in
|
||||||
|
XCTAssertEqual(error, .missingArtefact(
|
||||||
|
tempDir.appendingPathComponent("no_file.ti1").path))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import XCTest
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
@testable import ICCery
|
@testable import ICCery
|
||||||
|
|
||||||
/// Dataset-import error contracts through the
|
/// Dataset-import error contracts through the
|
||||||
/// `importMeasurementDataset(from:)` seam (issue #80): parser and I/O
|
/// `importMeasurementDataset(from:)` seam (issue #80): parser and I/O
|
||||||
/// failures must surface identically as a single `.error` Notice.
|
/// failures must surface identically as a single `.error` Notice.
|
||||||
@Suite("TargetWorkflowViewModel dataset import")
|
|
||||||
@MainActor
|
@MainActor
|
||||||
struct TargetWorkflowViewModelTests {
|
final class TargetWorkflowViewModelTests: XCTestCase {
|
||||||
|
|
||||||
@Test("Malformed content (CGATSParseError) produces one .error notice prefixed 'Import failed:'")
|
func testMalformedDatasetNotice() throws {
|
||||||
func malformedDatasetNotice() throws {
|
|
||||||
let env = try TestAppEnvironment.make()
|
let env = try TestAppEnvironment.make()
|
||||||
defer { env.cleanup() }
|
defer { env.cleanup() }
|
||||||
let vm = TargetWorkflowViewModel(environment: env.environment)
|
let vm = TargetWorkflowViewModel(environment: env.environment)
|
||||||
@@ -21,13 +19,12 @@ struct TargetWorkflowViewModelTests {
|
|||||||
|
|
||||||
vm.importMeasurementDataset(from: bad)
|
vm.importMeasurementDataset(from: bad)
|
||||||
|
|
||||||
let notice = try #require(vm.wizard.notice)
|
let notice = try XCTUnwrap(vm.wizard.notice)
|
||||||
#expect(notice.kind == .error)
|
XCTAssertEqual(notice.kind, .error)
|
||||||
#expect(notice.text.hasPrefix("Import failed:"))
|
XCTAssertTrue(notice.text.hasPrefix("Import failed:"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Missing file (CocoaError) produces one .error notice prefixed 'Import failed:'")
|
func testMissingDatasetNotice() throws {
|
||||||
func missingDatasetNotice() throws {
|
|
||||||
let env = try TestAppEnvironment.make()
|
let env = try TestAppEnvironment.make()
|
||||||
defer { env.cleanup() }
|
defer { env.cleanup() }
|
||||||
let vm = TargetWorkflowViewModel(environment: env.environment)
|
let vm = TargetWorkflowViewModel(environment: env.environment)
|
||||||
@@ -35,8 +32,8 @@ struct TargetWorkflowViewModelTests {
|
|||||||
let missing = env.root.appendingPathComponent("does-not-exist.ti3")
|
let missing = env.root.appendingPathComponent("does-not-exist.ti3")
|
||||||
vm.importMeasurementDataset(from: missing)
|
vm.importMeasurementDataset(from: missing)
|
||||||
|
|
||||||
let notice = try #require(vm.wizard.notice)
|
let notice = try XCTUnwrap(vm.wizard.notice)
|
||||||
#expect(notice.kind == .error)
|
XCTAssertEqual(notice.kind, .error)
|
||||||
#expect(notice.text.hasPrefix("Import failed:"))
|
XCTAssertTrue(notice.text.hasPrefix("Import failed:"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,10 +19,21 @@ struct TestAppEnvironment {
|
|||||||
var historyURL: URL {
|
var historyURL: URL {
|
||||||
root.appendingPathComponent("verification_history.json")
|
root.appendingPathComponent("verification_history.json")
|
||||||
}
|
}
|
||||||
|
var mediaLibraryURL: URL {
|
||||||
|
root.appendingPathComponent("media_library.json")
|
||||||
|
}
|
||||||
|
|
||||||
/// Creates an isolated environment under `NSTemporaryDirectory()`.
|
/// Creates an isolated environment under `NSTemporaryDirectory()`.
|
||||||
/// Call `cleanup()` when finished.
|
/// Call `cleanup()` when finished.
|
||||||
static func make() throws -> TestAppEnvironment {
|
/// `argyllBinDir` overrides the `BinaryResolver` tool directory so
|
||||||
|
/// tests can point at mock sidecar scripts (#148).
|
||||||
|
/// `bundledArgyllRoot` replaces the real app-bundle sidecar root so
|
||||||
|
/// tests can simulate a missing sidecar even when the build phase
|
||||||
|
/// copied real binaries into the host app.
|
||||||
|
static func make(
|
||||||
|
argyllBinDir: URL? = nil,
|
||||||
|
bundledArgyllRoot: URL? = nil
|
||||||
|
) throws -> TestAppEnvironment {
|
||||||
let root = FileManager.default.temporaryDirectory
|
let root = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("iccery-test-env-\(UUID().uuidString)")
|
.appendingPathComponent("iccery-test-env-\(UUID().uuidString)")
|
||||||
try FileManager.default.createDirectory(
|
try FileManager.default.createDirectory(
|
||||||
@@ -41,7 +52,9 @@ struct TestAppEnvironment {
|
|||||||
presetStore: PresetStore(settingsStore: settingsStore),
|
presetStore: PresetStore(settingsStore: settingsStore),
|
||||||
runner: ArgyllRunner(
|
runner: ArgyllRunner(
|
||||||
processManager: processManager,
|
processManager: processManager,
|
||||||
binaryResolver: BinaryResolver(overrideDir: nil)
|
binaryResolver: BinaryResolver(
|
||||||
|
bundledRoot: bundledArgyllRoot ?? AppPaths.bundledArgyllDir,
|
||||||
|
overrideDir: argyllBinDir)
|
||||||
),
|
),
|
||||||
cupsService: CupsService(
|
cupsService: CupsService(
|
||||||
processManager: processManager,
|
processManager: processManager,
|
||||||
@@ -49,6 +62,9 @@ struct TestAppEnvironment {
|
|||||||
),
|
),
|
||||||
historyStore: VerificationHistoryStore(
|
historyStore: VerificationHistoryStore(
|
||||||
url: root.appendingPathComponent("verification_history.json")
|
url: root.appendingPathComponent("verification_history.json")
|
||||||
|
),
|
||||||
|
mediaStore: MediaLibraryStore(
|
||||||
|
url: root.appendingPathComponent("media_library.json")
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return TestAppEnvironment(root: root, environment: environment)
|
return TestAppEnvironment(root: root, environment: environment)
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import XCTest
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
@Suite("VerificationHistoryStore")
|
final class VerificationHistoryStoreTests: XCTestCase {
|
||||||
struct VerificationHistoryStoreTests {
|
|
||||||
|
|
||||||
@Test("Append and cap")
|
func testAppendAndCap() async throws {
|
||||||
func appendAndCap() async throws {
|
|
||||||
let fm = FileManager.default
|
let fm = FileManager.default
|
||||||
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
|
try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
|
||||||
@@ -29,12 +27,11 @@ struct VerificationHistoryStoreTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let all = await store.all()
|
let all = await store.all()
|
||||||
#expect(all.count == 3)
|
XCTAssertEqual(all.count, 3)
|
||||||
#expect(all.first?.avgDE == 2.0)
|
XCTAssertEqual(all.first?.avgDE, 2.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Parse failure preserves file")
|
func testParseFailurePreservesFile() async {
|
||||||
func parseFailurePreservesFile() async {
|
|
||||||
let fm = FileManager.default
|
let fm = FileManager.default
|
||||||
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
try? fm.createDirectory(at: tmp, withIntermediateDirectories: true)
|
try? fm.createDirectory(at: tmp, withIntermediateDirectories: true)
|
||||||
@@ -45,14 +42,13 @@ struct VerificationHistoryStoreTests {
|
|||||||
let store = VerificationHistoryStore(url: url)
|
let store = VerificationHistoryStore(url: url)
|
||||||
do {
|
do {
|
||||||
_ = try await store.load()
|
_ = try await store.load()
|
||||||
Issue.record("load() should throw on invalid JSON")
|
XCTFail("load() should throw on invalid JSON")
|
||||||
} catch {
|
} catch {
|
||||||
#expect(fm.fileExists(atPath: url.path))
|
XCTAssertTrue(fm.fileExists(atPath: url.path))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Append loads existing records first")
|
func testAppendLoadsExisting() async throws {
|
||||||
func appendLoadsExisting() async throws {
|
|
||||||
let fm = FileManager.default
|
let fm = FileManager.default
|
||||||
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
|
try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
|
||||||
@@ -89,13 +85,12 @@ struct VerificationHistoryStoreTests {
|
|||||||
_ = try await store2.append(new)
|
_ = try await store2.append(new)
|
||||||
|
|
||||||
let all = await store2.all()
|
let all = await store2.all()
|
||||||
#expect(all.count == 2)
|
XCTAssertEqual(all.count, 2)
|
||||||
#expect(all.contains { $0.id == "vr-existing" })
|
XCTAssertTrue(all.contains { $0.id == "vr-existing" })
|
||||||
#expect(all.contains { $0.id == "vr-new" })
|
XCTAssertTrue(all.contains { $0.id == "vr-new" })
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Append does not overwrite an unparseable file")
|
func testAppendPreservesUnparseableFile() async {
|
||||||
func appendPreservesUnparseableFile() async {
|
|
||||||
let fm = FileManager.default
|
let fm = FileManager.default
|
||||||
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
try? fm.createDirectory(at: tmp, withIntermediateDirectories: true)
|
try? fm.createDirectory(at: tmp, withIntermediateDirectories: true)
|
||||||
@@ -119,20 +114,19 @@ struct VerificationHistoryStoreTests {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
_ = try await store.append(record)
|
_ = try await store.append(record)
|
||||||
Issue.record("append() should propagate the load error")
|
XCTFail("append() should propagate the load error")
|
||||||
} catch {
|
} catch {
|
||||||
#expect(fm.fileExists(atPath: url.path))
|
XCTAssertTrue(fm.fileExists(atPath: url.path))
|
||||||
if let data = try? Data(contentsOf: url),
|
if let data = try? Data(contentsOf: url),
|
||||||
let contents = String(data: data, encoding: .utf8) {
|
let contents = String(data: data, encoding: .utf8) {
|
||||||
#expect(contents == badJSON)
|
XCTAssertEqual(contents, badJSON)
|
||||||
} else {
|
} else {
|
||||||
Issue.record("Could not read preserved file")
|
XCTFail("Could not read preserved file")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Clear does not overwrite an unparseable file")
|
func testClearPreservesUnparseableFile() async {
|
||||||
func clearPreservesUnparseableFile() async {
|
|
||||||
let fm = FileManager.default
|
let fm = FileManager.default
|
||||||
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
try? fm.createDirectory(at: tmp, withIntermediateDirectories: true)
|
try? fm.createDirectory(at: tmp, withIntermediateDirectories: true)
|
||||||
@@ -144,15 +138,14 @@ struct VerificationHistoryStoreTests {
|
|||||||
let store = VerificationHistoryStore(url: url)
|
let store = VerificationHistoryStore(url: url)
|
||||||
do {
|
do {
|
||||||
try await store.clear()
|
try await store.clear()
|
||||||
Issue.record("clear() should propagate the load error")
|
XCTFail("clear() should propagate the load error")
|
||||||
} catch {
|
} catch {
|
||||||
let contents = try? String(contentsOf: url, encoding: .utf8)
|
let contents = try? String(contentsOf: url, encoding: .utf8)
|
||||||
#expect(contents == badJSON)
|
XCTAssertEqual(contents, badJSON)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("ISO-8601 timestamps round-trip through a fresh store")
|
func testIso8601RoundTrip() async throws {
|
||||||
func iso8601RoundTrip() async throws {
|
|
||||||
let fm = FileManager.default
|
let fm = FileManager.default
|
||||||
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
|
try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
|
||||||
@@ -174,16 +167,15 @@ struct VerificationHistoryStoreTests {
|
|||||||
_ = try await store1.append(record)
|
_ = try await store1.append(record)
|
||||||
|
|
||||||
let text = try String(contentsOf: url, encoding: .utf8)
|
let text = try String(contentsOf: url, encoding: .utf8)
|
||||||
#expect(text.contains(ISO8601DateFormatter().string(from: timestamp)))
|
XCTAssertTrue(text.contains(ISO8601DateFormatter().string(from: timestamp)))
|
||||||
|
|
||||||
let store2 = VerificationHistoryStore(url: url)
|
let store2 = VerificationHistoryStore(url: url)
|
||||||
let loaded = try await store2.load()
|
let loaded = try await store2.load()
|
||||||
#expect(loaded.count == 1)
|
XCTAssertEqual(loaded.count, 1)
|
||||||
#expect(loaded.first?.timestamp == timestamp)
|
XCTAssertEqual(loaded.first?.timestamp, timestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("CSV export quoting")
|
func testCsvQuoting() async throws {
|
||||||
func csvQuoting() async throws {
|
|
||||||
let fm = FileManager.default
|
let fm = FileManager.default
|
||||||
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
|
try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
|
||||||
@@ -204,7 +196,7 @@ struct VerificationHistoryStoreTests {
|
|||||||
_ = try await store.append(record)
|
_ = try await store.append(record)
|
||||||
|
|
||||||
let csv = await store.exportCSV()
|
let csv = await store.exportCSV()
|
||||||
#expect(csv.contains("\"a,b\""))
|
XCTAssertTrue(csv.contains("\"a,b\""))
|
||||||
#expect(csv.contains("\"\"quoted\"\""))
|
XCTAssertTrue(csv.contains("\"\"quoted\"\""))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import Testing
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import XCTest
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
@testable import ICCery
|
@testable import ICCery
|
||||||
|
|
||||||
/// Issue #29 — `CAL_` basename must be restored on relaunch and on any
|
/// Issue #29 — `CAL_` basename must be restored on relaunch and on any
|
||||||
/// attempt to navigate to a non-calibration stage that would use it.
|
/// attempt to navigate to a non-calibration stage that would use it.
|
||||||
@Suite("WizardCalibrationSession")
|
|
||||||
@MainActor
|
@MainActor
|
||||||
struct WizardCalibrationSessionTests {
|
final class WizardCalibrationSessionTests: XCTestCase {
|
||||||
|
|
||||||
private func tempURL() -> URL {
|
private func tempURL() -> URL {
|
||||||
FileManager.default.temporaryDirectory
|
FileManager.default.temporaryDirectory
|
||||||
@@ -24,8 +23,7 @@ struct WizardCalibrationSessionTests {
|
|||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Persist and restore calibrationOriginalBasename across a relaunch")
|
func testRelaunchRestoresOriginal() throws {
|
||||||
func relaunchRestoresOriginal() throws {
|
|
||||||
let url = tempURL()
|
let url = tempURL()
|
||||||
let store = WizardStateStore(fileURL: url)
|
let store = WizardStateStore(fileURL: url)
|
||||||
var saved = WizardState(
|
var saved = WizardState(
|
||||||
@@ -39,14 +37,13 @@ struct WizardCalibrationSessionTests {
|
|||||||
|
|
||||||
let model = WizardViewModel(stateStore: store)
|
let model = WizardViewModel(stateStore: store)
|
||||||
|
|
||||||
#expect(model.basename == "DemoTarget")
|
XCTAssertEqual(model.basename, "DemoTarget")
|
||||||
#expect(model.calibrationOriginalBasename == "")
|
XCTAssertEqual(model.calibrationOriginalBasename, "")
|
||||||
#expect(model.sessionMode == .profile)
|
XCTAssertEqual(model.sessionMode, .profile)
|
||||||
#expect(model.stage == .generate)
|
XCTAssertEqual(model.stage, .generate)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("go(to: .buildProfile) while basename is CAL_ refuses and restores the original")
|
func testGoToBuildProfileRefusesAndRestores() throws {
|
||||||
func goToBuildProfileRefusesAndRestores() throws {
|
|
||||||
let dir = try tempDir()
|
let dir = try tempDir()
|
||||||
let url = tempURL()
|
let url = tempURL()
|
||||||
let store = WizardStateStore(fileURL: url)
|
let store = WizardStateStore(fileURL: url)
|
||||||
@@ -60,14 +57,13 @@ struct WizardCalibrationSessionTests {
|
|||||||
|
|
||||||
model.go(to: .buildProfile)
|
model.go(to: .buildProfile)
|
||||||
|
|
||||||
#expect(model.basename == "DemoTarget")
|
XCTAssertEqual(model.basename, "DemoTarget")
|
||||||
#expect(model.calibrationOriginalBasename == "")
|
XCTAssertEqual(model.calibrationOriginalBasename, "")
|
||||||
#expect(model.sessionMode == .profile)
|
XCTAssertEqual(model.sessionMode, .profile)
|
||||||
#expect(model.stage == .calibrate)
|
XCTAssertEqual(model.stage, .calibrate)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("go(to: .layOutPrint) while basename is CAL_ stays in calibration")
|
func testGoToLayoutStaysCal() throws {
|
||||||
func goToLayoutStaysCal() throws {
|
|
||||||
let dir = try tempDir()
|
let dir = try tempDir()
|
||||||
let url = tempURL()
|
let url = tempURL()
|
||||||
let store = WizardStateStore(fileURL: url)
|
let store = WizardStateStore(fileURL: url)
|
||||||
@@ -81,9 +77,9 @@ struct WizardCalibrationSessionTests {
|
|||||||
|
|
||||||
model.go(to: .layOutPrint)
|
model.go(to: .layOutPrint)
|
||||||
|
|
||||||
#expect(model.basename == "CAL_DemoTarget")
|
XCTAssertEqual(model.basename, "CAL_DemoTarget")
|
||||||
#expect(model.calibrationOriginalBasename == "DemoTarget")
|
XCTAssertEqual(model.calibrationOriginalBasename, "DemoTarget")
|
||||||
#expect(model.sessionMode == .calibration)
|
XCTAssertEqual(model.sessionMode, .calibration)
|
||||||
#expect(model.stage == .layOutPrint)
|
XCTAssertEqual(model.stage, .layOutPrint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Testing
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import XCTest
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
|
|
||||||
private func artefacts(
|
private func artefacts(
|
||||||
@@ -16,77 +16,75 @@ private func artefacts(
|
|||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suite("WizardGating matrix")
|
final class WizardGatingTests: XCTestCase {
|
||||||
struct WizardGatingTests {
|
|
||||||
|
|
||||||
@Test func emptyProjectOnlyStage1() {
|
func testEmptyProjectOnlyStage1() {
|
||||||
let a = artefacts()
|
let a = artefacts()
|
||||||
#expect(WizardGating.isUnlocked(.generate, artefacts: a))
|
XCTAssertTrue(WizardGating.isUnlocked(.generate, artefacts: a))
|
||||||
#expect(WizardGating.isUnlocked(.calibrate, artefacts: a))
|
XCTAssertTrue(WizardGating.isUnlocked(.calibrate, artefacts: a))
|
||||||
for s in [WizardStage.layOutPrint, .measure, .buildProfile, .verifyInstall] {
|
for s in [WizardStage.layOutPrint, .measure, .buildProfile, .verifyInstall] {
|
||||||
#expect(!WizardGating.isUnlocked(s, artefacts: a), "\(s) should be locked")
|
XCTAssertFalse(WizardGating.isUnlocked(s, artefacts: a), "\(s) should be locked")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func ti1UnlocksStage2Only() {
|
func testTi1UnlocksStage2Only() {
|
||||||
let a = artefacts(ti1: true)
|
let a = artefacts(ti1: true)
|
||||||
#expect(WizardGating.isUnlocked(.layOutPrint, artefacts: a))
|
XCTAssertTrue(WizardGating.isUnlocked(.layOutPrint, artefacts: a))
|
||||||
#expect(!WizardGating.isUnlocked(.measure, artefacts: a))
|
XCTAssertFalse(WizardGating.isUnlocked(.measure, artefacts: a))
|
||||||
#expect(!WizardGating.isUnlocked(.buildProfile, artefacts: a))
|
XCTAssertFalse(WizardGating.isUnlocked(.buildProfile, artefacts: a))
|
||||||
#expect(!WizardGating.isUnlocked(.verifyInstall, artefacts: a))
|
XCTAssertFalse(WizardGating.isUnlocked(.verifyInstall, artefacts: a))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func stage3NeedsTi1AndTi2() {
|
func testStage3NeedsTi1AndTi2() {
|
||||||
#expect(!WizardGating.isUnlocked(.measure, artefacts: artefacts(ti2: true)))
|
XCTAssertFalse(WizardGating.isUnlocked(.measure, artefacts: artefacts(ti2: true)))
|
||||||
#expect(WizardGating.isUnlocked(.measure, artefacts: artefacts(ti1: true, ti2: true)))
|
XCTAssertTrue(WizardGating.isUnlocked(.measure, artefacts: artefacts(ti1: true, ti2: true)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func stage4NeedsTi3NotTi2() {
|
func testStage4NeedsTi3NotTi2() {
|
||||||
// #109/#110: .ti2 alone must never unlock Stage 4.
|
// #109/#110: .ti2 alone must never unlock Stage 4.
|
||||||
let a = artefacts(ti1: true, ti2: true)
|
let a = artefacts(ti1: true, ti2: true)
|
||||||
#expect(!WizardGating.isUnlocked(.buildProfile, artefacts: a))
|
XCTAssertFalse(WizardGating.isUnlocked(.buildProfile, artefacts: a))
|
||||||
#expect(WizardGating.isUnlocked(.buildProfile, artefacts: artefacts(ti3: true)))
|
XCTAssertTrue(WizardGating.isUnlocked(.buildProfile, artefacts: artefacts(ti3: true)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func stage5NeedsTi3AndProfile() {
|
func testStage5NeedsTi3AndProfile() {
|
||||||
#expect(!WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(ti3: true)))
|
XCTAssertFalse(WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(ti3: true)))
|
||||||
#expect(!WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(profile: true)))
|
XCTAssertFalse(WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(profile: true)))
|
||||||
#expect(WizardGating.isUnlocked(
|
XCTAssertTrue(WizardGating.isUnlocked(
|
||||||
.verifyInstall, artefacts: artefacts(ti3: true, profile: true)
|
.verifyInstall, artefacts: artefacts(ti3: true, profile: true)
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func forwardGatedBackwardFree() {
|
func testForwardGatedBackwardFree() {
|
||||||
let a = artefacts()
|
let a = artefacts()
|
||||||
#expect(!WizardGating.canNavigate(to: .layOutPrint, from: .generate, artefacts: a))
|
XCTAssertFalse(WizardGating.canNavigate(to: .layOutPrint, from: .generate, artefacts: a))
|
||||||
// Backward always allowed even when artefacts vanished.
|
// Backward always allowed even when artefacts vanished.
|
||||||
#expect(WizardGating.canNavigate(to: .generate, from: .measure, artefacts: a))
|
XCTAssertTrue(WizardGating.canNavigate(to: .generate, from: .measure, artefacts: a))
|
||||||
// Same stage is a no-op.
|
// Same stage is a no-op.
|
||||||
#expect(WizardGating.canNavigate(to: .measure, from: .measure, artefacts: a))
|
XCTAssertTrue(WizardGating.canNavigate(to: .measure, from: .measure, artefacts: a))
|
||||||
// Stage 0 is a side-trip, never gated.
|
// Stage 0 is a side-trip, never gated.
|
||||||
#expect(WizardGating.canNavigate(to: .calibrate, from: .generate, artefacts: a))
|
XCTAssertTrue(WizardGating.canNavigate(to: .calibrate, from: .generate, artefacts: a))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func deepestUnlocked() {
|
func testDeepestUnlocked() {
|
||||||
#expect(WizardGating.deepestUnlocked(artefacts: artefacts()) == .generate)
|
XCTAssertEqual(WizardGating.deepestUnlocked(artefacts: artefacts()), .generate)
|
||||||
#expect(WizardGating.deepestUnlocked(
|
XCTAssertEqual(WizardGating.deepestUnlocked(
|
||||||
artefacts: artefacts(ti1: true, ti2: true)
|
artefacts: artefacts(ti1: true, ti2: true)
|
||||||
) == .measure)
|
), .measure)
|
||||||
#expect(WizardGating.deepestUnlocked(
|
XCTAssertEqual(WizardGating.deepestUnlocked(
|
||||||
artefacts: artefacts(ti3: true, profile: true)
|
artefacts: artefacts(ti3: true, profile: true)
|
||||||
) == .verifyInstall)
|
), .verifyInstall)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suite("WizardStateStore")
|
final class WizardStateStoreTests: XCTestCase {
|
||||||
struct WizardStateStoreTests {
|
|
||||||
private func tempURL() -> URL {
|
private func tempURL() -> URL {
|
||||||
FileManager.default.temporaryDirectory
|
FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("iccery-wiz-\(UUID().uuidString)")
|
.appendingPathComponent("iccery-wiz-\(UUID().uuidString)")
|
||||||
.appendingPathComponent("wizard_state.json")
|
.appendingPathComponent("wizard_state.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func roundTrip() throws {
|
func testRoundTrip() throws {
|
||||||
let url = tempURL()
|
let url = tempURL()
|
||||||
let store = WizardStateStore(fileURL: url)
|
let store = WizardStateStore(fileURL: url)
|
||||||
var s = WizardState()
|
var s = WizardState()
|
||||||
@@ -97,43 +95,43 @@ struct WizardStateStoreTests {
|
|||||||
s.profileBasename = "imported"
|
s.profileBasename = "imported"
|
||||||
s.calibrationOriginalBasename = "pre-cal"
|
s.calibrationOriginalBasename = "pre-cal"
|
||||||
try store.save(s)
|
try store.save(s)
|
||||||
#expect(store.load() == s)
|
XCTAssertEqual(store.load(), s)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func missingFileDefaults() {
|
func testMissingFileDefaults() {
|
||||||
let s = WizardStateStore(fileURL: tempURL()).load()
|
let s = WizardStateStore(fileURL: tempURL()).load()
|
||||||
#expect(s == .default)
|
XCTAssertEqual(s, .default)
|
||||||
#expect(s.stage == .generate)
|
XCTAssertEqual(s.stage, .generate)
|
||||||
#expect(s.sessionMode == .profile)
|
XCTAssertEqual(s.sessionMode, .profile)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func corruptStageFallsBackToGenerate() throws {
|
func testCorruptStageFallsBackToGenerate() throws {
|
||||||
let url = tempURL()
|
let url = tempURL()
|
||||||
try FileManager.default.createDirectory(
|
try FileManager.default.createDirectory(
|
||||||
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||||
)
|
)
|
||||||
try #"{"current_stage": 99, "basename": "", "cwd": "", "session_mode": "profile"}"#
|
try #"{"current_stage": 99, "basename": "", "cwd": "", "session_mode": "profile"}"#
|
||||||
.write(to: url, atomically: true, encoding: .utf8)
|
.write(to: url, atomically: true, encoding: .utf8)
|
||||||
#expect(WizardStateStore(fileURL: url).load().stage == .generate)
|
XCTAssertEqual(WizardStateStore(fileURL: url).load().stage, .generate)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func corruptJsonReturnsDefaultAndKeepsBytes() throws {
|
func testCorruptJsonReturnsDefaultAndKeepsBytes() throws {
|
||||||
let url = tempURL()
|
let url = tempURL()
|
||||||
try FileManager.default.createDirectory(
|
try FileManager.default.createDirectory(
|
||||||
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||||
)
|
)
|
||||||
try "not json".write(to: url, atomically: true, encoding: .utf8)
|
try "not json".write(to: url, atomically: true, encoding: .utf8)
|
||||||
#expect(WizardStateStore(fileURL: url).load() == .default)
|
XCTAssertEqual(WizardStateStore(fileURL: url).load(), .default)
|
||||||
let kept = try String(contentsOf: url, encoding: .utf8)
|
let kept = try String(contentsOf: url, encoding: .utf8)
|
||||||
#expect(kept == "not json")
|
XCTAssertEqual(kept, "not json")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func sessionModeCalibrationRoundTrips() throws {
|
func testSessionModeCalibrationRoundTrips() throws {
|
||||||
var s = WizardState(sessionMode: .calibration)
|
var s = WizardState(sessionMode: .calibration)
|
||||||
let data = try JSONEncoder().encode(s)
|
let data = try JSONEncoder().encode(s)
|
||||||
let decoded = try JSONDecoder().decode(WizardState.self, from: data)
|
let decoded = try JSONDecoder().decode(WizardState.self, from: data)
|
||||||
#expect(decoded.sessionMode == .calibration)
|
XCTAssertEqual(decoded.sessionMode, .calibration)
|
||||||
s.sessionMode = .profile
|
s.sessionMode = .profile
|
||||||
#expect(s.sessionMode == .profile)
|
XCTAssertEqual(s.sessionMode, .profile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,12 @@ final class AboutHelpUITests: XCTestCase {
|
|||||||
launchApp()
|
launchApp()
|
||||||
|
|
||||||
let openAbout = app.buttons["openAboutBtn"]
|
let openAbout = app.buttons["openAboutBtn"]
|
||||||
XCTAssertTrue(openAbout.waitForExistence(timeout: 10))
|
if !openAbout.waitForExistence(timeout: 10) {
|
||||||
|
// CI triage (#128): print the a11y tree so an empty or
|
||||||
|
// unexpected hierarchy shows up directly in the job log.
|
||||||
|
print("AXTREE-BEGIN windows=\(app.windows.count)\n\(app.debugDescription)\nAXTREE-END")
|
||||||
|
}
|
||||||
|
XCTAssertTrue(openAbout.exists)
|
||||||
openAbout.click()
|
openAbout.click()
|
||||||
|
|
||||||
_ = waitFor("aboutVersion", timeout: 10)
|
_ = waitFor("aboutVersion", timeout: 10)
|
||||||
@@ -64,14 +69,20 @@ final class AboutHelpUITests: XCTestCase {
|
|||||||
let toggle = app.buttons["btnToggleAllHelp"]
|
let toggle = app.buttons["btnToggleAllHelp"]
|
||||||
XCTAssertTrue(toggle.waitForExistence(timeout: 10))
|
XCTAssertTrue(toggle.waitForExistence(timeout: 10))
|
||||||
|
|
||||||
let sidebar = app.groups.containing(.button, identifier: "openSettingsBtn").element
|
// SDK 13.1 emits no AXGroup for the sidebar root, and an
|
||||||
let before = sidebar.frame
|
// identifier on the container clobbers child identifiers
|
||||||
|
// (#130) — measure a stable sidebar child instead. Query the
|
||||||
|
// pop-up by type: the Picker's "Preset" label inherits the same
|
||||||
|
// identifier, so an .any query matches twice.
|
||||||
|
let sidebarChild = app.popUpButtons["presetSelect"]
|
||||||
|
XCTAssertTrue(sidebarChild.waitForExistence(timeout: 10))
|
||||||
|
let before = sidebarChild.frame
|
||||||
|
|
||||||
toggle.click()
|
toggle.click()
|
||||||
let after = sidebar.frame
|
let after = sidebarChild.frame
|
||||||
|
|
||||||
XCTAssertEqual(before.size.height, after.size.height,
|
XCTAssertEqual(before, after,
|
||||||
"Toggling global help must not reflow the sidebar height.")
|
"Toggling global help must not reflow the sidebar.")
|
||||||
XCTAssertTrue(app.descendants(matching: .any)["openSettingsBtn"].exists)
|
XCTAssertTrue(app.descendants(matching: .any)["openSettingsBtn"].exists)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+48
@@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Mock spotread for Milestone10SpotReadUITests.
|
||||||
|
|
||||||
|
Emits the real spotread prompt phrasing; each trigger line produces one
|
||||||
|
"Result is XYZ: …, D50 Lab: …" sample. Override the emitted colour with
|
||||||
|
MOCK_SPOTREAD_LAB / MOCK_SPOTREAD_XYZ. 'q' quits with exit 0.
|
||||||
|
Usage: spotread -v -e [-c port] [-Y l]
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
LAB = os.environ.get("MOCK_SPOTREAD_LAB", "51.9 -8.3 12.2")
|
||||||
|
XYZ = os.environ.get("MOCK_SPOTREAD_XYZ", "18.51 20.05 15.71")
|
||||||
|
|
||||||
|
|
||||||
|
def read_line():
|
||||||
|
try:
|
||||||
|
return sys.stdin.readline()
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("Spot read needs a calibration before continuing")
|
||||||
|
print("Place instrument on spot reading white calibration tile,")
|
||||||
|
print(" and then hit any key to continue,")
|
||||||
|
print("or hit Esc or Q to abort:")
|
||||||
|
sys.stdout.flush()
|
||||||
|
if not read_line():
|
||||||
|
return 0
|
||||||
|
print("Calibration successful.")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
print("Place instrument on a spot to be measured,")
|
||||||
|
print(" and hit a key to take a reading,")
|
||||||
|
print("or hit Esc or Q to abort:")
|
||||||
|
sys.stdout.flush()
|
||||||
|
line = read_line()
|
||||||
|
if not line:
|
||||||
|
return 0
|
||||||
|
if line.strip().lower().startswith("q"):
|
||||||
|
return 0
|
||||||
|
print("Result is XYZ: %s, D50 Lab: %s" % (XYZ, LAB))
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
/// Milestone 10 UI tests — issue #146 media recipe library. Mock CUPS
|
||||||
|
/// binaries (`ICCERY_CUPS_BIN_DIR` → `Fixtures/bin`) emit
|
||||||
|
/// `Mock_Epson_7450` / `Mock_Canon_Pro`; recipes are seeded by writing
|
||||||
|
/// `<ICCERY_TEST_ROOT>/AppData/media_library.json` before launch —
|
||||||
|
/// `AppPaths` redirects app data under `ICCERY_TEST_ROOT`. All queries
|
||||||
|
/// are by identifier only ("Media" also appears in help overlays).
|
||||||
|
@MainActor
|
||||||
|
final class Milestone10MediaLibraryUITests: XCTestCase {
|
||||||
|
|
||||||
|
private var app: XCUIApplication!
|
||||||
|
private var testRoot: URL!
|
||||||
|
private var binDir: URL!
|
||||||
|
|
||||||
|
override func setUp() async throws {
|
||||||
|
continueAfterFailure = false
|
||||||
|
testRoot = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("iccery-ui10-\(UUID().uuidString)")
|
||||||
|
binDir = URL(fileURLWithPath: #filePath)
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.appendingPathComponent("Fixtures/bin")
|
||||||
|
|
||||||
|
let appData = testRoot.appendingPathComponent("AppData", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: appData, withIntermediateDirectories: true)
|
||||||
|
// A recipe bound to a queue that is never enumerated.
|
||||||
|
let fixture = """
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "fixture-missing-queue",
|
||||||
|
"name": "Missing Queue Recipe",
|
||||||
|
"notes": "",
|
||||||
|
"printer_id": "No_Such_Queue",
|
||||||
|
"printer_display_name": "Missing Queue",
|
||||||
|
"paper_name": "Rag",
|
||||||
|
"ink_set": "PK",
|
||||||
|
"colour_space": "rgb",
|
||||||
|
"preset_id": "preset-std-rgb",
|
||||||
|
"calibration_url": null,
|
||||||
|
"apply_calibration": false,
|
||||||
|
"created": "2026-09-12T00:00:00Z",
|
||||||
|
"updated": "2026-09-12T00:00:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
"""
|
||||||
|
try fixture.write(
|
||||||
|
to: appData.appendingPathComponent("media_library.json"),
|
||||||
|
atomically: true, encoding: .utf8)
|
||||||
|
|
||||||
|
app = XCUIApplication()
|
||||||
|
app.launchEnvironment = [
|
||||||
|
"ICCERY_UI_TESTING": "1",
|
||||||
|
"ICCERY_TEST_ROOT": testRoot.path,
|
||||||
|
"ICCERY_ARGYLL_BINARY_DIR": binDir.path,
|
||||||
|
"ICCERY_CUPS_BIN_DIR": binDir.path,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() async throws {
|
||||||
|
app?.terminate()
|
||||||
|
app = nil
|
||||||
|
if let testRoot {
|
||||||
|
try? FileManager.default.removeItem(at: testRoot)
|
||||||
|
}
|
||||||
|
testRoot = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func launchApp() {
|
||||||
|
app.launch()
|
||||||
|
if !app.wait(for: .runningForeground, timeout: 10) {
|
||||||
|
app.activate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func element(_ id: String) -> XCUIElement {
|
||||||
|
let inApp = app.descendants(matching: .any)[id].firstMatch
|
||||||
|
if inApp.exists { return inApp }
|
||||||
|
return app.sheets.firstMatch.descendants(matching: .any)[id].firstMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitFor(_ id: String, timeout: TimeInterval = 10) -> XCUIElement {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
let el = element(id)
|
||||||
|
if el.exists { return el }
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
|
}
|
||||||
|
let el = element(id)
|
||||||
|
XCTAssertTrue(el.exists, "Expected element \(id)")
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitForEnabled(_ id: String, timeout: TimeInterval = 15) -> XCUIElement {
|
||||||
|
let el = waitFor(id, timeout: timeout)
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
if el.isEnabled { return el }
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
|
}
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMediaPickerDoesNotReusePresetSelect() throws {
|
||||||
|
launchApp()
|
||||||
|
|
||||||
|
let preset = app.popUpButtons["presetSelect"]
|
||||||
|
XCTAssertTrue(preset.waitForExistence(timeout: 10))
|
||||||
|
let media = app.popUpButtons["mediaSelect"]
|
||||||
|
XCTAssertTrue(media.waitForExistence(timeout: 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCaptureRequiresNamePaperInk() throws {
|
||||||
|
launchApp()
|
||||||
|
|
||||||
|
// Capture enables once the mock CUPS enumeration selects a queue.
|
||||||
|
let capture = waitForEnabled("btnMediaLibraryCapture")
|
||||||
|
XCTAssertTrue(capture.isEnabled)
|
||||||
|
capture.click()
|
||||||
|
|
||||||
|
_ = waitFor("saveMediaRecipeDialog")
|
||||||
|
let save = element("btnConfirmSaveMedia")
|
||||||
|
XCTAssertTrue(save.exists)
|
||||||
|
XCTAssertFalse(save.isEnabled)
|
||||||
|
|
||||||
|
for (id, text) in [
|
||||||
|
("saveMediaName", "UI Recipe"),
|
||||||
|
("saveMediaPaper", "Rag"),
|
||||||
|
("saveMediaInk", "PK"),
|
||||||
|
] {
|
||||||
|
let field = element(id)
|
||||||
|
field.click()
|
||||||
|
field.typeText(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertTrue(save.isEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testManageApplyMissingPrinterShowsBanner() throws {
|
||||||
|
launchApp()
|
||||||
|
|
||||||
|
let manage = app.buttons["btnMediaLibraryManage"]
|
||||||
|
XCTAssertTrue(manage.waitForExistence(timeout: 10))
|
||||||
|
manage.click()
|
||||||
|
_ = waitFor("manageMediaDialog")
|
||||||
|
|
||||||
|
let apply = element("btnMediaLibraryApply-fixture-missing-queue")
|
||||||
|
XCTAssertTrue(apply.waitForExistence(timeout: 10))
|
||||||
|
apply.click()
|
||||||
|
|
||||||
|
let notice = waitFor("noticeText")
|
||||||
|
let text = (notice.value as? String) ?? notice.label
|
||||||
|
XCTAssertTrue(
|
||||||
|
text.contains("is not installed"),
|
||||||
|
"expected not-installed notice, got: \(text)")
|
||||||
|
|
||||||
|
// A failed apply keeps the manage sheet open and the sidebar
|
||||||
|
// picker reverts.
|
||||||
|
XCTAssertTrue(element("manageMediaDialog").exists)
|
||||||
|
XCTAssertTrue(element("mediaSelect").exists)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
/// Milestone 10 UI tests — issue #148 spot-read console. Mock Argyll
|
||||||
|
/// sidecars (`ICCERY_ARGYLL_BINARY_DIR` → `Fixtures/bin`) provide
|
||||||
|
/// `instlist`, `chartread`, and `spotread`; no real USB Detect is ever
|
||||||
|
/// clicked. All queries are by identifier only.
|
||||||
|
@MainActor
|
||||||
|
final class Milestone10SpotReadUITests: XCTestCase {
|
||||||
|
|
||||||
|
private var app: XCUIApplication!
|
||||||
|
private var testRoot: URL!
|
||||||
|
private var binDir: URL!
|
||||||
|
private var workDir: URL!
|
||||||
|
|
||||||
|
override func setUp() async throws {
|
||||||
|
continueAfterFailure = false
|
||||||
|
testRoot = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("iccery-ui10spot-\(UUID().uuidString)")
|
||||||
|
binDir = URL(fileURLWithPath: #filePath)
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.appendingPathComponent("Fixtures/bin")
|
||||||
|
workDir = testRoot.appendingPathComponent("work")
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: workDir, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
app = XCUIApplication()
|
||||||
|
app.launchEnvironment = [
|
||||||
|
"ICCERY_UI_TESTING": "1",
|
||||||
|
"ICCERY_TEST_ROOT": testRoot.path,
|
||||||
|
"ICCERY_ARGYLL_BINARY_DIR": binDir.path,
|
||||||
|
// Redirect the bundled root too so the real sidecars copied
|
||||||
|
// into the product by the build phase cannot mask a missing
|
||||||
|
// override binary (`testMissingSidecarShowsMessage`).
|
||||||
|
"ICCERY_ARGYLL_BUNDLED_ROOT": binDir.path,
|
||||||
|
"ICCERY_CUPS_BIN_DIR": binDir.path,
|
||||||
|
"ICCERY_TEST_SAVE_TARGET":
|
||||||
|
workDir.appendingPathComponent("mytarget.ti1").path,
|
||||||
|
"ICCERY_TEST_WORKDIR": workDir.path,
|
||||||
|
"ICCERY_TEST_CSV_EXPORT":
|
||||||
|
workDir.appendingPathComponent("spot-history.csv").path,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() async throws {
|
||||||
|
app?.terminate()
|
||||||
|
app = nil
|
||||||
|
if let testRoot {
|
||||||
|
try? FileManager.default.removeItem(at: testRoot)
|
||||||
|
}
|
||||||
|
testRoot = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func launchApp() {
|
||||||
|
app.launch()
|
||||||
|
if !app.wait(for: .runningForeground, timeout: 10) {
|
||||||
|
app.activate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seed `wizard_state.json` with a working directory so `btnSpotRead`
|
||||||
|
/// is enabled without driving the whole Stage 1/2 flow.
|
||||||
|
private func seedWorkingDirectory() throws {
|
||||||
|
let appData = testRoot.appendingPathComponent("AppData", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: appData, withIntermediateDirectories: true)
|
||||||
|
let state = """
|
||||||
|
{
|
||||||
|
"currentStage": 0,
|
||||||
|
"basename": "spotui",
|
||||||
|
"cwd": "\(workDir.path)",
|
||||||
|
"sessionMode": "profile"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try state.write(
|
||||||
|
to: appData.appendingPathComponent("wizard_state.json"),
|
||||||
|
atomically: true, encoding: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func element(_ id: String) -> XCUIElement {
|
||||||
|
let inApp = app.descendants(matching: .any)[id].firstMatch
|
||||||
|
if inApp.exists { return inApp }
|
||||||
|
return app.sheets.firstMatch.descendants(matching: .any)[id].firstMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitFor(_ id: String, timeout: TimeInterval = 10) -> XCUIElement {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
let el = element(id)
|
||||||
|
if el.exists { return el }
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
|
}
|
||||||
|
let el = element(id)
|
||||||
|
XCTAssertTrue(el.exists, "Expected element \(id)")
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sidebar gating
|
||||||
|
|
||||||
|
func testSpotReadButtonDisabledWithoutCwd() throws {
|
||||||
|
launchApp()
|
||||||
|
let button = app.buttons["btnSpotRead"]
|
||||||
|
XCTAssertTrue(button.waitForExistence(timeout: 10))
|
||||||
|
XCTAssertFalse(button.isEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSpotReadButtonDisabledDuringChartread() throws {
|
||||||
|
launchApp()
|
||||||
|
// Drive to Stage 3 with the mock targen/printtarg fixtures.
|
||||||
|
app.buttons["btnBrowse"].click()
|
||||||
|
app.buttons["btnGenerate"].click()
|
||||||
|
_ = waitFor("btnCreateLayout", timeout: 20)
|
||||||
|
app.buttons["btnCreateLayout"].click()
|
||||||
|
_ = waitFor("galleryPage-0", timeout: 20)
|
||||||
|
_ = waitFor("btnAdvanceToStage3", timeout: 10)
|
||||||
|
app.buttons["btnAdvanceToStage3"].click()
|
||||||
|
_ = waitFor("stage3TargetBasename", timeout: 10)
|
||||||
|
|
||||||
|
// Start the mock chartread — it blocks on the calibrate prompt.
|
||||||
|
app.buttons["btnStartRead"].click()
|
||||||
|
_ = waitFor("btnCalibrate", timeout: 25)
|
||||||
|
|
||||||
|
let button = app.buttons["btnSpotRead"]
|
||||||
|
XCTAssertTrue(button.exists)
|
||||||
|
XCTAssertFalse(button.isEnabled)
|
||||||
|
|
||||||
|
// Clean up the live chartread child before teardown.
|
||||||
|
if app.buttons["btnCancel"].exists {
|
||||||
|
app.buttons["btnCancel"].click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sheet contract
|
||||||
|
|
||||||
|
func testSheetHasOwnInstrumentIds() throws {
|
||||||
|
try seedWorkingDirectory()
|
||||||
|
launchApp()
|
||||||
|
|
||||||
|
let button = app.buttons["btnSpotRead"]
|
||||||
|
XCTAssertTrue(button.waitForExistence(timeout: 10))
|
||||||
|
let deadline = Date().addingTimeInterval(10)
|
||||||
|
while !button.isEnabled, Date() < deadline {
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
|
}
|
||||||
|
XCTAssertTrue(button.isEnabled)
|
||||||
|
button.click()
|
||||||
|
|
||||||
|
_ = waitFor("spotReadView", timeout: 10)
|
||||||
|
XCTAssertTrue(element("spotInstrumentSelect").waitForExistence(timeout: 10))
|
||||||
|
// Stage 3 ids must not appear inside the sheet.
|
||||||
|
XCTAssertFalse(
|
||||||
|
app.sheets.firstMatch.descendants(matching: .any)["chartreadInstrumentSelect"].exists)
|
||||||
|
XCTAssertFalse(
|
||||||
|
app.sheets.firstMatch.descendants(matching: .any)["btnDetectInstruments"].exists)
|
||||||
|
XCTAssertTrue(element("btnCloseSpotRead").exists)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMissingSidecarShowsMessage() throws {
|
||||||
|
// Point the override at an empty dir; the bundled root has no
|
||||||
|
// real sidecars in this checkout, so resolve() misses both.
|
||||||
|
let emptyBin = testRoot.appendingPathComponent("empty-bin")
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: emptyBin, withIntermediateDirectories: true)
|
||||||
|
app.launchEnvironment["ICCERY_ARGYLL_BINARY_DIR"] = emptyBin.path
|
||||||
|
try seedWorkingDirectory()
|
||||||
|
launchApp()
|
||||||
|
|
||||||
|
let button = app.buttons["btnSpotRead"]
|
||||||
|
XCTAssertTrue(button.waitForExistence(timeout: 10))
|
||||||
|
let deadline = Date().addingTimeInterval(10)
|
||||||
|
while !button.isEnabled, Date() < deadline {
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
|
}
|
||||||
|
button.click()
|
||||||
|
|
||||||
|
_ = waitFor("spotReadView", timeout: 10)
|
||||||
|
XCTAssertTrue(element("spotSidecarMissing").waitForExistence(timeout: 10))
|
||||||
|
XCTAssertFalse(element("btnSpotStart").exists)
|
||||||
|
XCTAssertFalse(element("btnSpotDetectInstruments").exists)
|
||||||
|
XCTAssertTrue(element("btnCloseSpotRead").exists)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testHistoryCopyDisabledWhenEmpty() throws {
|
||||||
|
try seedWorkingDirectory()
|
||||||
|
launchApp()
|
||||||
|
|
||||||
|
let button = app.buttons["btnSpotRead"]
|
||||||
|
XCTAssertTrue(button.waitForExistence(timeout: 10))
|
||||||
|
let deadline = Date().addingTimeInterval(10)
|
||||||
|
while !button.isEnabled, Date() < deadline {
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
|
}
|
||||||
|
button.click()
|
||||||
|
|
||||||
|
_ = waitFor("spotReadView", timeout: 10)
|
||||||
|
XCTAssertTrue(element("spotHistoryEmpty").waitForExistence(timeout: 10))
|
||||||
|
XCTAssertTrue(element("spotLastEmpty").exists)
|
||||||
|
XCTAssertFalse(element("btnSpotCopyLab").isEnabled)
|
||||||
|
XCTAssertFalse(element("btnSpotExportCsv").isEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full mock session: Start → Calibrate → Read produces one Lab
|
||||||
|
/// sample and enables Copy/Export.
|
||||||
|
func testMockSessionProducesSample() throws {
|
||||||
|
try seedWorkingDirectory()
|
||||||
|
launchApp()
|
||||||
|
|
||||||
|
let button = app.buttons["btnSpotRead"]
|
||||||
|
XCTAssertTrue(button.waitForExistence(timeout: 10))
|
||||||
|
let deadline = Date().addingTimeInterval(10)
|
||||||
|
while !button.isEnabled, Date() < deadline {
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
|
}
|
||||||
|
button.click()
|
||||||
|
|
||||||
|
_ = waitFor("spotReadView", timeout: 10)
|
||||||
|
let start = element("btnSpotStart")
|
||||||
|
XCTAssertTrue(start.waitForExistence(timeout: 10))
|
||||||
|
start.click()
|
||||||
|
|
||||||
|
XCTAssertTrue(element("btnSpotCalibrate").waitForExistence(timeout: 15))
|
||||||
|
element("btnSpotCalibrate").click()
|
||||||
|
|
||||||
|
XCTAssertTrue(element("btnSpotTrigger").waitForExistence(timeout: 15))
|
||||||
|
element("btnSpotTrigger").click()
|
||||||
|
|
||||||
|
XCTAssertTrue(element("spotLastSample").waitForExistence(timeout: 15))
|
||||||
|
XCTAssertTrue(element("spotLabL").exists)
|
||||||
|
XCTAssertTrue(element("spotSwatch").exists)
|
||||||
|
XCTAssertTrue(element("btnSpotCopyLab").isEnabled)
|
||||||
|
XCTAssertTrue(element("btnSpotExportCsv").isEnabled)
|
||||||
|
|
||||||
|
element("btnSpotStop").click()
|
||||||
|
element("btnCloseSpotRead").click()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -85,6 +85,16 @@ final class Milestone2UITests: XCTestCase {
|
|||||||
return el
|
return el
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Assert an element stays absent after a short dwell — unlike
|
||||||
|
/// `waitForExistence`, which always burns its full timeout on the
|
||||||
|
/// negative path.
|
||||||
|
private func assertAbsent(_ el: XCUIElement, dwell: TimeInterval = 0.5,
|
||||||
|
_ message: String = "expected element to stay absent",
|
||||||
|
file: StaticString = #filePath, line: UInt = #line) {
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(dwell))
|
||||||
|
XCTAssertFalse(el.exists, message, file: file, line: line)
|
||||||
|
}
|
||||||
|
|
||||||
private func staticText(_ exact: String) -> XCUIElement {
|
private func staticText(_ exact: String) -> XCUIElement {
|
||||||
let inApp = app.staticTexts[exact]
|
let inApp = app.staticTexts[exact]
|
||||||
if inApp.exists { return inApp }
|
if inApp.exists { return inApp }
|
||||||
@@ -316,7 +326,7 @@ final class Milestone2UITests: XCTestCase {
|
|||||||
"identifier BEGINSWITH 'btnDeletePreset-'")
|
"identifier BEGINSWITH 'btnDeletePreset-'")
|
||||||
XCTAssertTrue(deleteButtons.firstMatch.waitForExistence(timeout: 5))
|
XCTAssertTrue(deleteButtons.firstMatch.waitForExistence(timeout: 5))
|
||||||
deleteButtons.firstMatch.click()
|
deleteButtons.firstMatch.click()
|
||||||
XCTAssertFalse(staticText("UI Test Preset").waitForExistence(timeout: 3))
|
assertAbsent(staticText("UI Test Preset"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Export a preset to JSON and re-import it (issue #11).
|
/// Export a preset to JSON and re-import it (issue #11).
|
||||||
@@ -348,7 +358,7 @@ final class Milestone2UITests: XCTestCase {
|
|||||||
let deleteButtons = buttonsMatching(
|
let deleteButtons = buttonsMatching(
|
||||||
"identifier BEGINSWITH 'btnDeletePreset-'")
|
"identifier BEGINSWITH 'btnDeletePreset-'")
|
||||||
deleteButtons.firstMatch.click()
|
deleteButtons.firstMatch.click()
|
||||||
XCTAssertFalse(staticText("RoundTrip").waitForExistence(timeout: 3))
|
assertAbsent(staticText("RoundTrip"))
|
||||||
|
|
||||||
// Copy the export to the import path so the hook picks it up.
|
// Copy the export to the import path so the hook picks it up.
|
||||||
try FileManager.default.copyItem(at: exportURL, to: importURL)
|
try FileManager.default.copyItem(at: exportURL, to: importURL)
|
||||||
|
|||||||
@@ -198,7 +198,37 @@ final class Milestone3UITests: XCTestCase {
|
|||||||
}
|
}
|
||||||
XCTAssertTrue(app.buttons["btnPrintAll"].isEnabled)
|
XCTAssertTrue(app.buttons["btnPrintAll"].isEnabled)
|
||||||
|
|
||||||
app.buttons["btnPrintPage-0"].click()
|
// The gallery cell's Print button sits at the window's bottom
|
||||||
|
// edge where synthesized scroll-wheel events are inert on the
|
||||||
|
// LazyVGrid (#132). Drag the NSScrollView's vertical AXScrollBar
|
||||||
|
// thumb instead — a real scroll that re-renders the cell onscreen.
|
||||||
|
var printPage = app.buttons["btnPrintPage-0"]
|
||||||
|
let scrollDeadline = Date().addingTimeInterval(15)
|
||||||
|
while !printPage.isHittable, Date() < scrollDeadline {
|
||||||
|
let scroller = app.scrollBars.allElementsBoundByIndex
|
||||||
|
.first { $0.frame.height > $0.frame.width }
|
||||||
|
if let scroller {
|
||||||
|
scroller.coordinate(withNormalizedOffset:
|
||||||
|
CGVector(dx: 0.5, dy: 0.1))
|
||||||
|
.press(forDuration: 0.1, thenDragTo:
|
||||||
|
scroller.coordinate(withNormalizedOffset:
|
||||||
|
CGVector(dx: 0.5, dy: 0.6)))
|
||||||
|
} else {
|
||||||
|
app.scrollViews["stage-2"].scroll(byDeltaX: 0, deltaY: -1)
|
||||||
|
}
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
|
||||||
|
printPage = app.buttons["btnPrintPage-0"]
|
||||||
|
}
|
||||||
|
if printPage.isHittable {
|
||||||
|
printPage.click()
|
||||||
|
} else {
|
||||||
|
// LazyVGrid cells can report a stale a11y frame — click the
|
||||||
|
// point directly; the lp argv assert below still verifies.
|
||||||
|
print("AXTREE-BEGIN frame=\(printPage.frame)\n" +
|
||||||
|
"\(app.debugDescription)\nAXTREE-END")
|
||||||
|
printPage.coordinate(withNormalizedOffset:
|
||||||
|
CGVector(dx: 0.5, dy: 0.5)).click()
|
||||||
|
}
|
||||||
let argv = waitForLpLine()
|
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)
|
||||||
|
|||||||
@@ -111,16 +111,9 @@ final class Milestone4UITests: XCTestCase {
|
|||||||
}
|
}
|
||||||
app.buttons["btnCalibrate"].click()
|
app.buttons["btnCalibrate"].click()
|
||||||
|
|
||||||
// Trigger strip A.
|
// Trigger each strip until all are read → Done & Save appears.
|
||||||
_ = waitFor("btnTrigger", timeout: 20)
|
driveStripsUntilDone()
|
||||||
app.buttons["btnTrigger"].click()
|
XCTAssertTrue(element("btnDoneRead").exists)
|
||||||
|
|
||||||
// Trigger strip B.
|
|
||||||
_ = waitFor("btnTrigger", timeout: 20)
|
|
||||||
app.buttons["btnTrigger"].click()
|
|
||||||
|
|
||||||
// All strips read → Done & Save appears.
|
|
||||||
_ = waitFor("btnDoneRead", timeout: 20)
|
|
||||||
app.buttons["btnDoneRead"].firstMatch.click()
|
app.buttons["btnDoneRead"].firstMatch.click()
|
||||||
|
|
||||||
// Averaging panel appears with one pass snapshot.
|
// Averaging panel appears with one pass snapshot.
|
||||||
@@ -186,11 +179,21 @@ final class Milestone4UITests: XCTestCase {
|
|||||||
start.click()
|
start.click()
|
||||||
_ = waitFor("btnCalibrate", timeout: 25)
|
_ = waitFor("btnCalibrate", timeout: 25)
|
||||||
app.buttons["btnCalibrate"].click()
|
app.buttons["btnCalibrate"].click()
|
||||||
_ = waitFor("btnTrigger", timeout: 20)
|
driveStripsUntilDone()
|
||||||
app.buttons["btnTrigger"].click()
|
XCTAssertTrue(element("btnDoneRead").exists)
|
||||||
_ = waitFor("btnTrigger", timeout: 20)
|
|
||||||
app.buttons["btnTrigger"].click()
|
|
||||||
_ = waitFor("btnDoneRead", timeout: 20)
|
|
||||||
app.buttons["btnDoneRead"].firstMatch.click()
|
app.buttons["btnDoneRead"].firstMatch.click()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clicks Trigger for each remaining strip until `btnDoneRead`
|
||||||
|
/// appears — the button is re-polled each pass so a click that
|
||||||
|
/// races a state transition isn't lost.
|
||||||
|
private func driveStripsUntilDone() {
|
||||||
|
let deadline = Date().addingTimeInterval(40)
|
||||||
|
while !element("btnDoneRead").exists, Date() < deadline {
|
||||||
|
if app.buttons["btnTrigger"].waitForExistence(timeout: 10) {
|
||||||
|
app.buttons["btnTrigger"].click()
|
||||||
|
}
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,7 +76,14 @@ final class Milestone6CalibrationUITests: XCTestCase {
|
|||||||
// After generation the wizard should advance to Stage 2 (layout) because
|
// After generation the wizard should advance to Stage 2 (layout) because
|
||||||
// a CAL_ .ti1 now exists and the session is in calibration mode.
|
// a CAL_ .ti1 now exists and the session is in calibration mode.
|
||||||
let layout = app.buttons["btnCreateLayout"]
|
let layout = app.buttons["btnCreateLayout"]
|
||||||
XCTAssertTrue(layout.waitForExistence(timeout: 25))
|
if !layout.waitForExistence(timeout: 25) {
|
||||||
|
// The generate tap can be dropped while the dashboard is still
|
||||||
|
// settling after the stage transition; retry once before failing.
|
||||||
|
if calGenerate.waitForExistence(timeout: 2) {
|
||||||
|
calGenerate.tap()
|
||||||
|
}
|
||||||
|
XCTAssertTrue(layout.waitForExistence(timeout: 25))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A failing calibration targen surfaces the error through the
|
/// A failing calibration targen surfaces the error through the
|
||||||
|
|||||||
@@ -695,7 +695,7 @@ Consumes ICC/ICM. Produces `{stem}.gam` next to the profile (Argyll default). Bu
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `dispwin` | Never spawned. ICCery#90 “Emissive display calibration (dispwin & dispread)” = Won't Fix |
|
| `dispwin` | Never spawned. ICCery#90 “Emissive display calibration (dispwin & dispread)” = Won't Fix |
|
||||||
| `dispread` | Same |
|
| `dispread` | Same |
|
||||||
| `spotread`, `dispcal`, `collink`, `cctiff`, `spec2cie`, `illumread`, `synthacc` | Not referenced |
|
| `dispcal`, `collink`, `cctiff`, `spec2cie`, `illumread`, `synthacc` | Not referenced |
|
||||||
| Generic `spawn_process` | **Registered** (`lib.rs:55`, `commands.rs:6–14`) but **no JS caller**. Always `cwd=None`. Exists as an escape hatch |
|
| Generic `spawn_process` | **Registered** (`lib.rs:55`, `commands.rs:6–14`) but **no JS caller**. Always `cwd=None`. Exists as an escape hatch |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -725,6 +725,7 @@ The `Child` itself lives only in the wait task (not in a map) so `wait()` cannot
|
|||||||
| `profcheck_{ti3_path}` | profcheck (full path) |
|
| `profcheck_{ti3_path}` | profcheck (full path) |
|
||||||
| `iccgamut_{stem}` | iccgamut |
|
| `iccgamut_{stem}` | iccgamut |
|
||||||
| `instlist` | instlist (literal) |
|
| `instlist` | instlist (literal) |
|
||||||
|
| `spotread` | spotread (literal, issue #148) |
|
||||||
| caller-supplied | unused `spawn_process` |
|
| caller-supplied | unused `spawn_process` |
|
||||||
|
|
||||||
### 12.3 Duplicate rejection (ICCery#116, `07d28eb`)
|
### 12.3 Duplicate rejection (ICCery#116, `07d28eb`)
|
||||||
@@ -830,6 +831,7 @@ From `lib.rs:54–119` plus the command bodies:
|
|||||||
| `run_profcheck` | profcheck | `profcheck_{ti3_path}` |
|
| `run_profcheck` | profcheck | `profcheck_{ti3_path}` |
|
||||||
| `extract_gamut` | iccgamut | `iccgamut_{stem}` |
|
| `extract_gamut` | iccgamut | `iccgamut_{stem}` |
|
||||||
| `detect_instruments` | instlist | `instlist` |
|
| `detect_instruments` | instlist | `instlist` |
|
||||||
|
| `run_spotread` | spotread (`-v -e [-c port] [-Y l]`, no `-u`) | `spotread` |
|
||||||
| `generate_calibration_target` | targen | `targen_{CAL_basename}` |
|
| `generate_calibration_target` | targen | `targen_{CAL_basename}` |
|
||||||
|
|
||||||
### Argyll runners (captured, no events)
|
### Argyll runners (captured, no events)
|
||||||
|
|||||||
@@ -80,3 +80,14 @@ If an unpatched binary rejects `-Y l`, capture last stderr line and expand Proce
|
|||||||
## Interactive buttons vs real keys
|
## Interactive buttons vs real keys
|
||||||
|
|
||||||
See [05](05-argyll-fork.md) §12. Real strip-mode keys are `f/b/n/d/q`, Space, Return, `y/n`. UI labels "Skip" / "Undo" send `s\n` / `u\n` which the **mock** understands; upstream strip mode treats unknown letters as trigger. Preserve current UI behaviour or document a protocol change — do not silently change what bytes are sent without updating tests.
|
See [05](05-argyll-fork.md) §12. Real strip-mode keys are `f/b/n/d/q`, Space, Return, `y/n`. UI labels "Skip" / "Undo" send `s\n` / `u\n` which the **mock** understands; upstream strip mode treats unknown letters as trigger. Preserve current UI behaviour or document a protocol change — do not silently change what bytes are sent without updating tests.
|
||||||
|
|
||||||
|
## Spot Read (issue #148)
|
||||||
|
|
||||||
|
The Spot Read sheet (`btnSpotRead` in the sidebar) runs the bundled
|
||||||
|
`spotread` sidecar under the single-lease process id `spotread` — it is
|
||||||
|
**not** Stage 3 and shares no identifiers or process ids with
|
||||||
|
`chartread`. Stage 3 is unchanged: `chartread_{basename}` remains the
|
||||||
|
only chart path. `spotread` argv is `-v -e [-c port] [-Y l]` — never
|
||||||
|
`-u` (the v2.0 `-u` policy covers printtarg + chartread + profcheck
|
||||||
|
only). Stdin reuses the `chartread` byte table (`" \n"` trigger,
|
||||||
|
`"q\n"` quit + ~500 ms + kill).
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -67,3 +67,25 @@ Built-ins cannot be deleted. Custom presets overlay by `id`. Import/export is JS
|
|||||||
All four: `instrument: "i1"`, `colprof_algorithm: "l"`, `random_seed: 1`, `no_randomize: false`, `colprof_fwa: "D50"`.
|
All four: `instrument: "i1"`, `colprof_algorithm: "l"`, `random_seed: 1`, `no_randomize: false`, `colprof_fwa: "D50"`.
|
||||||
|
|
||||||
UI: `#presetSelect`, `#btnSavePresetModal` → `#savePresetDialog` (`savePresetName`, `savePresetDesc`, `btnConfirmSavePreset`), `#btnOpenPresetsDialog` → `#managePresetsDialog` (`managePresetsList`, `btnExportActivePreset`, `btnImportPreset`).
|
UI: `#presetSelect`, `#btnSavePresetModal` → `#savePresetDialog` (`savePresetName`, `savePresetDesc`, `btnConfirmSavePreset`), `#btnOpenPresetsDialog` → `#managePresetsDialog` (`managePresetsList`, `btnExportActivePreset`, `btnImportPreset`).
|
||||||
|
|
||||||
|
## Media library (`media_library.json`)
|
||||||
|
|
||||||
|
Persisted at `{app_data}/media_library.json` — a sibling of `settings.json`, never a field inside it (issue #146). A `MediaRecipe` binds a CUPS queue + paper + ink set + optional `.cal` to a `ProfilingPreset`. Cap: 200 entries; the 201st is refused with an error, never silently evicted. Corrupt JSON → keep the file, load `[]`, persistent warning banner.
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|-------|------|-------|
|
||||||
|
| `id` | string | `recipe-<uuid>`, never user-typed |
|
||||||
|
| `name`, `notes` | string | Rendered through `Text` only (#114) |
|
||||||
|
| `printer_id` | string | CUPS queue id (`lpstat -e` name) |
|
||||||
|
| `printer_display_name` | string | Human label; applied to `wizard.printerName` |
|
||||||
|
| `paper_name`, `ink_set` | string | Library metadata only — never written to targen flags |
|
||||||
|
| `driver_media_type` | string? | Last captured CUPS `media_type` (read-only) |
|
||||||
|
| `colour_space` | `"rgb"` \| `"cmyk"` | Must match the bound preset |
|
||||||
|
| `preset_id` | string | `ProfilingPreset.id` (built-in or custom) |
|
||||||
|
| `calibration_url` | string? | Absolute `.cal` path, stored verbatim |
|
||||||
|
| `apply_calibration` | bool | Forced off for `CAL_` stems or missing files |
|
||||||
|
| `created`, `updated` | iso8601 | |
|
||||||
|
|
||||||
|
Apply path: recipe → `applyPreset` (#82 mapping, no second Stage 1 form) → queue re-enumerated (`lpstat -e`) → `printer_id` absent from a non-empty list warns "not installed" and leaves the queue untouched; an empty list is indeterminate and never flags. `CAL_` bound cal **or** a live `CAL_` wizard basename forces `applyCalibration` off — `printtarg -K` can never see a `CAL_` file (literal refusal; escape hatch is rename + re-capture). Staleness: `.printer` = absent from enumerated queues; `.calibration` = bound cal `CREATED + calibration_stale_days < now`.
|
||||||
|
|
||||||
|
Capture with no preset selected auto-snapshots the live form as a `custom-` preset and binds to it. UI: `#mediaSelect` (immediate apply, `#presetSelect`-style), `#btnMediaLibraryCapture` → `#saveMediaRecipeDialog`, `#btnMediaLibraryManage` → `#manageMediaDialog` (`mediaLibraryList`, `mediaRow-{id}`, `btnMediaLibraryApply-{id}`, `btnMediaLibraryDelete-{id}`), stale badge `#mediaRecipeStale`.
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ Do not create issues until labels and milestones exist.
|
|||||||
- Artefact gating on disk; atomic writes (`.tmp` + rename); user strings via SwiftUI `Text` only.
|
- Artefact gating on disk; atomic writes (`.tmp` + rename); user strings via SwiftUI `Text` only.
|
||||||
- Branching: `develop` ← `milestone/mN-<name>` ← `feat/<issue#>-<slug>`; PRs via Gitea MCP.
|
- Branching: `develop` ← `milestone/mN-<name>` ← `feat/<issue#>-<slug>`; PRs via Gitea MCP.
|
||||||
- Labels: every issue/PR has `Project/ICCery-v2` + one `Feature/*` or `Bug/*` + `Priority/*`.
|
- Labels: every issue/PR has `Project/ICCery-v2` + one `Feature/*` or `Bug/*` + `Priority/*`.
|
||||||
- Verify: `xcodebuild test -scheme ICCery -destination 'platform=macOS' ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO`; sidecar `codesign -dvv`.
|
- Verify: `xcodebuild test -scheme ICCery -destination 'platform=macOS' ARCHS="$(uname -m)"` (host arch; universal reserved for release packaging); sidecar `codesign -dvv`.
|
||||||
- Private ColorSync SPI: 2-arg `(PMPrintSession, CFStringRef) -> OSStatus`. Never pass integer `1`.
|
- Private ColorSync SPI: 2-arg `(PMPrintSession, CFStringRef) -> OSStatus`. Never pass integer `1`.
|
||||||
|
|
||||||
### `BUILD-PLAN.md`
|
### `BUILD-PLAN.md`
|
||||||
@@ -566,7 +566,7 @@ Labels: `Feature/DevOps`, `Priority/High`
|
|||||||
Milestone: M6
|
Milestone: M6
|
||||||
|
|
||||||
- **Self-hosted Mac runner** (Gitea has no `macos-latest` unless you attach one). Optional GitHub Actions mirror.
|
- **Self-hosted Mac runner** (Gitea has no `macos-latest` unless you attach one). Optional GitHub Actions mirror.
|
||||||
- Pipeline: `fetch-argyll` → ad-hoc `codesign -s -` + `codesign -dvv` on every sidecar Mach-O (hard fail) → `xcodebuild build test -scheme ICCery ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO` → unit + mock fixtures (#215) → **dmgbuild** with background art (**not** Finder AppleScript, #189) → upload artefact.
|
- Pipeline: `fetch-argyll` → ad-hoc `codesign -s -` + `codesign -dvv` on every sidecar Mach-O (hard fail) → `xcodebuild build test -scheme ICCery ARCHS="$(uname -m)"` (host arch; the dmgbuild leg still builds universal) → unit + mock fixtures (#215) → **dmgbuild** with background art (**not** Finder AppleScript, #189) → upload artefact.
|
||||||
- App signing: Developer ID + **notarize/staple** for the `.app` / `.dmg`. Sidecars remain **ad-hoc** inside the bundle (#165). These are two different gates — do not conflate.
|
- App signing: Developer ID + **notarize/staple** for the `.app` / `.dmg`. Sidecars remain **ad-hoc** inside the bundle (#165). These are two different gates — do not conflate.
|
||||||
- Confirm entitlements: sandbox **false**.
|
- Confirm entitlements: sandbox **false**.
|
||||||
- Spec: [04](04-argyll-binaries.md) §0.6, [05](05-argyll-fork.md) §8–9, [23](23-assets.md), [24](24-issues-invariants.md).
|
- Spec: [04](04-argyll-binaries.md) §0.6, [05](05-argyll-fork.md) §8–9, [23](23-assets.md), [24](24-issues-invariants.md).
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@ Unsigned CI artefacts (e.g. a `.zip` from a non-notarized workflow run) are **no
|
|||||||
scripts/fetch-argyll.sh # populates Vendor/Argyll and signs sidecars
|
scripts/fetch-argyll.sh # populates Vendor/Argyll and signs sidecars
|
||||||
xcodegen generate --project .
|
xcodegen generate --project .
|
||||||
xcodebuild test -scheme ICCery -destination 'platform=macOS' \
|
xcodebuild test -scheme ICCery -destination 'platform=macOS' \
|
||||||
ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO \
|
ARCHS="$(uname -m)" \
|
||||||
CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY='-'
|
CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY='-'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+10
@@ -19,6 +19,7 @@ targets:
|
|||||||
- path: Resources
|
- path: Resources
|
||||||
excludes:
|
excludes:
|
||||||
- ICCery.entitlements
|
- ICCery.entitlements
|
||||||
|
- ICCery.Debug.entitlements
|
||||||
- Argyll
|
- Argyll
|
||||||
- path: Resources/Argyll
|
- path: Resources/Argyll
|
||||||
type: folder
|
type: folder
|
||||||
@@ -62,6 +63,15 @@ targets:
|
|||||||
OTHER_SWIFT_FLAGS: ["$(inherited)", "-strict-concurrency=minimal"]
|
OTHER_SWIFT_FLAGS: ["$(inherited)", "-strict-concurrency=minimal"]
|
||||||
MACOSX_DEPLOYMENT_TARGET: "12.0"
|
MACOSX_DEPLOYMENT_TARGET: "12.0"
|
||||||
ARCHS: "$(ARCHS_STANDARD)"
|
ARCHS: "$(ARCHS_STANDARD)"
|
||||||
|
# Debug builds sign ad-hoc; hardened-runtime library validation would
|
||||||
|
# reject the embedded ICCeryCore package framework (no Team ID) when
|
||||||
|
# the test host launches (run 31992, #119). DISABLE_LIBRARY_VALIDATION
|
||||||
|
# does not inject the entitlement on Xcode 14.2, so use a dedicated
|
||||||
|
# Debug entitlements file. Release keeps validation and links the
|
||||||
|
# package statically anyway.
|
||||||
|
configs:
|
||||||
|
Debug:
|
||||||
|
CODE_SIGN_ENTITLEMENTS: Resources/ICCery.Debug.entitlements
|
||||||
|
|
||||||
ICCeryCoreTests:
|
ICCeryCoreTests:
|
||||||
type: bundle.unit-test
|
type: bundle.unit-test
|
||||||
|
|||||||
Executable
+40
@@ -0,0 +1,40 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# scripts/ensure-host-tools.sh
|
||||||
|
#
|
||||||
|
# Bootstrap host tools needed by CI on the macOS 12 runner:
|
||||||
|
# - xcodegen: pinned prebuilt release from GitHub (Homebrew's current
|
||||||
|
# formula requires Xcode 15.3, which cannot be installed on macOS 12).
|
||||||
|
# - dmgbuild: via pip3 (used by scripts/package-release.sh).
|
||||||
|
#
|
||||||
|
# Safe to run repeatedly: existing tools are left alone.
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
XCODEGEN_VERSION="2.38.0"
|
||||||
|
INSTALL_ROOT="${XCODEGEN_HOME:-$HOME/.local/xcodegen/$XCODEGEN_VERSION}"
|
||||||
|
|
||||||
|
echo "==> Ensuring dmgbuild"
|
||||||
|
python3 -c "import dmgbuild" 2>/dev/null || pip3 install dmgbuild
|
||||||
|
|
||||||
|
if command -v xcodegen >/dev/null 2>&1; then
|
||||||
|
echo "==> xcodegen already on PATH: $(xcodegen --version)"
|
||||||
|
else
|
||||||
|
echo "==> Installing xcodegen $XCODEGEN_VERSION (prebuilt)"
|
||||||
|
TMP="${RUNNER_TEMP:-${TMPDIR:-/tmp}}"
|
||||||
|
ZIP="$TMP/xcodegen-$XCODEGEN_VERSION.zip"
|
||||||
|
curl -fL --retry 3 \
|
||||||
|
"https://github.com/yonaskolb/XcodeGen/releases/download/$XCODEGEN_VERSION/xcodegen.zip" \
|
||||||
|
-o "$ZIP"
|
||||||
|
rm -rf "$INSTALL_ROOT"
|
||||||
|
mkdir -p "$INSTALL_ROOT"
|
||||||
|
# Zip contains xcodegen/{bin/xcodegen,share/xcodegen/SettingPresets};
|
||||||
|
# XcodeGen resolves its presets relative to the binary, so keep the tree.
|
||||||
|
unzip -q "$ZIP" -d "$INSTALL_ROOT"
|
||||||
|
BIN_DIR="$INSTALL_ROOT/xcodegen/bin"
|
||||||
|
chmod +x "$BIN_DIR/xcodegen"
|
||||||
|
if [ -n "${GITHUB_PATH:-}" ]; then
|
||||||
|
echo "$BIN_DIR" >> "$GITHUB_PATH"
|
||||||
|
fi
|
||||||
|
PATH="$BIN_DIR:$PATH"
|
||||||
|
echo "==> Installed: $("$BIN_DIR/xcodegen" --version)"
|
||||||
|
fi
|
||||||
Reference in New Issue
Block a user