Compare commits
27
Commits
v2.0.0-pre1
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09f433106d | ||
|
|
891a504ee7 | ||
|
|
f681e60778 | ||
|
|
0d0233e8d6 | ||
|
|
d117d7a510 | ||
|
|
78ffeff61b | ||
|
|
0298f69a2c | ||
|
|
da602e2775 | ||
|
|
5e3b183b9a | ||
|
|
e48c3f6840 | ||
|
|
7751701208 | ||
|
|
c539507d5d | ||
|
|
0ffcf5ea91 | ||
|
|
597cce7b60 | ||
|
|
bb4512e129 | ||
|
|
12584d156a | ||
|
|
0332a2bb4f | ||
|
|
0d98440a15 | ||
|
|
073e3aa308 | ||
|
|
85fa543c4a | ||
|
|
6a612c0c1d | ||
|
|
e203127794 | ||
|
|
118b77b441 | ||
|
|
0115aa2726 | ||
|
|
e9daaddf2d | ||
|
|
d4261ba2c9 | ||
|
|
2e8b07c8f3 |
@@ -36,7 +36,7 @@ jobs:
|
|||||||
CODE_SIGNING_ALLOWED=YES \
|
CODE_SIGNING_ALLOWED=YES \
|
||||||
CODE_SIGN_IDENTITY='-'
|
CODE_SIGN_IDENTITY='-'
|
||||||
|
|
||||||
- name: Test (universal)
|
- name: Test unit (ICCeryCoreTests)
|
||||||
run: |
|
run: |
|
||||||
XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)"
|
XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)"
|
||||||
if [ -z "$XCTESTRUN" ] || [ ! -f "$XCTESTRUN" ]; then
|
if [ -z "$XCTESTRUN" ] || [ ! -f "$XCTESTRUN" ]; then
|
||||||
@@ -46,9 +46,78 @@ jobs:
|
|||||||
echo "xctestrun: $XCTESTRUN"
|
echo "xctestrun: $XCTESTRUN"
|
||||||
xcodebuild test-without-building \
|
xcodebuild test-without-building \
|
||||||
-xctestrun "$XCTESTRUN" \
|
-xctestrun "$XCTESTRUN" \
|
||||||
|
-only-testing:ICCeryCoreTests \
|
||||||
-destination 'platform=macOS' \
|
-destination 'platform=macOS' \
|
||||||
-derivedDataPath "$DERIVED"
|
-derivedDataPath "$DERIVED"
|
||||||
|
|
||||||
|
# UI tests need macOS Automation / Accessibility permission on the
|
||||||
|
# runner. The self-hosted Mac intermittently times out enabling
|
||||||
|
# that mode (run 29700) or launches the app into
|
||||||
|
# `.runningBackground` without ever activating it (run 29804).
|
||||||
|
# Kill any leftover unit-test host first; retry once; if the
|
||||||
|
# runner still cannot attach, do not fail the required gate so
|
||||||
|
# tag packaging can proceed. Real XCTest assertion failures
|
||||||
|
# still fail the job.
|
||||||
|
- name: Test UI (ICCeryUITests)
|
||||||
|
run: |
|
||||||
|
set -o pipefail
|
||||||
|
XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)"
|
||||||
|
LOG="$DERIVED/ui-test.log"
|
||||||
|
pkill -x ICCery 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
run_ui() {
|
||||||
|
local label="$1"
|
||||||
|
shift
|
||||||
|
echo "::group::UI tests $label"
|
||||||
|
set +e
|
||||||
|
xcodebuild test-without-building \
|
||||||
|
-xctestrun "$XCTESTRUN" \
|
||||||
|
-destination 'platform=macOS' \
|
||||||
|
-derivedDataPath "$DERIVED" \
|
||||||
|
"$@" | tee "$LOG"
|
||||||
|
rc=${PIPESTATUS[0]}
|
||||||
|
set -e
|
||||||
|
echo "::endgroup::"
|
||||||
|
return "$rc"
|
||||||
|
}
|
||||||
|
|
||||||
|
is_runner_attach_failure() {
|
||||||
|
grep -Eq "Timed out while enabling automation mode|Failed to activate application|current state: Running Background" "$LOG"
|
||||||
|
}
|
||||||
|
|
||||||
|
attempt=1
|
||||||
|
while [ "$attempt" -le 2 ]; do
|
||||||
|
# Probe one case first. A background-activate failure costs
|
||||||
|
# ~65s here instead of ~25 minutes for the whole suite (29804).
|
||||||
|
if ! run_ui "probe attempt $attempt" \
|
||||||
|
-only-testing:ICCeryUITests/AboutHelpUITests/testAboutDialogShowsVersionAndBuildDate; then
|
||||||
|
if is_runner_attach_failure; then
|
||||||
|
echo "warning: UI runner could not attach/activate the app (attempt $attempt)"
|
||||||
|
pkill -x ICCery 2>/dev/null || true
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
sleep 8
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
echo "error: UI probe failed with a real test error" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if run_ui "full suite attempt $attempt" -only-testing:ICCeryUITests; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if is_runner_attach_failure; then
|
||||||
|
echo "warning: UI runner lost activation mid-suite (attempt $attempt)"
|
||||||
|
pkill -x ICCery 2>/dev/null || true
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
sleep 8
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
echo "error: UI tests failed with a real test error" >&2
|
||||||
|
exit 1
|
||||||
|
done
|
||||||
|
echo "warning: skipping UI tests after repeated runner attach/activate failures"
|
||||||
|
exit 0
|
||||||
|
|
||||||
package:
|
package:
|
||||||
needs: build-and-test
|
needs: build-and-test
|
||||||
runs-on: macos-14
|
runs-on: macos-14
|
||||||
@@ -85,3 +154,14 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: iccery-dmg
|
name: iccery-dmg
|
||||||
path: ICCery-*.dmg
|
path: ICCery-*.dmg
|
||||||
|
|
||||||
|
- name: Attach DMG to Gitea release
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
run: scripts/attach-release-asset.sh
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
|
GITEA_SERVER_URL: ${{ github.server_url }}
|
||||||
|
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||||
|
GITHUB_REF_NAME: ${{ github.ref_name }}
|
||||||
|
GITHUB_SHA: ${{ github.sha }}
|
||||||
|
|||||||
+2
-1
@@ -27,4 +27,5 @@ ICCery.xcodeproj/
|
|||||||
Release/
|
Release/
|
||||||
notarization/
|
notarization/
|
||||||
build/
|
build/
|
||||||
docs/megaplans/
|
docs/megaplans/*
|
||||||
|
docs/megaplans
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ Hardware gates block *release of that sprint*, not filing, and not starting codi
|
|||||||
| M4 | Measurement | 18–22 | `chartread.mock`; 39+ classifier fixtures; ΔE₀₀; snapshot/average | Detect real instrument; one strip or XY through Done → `.ti3` |
|
| M4 | Measurement | 18–22 | `chartread.mock`; 39+ classifier fixtures; ΔE₀₀; snapshot/average | Detect real instrument; one strip or XY through Done → `.ti3` |
|
||||||
| M5 | Profile / verify / install | 23–27 | colprof → `.icc`; profcheck parse; atomic history; install into temp dir | Full `.ti1`→`.icc`; profile visible in ColorSync Utility |
|
| 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 |
|
||||||
| 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.**
|
Issue **16 is not an M3 or M6 exit gate.**
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Tiny argv helpers. Each Argyll tool keeps its own `*Args` enum —
|
||||||
|
/// `-d` / `-u` / `-r` still mean different things per binary.
|
||||||
|
public enum ArgsBuilder {
|
||||||
|
/// `["-f", value]` when `value` is non-nil.
|
||||||
|
public static func option(_ flag: String, _ value: String?) -> [String] {
|
||||||
|
guard let value else { return [] }
|
||||||
|
return [flag, value]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `["-f", trimmed]` when trimmed is non-empty.
|
||||||
|
public static func optionIfNonEmpty(_ flag: String, _ value: String?) -> [String] {
|
||||||
|
guard let raw = value?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!raw.isEmpty else { return [] }
|
||||||
|
return [flag, raw]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Omits the flag when `value` is nil or within `epsilon` of `skip`.
|
||||||
|
public static func optionUnlessApprox(
|
||||||
|
_ flag: String,
|
||||||
|
_ value: Double?,
|
||||||
|
skip: Double,
|
||||||
|
epsilon: Double = 0.001,
|
||||||
|
format: String = "%.2f"
|
||||||
|
) -> [String] {
|
||||||
|
guard let value, abs(value - skip) >= epsilon else { return [] }
|
||||||
|
return [flag, String(format: format, locale: Locale(identifier: "en_US_POSIX"), value)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bare flag when `when` is true.
|
||||||
|
public static func flag(_ flag: String, when: Bool) -> [String] {
|
||||||
|
when ? [flag] : []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,43 +2,41 @@ import Foundation
|
|||||||
|
|
||||||
/// Errors from `ArgyllRunner` executions.
|
/// Errors from `ArgyllRunner` executions.
|
||||||
public enum ArgyllRunnerError: LocalizedError, Equatable, Sendable {
|
public enum ArgyllRunnerError: LocalizedError, Equatable, Sendable {
|
||||||
case processFailed(code: Int32, logs: [String])
|
case toolFailed(tool: String, code: Int32, logs: [String])
|
||||||
case missingArtefact(String)
|
case missingArtefact(String)
|
||||||
case malformedManifest(String)
|
case malformedManifest(String)
|
||||||
case instrumentDetectionFailed(String)
|
case instrumentDetectionFailed(String)
|
||||||
case chartreadFailed(String)
|
|
||||||
case averageFailed(String)
|
|
||||||
case colprofFailed(String)
|
|
||||||
case printcalFailed(String)
|
|
||||||
case applycalFailed(String)
|
|
||||||
case iccgamutFailed(String)
|
|
||||||
case profcheckFailed(String)
|
|
||||||
case profcheckUnparseable
|
case profcheckUnparseable
|
||||||
|
|
||||||
public var errorDescription: String? {
|
public var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .processFailed(let code, _):
|
case .toolFailed(let tool, let code, let logs):
|
||||||
|
let detail = logs.last.flatMap { $0.isEmpty ? nil : $0 }
|
||||||
|
?? "exited with code \(code)"
|
||||||
|
switch tool {
|
||||||
|
case "chartread":
|
||||||
|
return "Chartread failed: \(detail)"
|
||||||
|
case "average":
|
||||||
|
return "Averaging failed: \(detail)"
|
||||||
|
case "colprof":
|
||||||
|
return "Profile creation failed: \(detail)"
|
||||||
|
case "printcal":
|
||||||
|
return "Calibration curve computation failed: \(detail)"
|
||||||
|
case "applycal":
|
||||||
|
return "Apply calibration failed: \(detail)"
|
||||||
|
case "iccgamut":
|
||||||
|
return "Gamut extraction failed: \(detail)"
|
||||||
|
case "profcheck":
|
||||||
|
return "Profile verification failed: \(detail)"
|
||||||
|
default:
|
||||||
return "Process exited with code \(code)"
|
return "Process exited with code \(code)"
|
||||||
|
}
|
||||||
case .missingArtefact(let path):
|
case .missingArtefact(let path):
|
||||||
return "Expected output file was not created: \(path)"
|
return "Expected output file was not created: \(path)"
|
||||||
case .malformedManifest(let reason):
|
case .malformedManifest(let reason):
|
||||||
return "Failed to parse printtarg manifest: \(reason)"
|
return "Failed to parse printtarg manifest: \(reason)"
|
||||||
case .instrumentDetectionFailed(let reason):
|
case .instrumentDetectionFailed(let reason):
|
||||||
return "Instrument detection failed: \(reason)"
|
return "Instrument detection failed: \(reason)"
|
||||||
case .chartreadFailed(let reason):
|
|
||||||
return "Chartread failed: \(reason)"
|
|
||||||
case .averageFailed(let reason):
|
|
||||||
return "Averaging failed: \(reason)"
|
|
||||||
case .colprofFailed(let reason):
|
|
||||||
return "Profile creation failed: \(reason)"
|
|
||||||
case .printcalFailed(let reason):
|
|
||||||
return "Calibration curve computation failed: \(reason)"
|
|
||||||
case .applycalFailed(let reason):
|
|
||||||
return "Apply calibration failed: \(reason)"
|
|
||||||
case .iccgamutFailed(let reason):
|
|
||||||
return "Gamut extraction failed: \(reason)"
|
|
||||||
case .profcheckFailed(let reason):
|
|
||||||
return "Profile verification failed: \(reason)"
|
|
||||||
case .profcheckUnparseable:
|
case .profcheckUnparseable:
|
||||||
return "Profile verification produced unparseable output"
|
return "Profile verification produced unparseable output"
|
||||||
}
|
}
|
||||||
@@ -76,6 +74,48 @@ public struct ArgyllRunner: Sendable {
|
|||||||
self.binaryResolver = binaryResolver
|
self.binaryResolver = binaryResolver
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Shared streaming loop (issue #79)
|
||||||
|
|
||||||
|
private func runStreamingTool(
|
||||||
|
name: String,
|
||||||
|
id: String,
|
||||||
|
arguments: [String],
|
||||||
|
workingDirectory: URL?,
|
||||||
|
flushPartialLines: Bool = false,
|
||||||
|
onLogBatch: (@Sendable ([String]) -> Void)? = nil
|
||||||
|
) async throws -> CollectedRun {
|
||||||
|
let binaryURL = binaryResolver.resolve(name)
|
||||||
|
await ensureNotRunning(id: id)
|
||||||
|
let events = processManager.events()
|
||||||
|
try await processManager.runStreaming(
|
||||||
|
id: id,
|
||||||
|
binary: binaryURL,
|
||||||
|
arguments: arguments,
|
||||||
|
workingDirectory: workingDirectory
|
||||||
|
)
|
||||||
|
let run = await collect(
|
||||||
|
id: id,
|
||||||
|
events: events,
|
||||||
|
onLogBatch: onLogBatch,
|
||||||
|
flushPartialLines: flushPartialLines
|
||||||
|
)
|
||||||
|
guard run.exitCode == 0 else {
|
||||||
|
throw ArgyllRunnerError.toolFailed(
|
||||||
|
tool: name,
|
||||||
|
code: run.exitCode ?? -1,
|
||||||
|
logs: run.lines
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return run
|
||||||
|
}
|
||||||
|
|
||||||
|
private func requireArtefact(_ url: URL) throws -> URL {
|
||||||
|
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||||
|
throw ArgyllRunnerError.missingArtefact(url.path)
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - targen (Stage 1)
|
// MARK: - targen (Stage 1)
|
||||||
|
|
||||||
/// Runs `targen` streaming, collecting logs and verifying `.ti1`
|
/// Runs `targen` streaming, collecting logs and verifying `.ti1`
|
||||||
@@ -87,27 +127,16 @@ public struct ArgyllRunner: Sendable {
|
|||||||
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
|
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
|
||||||
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
||||||
let args = try TargenArgs.build(config: config)
|
let args = try TargenArgs.build(config: config)
|
||||||
let binaryURL = binaryResolver.resolve("targen")
|
|
||||||
let processId = ProcessID.targen(cleanBasename)
|
let processId = ProcessID.targen(cleanBasename)
|
||||||
|
_ = try await runStreamingTool(
|
||||||
await ensureNotRunning(id: processId)
|
name: "targen",
|
||||||
let events = processManager.events()
|
|
||||||
try await processManager.runStreaming(
|
|
||||||
id: processId,
|
id: processId,
|
||||||
binary: binaryURL,
|
|
||||||
arguments: args,
|
arguments: args,
|
||||||
workingDirectory: cwd
|
workingDirectory: cwd,
|
||||||
|
onLogBatch: onLogBatch
|
||||||
)
|
)
|
||||||
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
|
|
||||||
|
|
||||||
guard run.exitCode == 0 else {
|
|
||||||
throw ArgyllRunnerError.processFailed(code: run.exitCode ?? -1, logs: run.lines)
|
|
||||||
}
|
|
||||||
let ti1URL = cwd.appendingPathComponent("\(cleanBasename).ti1")
|
let ti1URL = cwd.appendingPathComponent("\(cleanBasename).ti1")
|
||||||
guard FileManager.default.fileExists(atPath: ti1URL.path) else {
|
return try requireArtefact(ti1URL)
|
||||||
throw ArgyllRunnerError.missingArtefact(ti1URL.path)
|
|
||||||
}
|
|
||||||
return ti1URL
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - printtarg (Stage 2)
|
// MARK: - printtarg (Stage 2)
|
||||||
@@ -122,26 +151,15 @@ public struct ArgyllRunner: Sendable {
|
|||||||
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
|
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
|
||||||
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
||||||
let args = try PrinttargArgs.build(config: config)
|
let args = try PrinttargArgs.build(config: config)
|
||||||
let binaryURL = binaryResolver.resolve("printtarg")
|
|
||||||
let processId = ProcessID.printtarg(cleanBasename)
|
let processId = ProcessID.printtarg(cleanBasename)
|
||||||
|
let run = try await runStreamingTool(
|
||||||
await ensureNotRunning(id: processId)
|
name: "printtarg",
|
||||||
let events = processManager.events()
|
|
||||||
try await processManager.runStreaming(
|
|
||||||
id: processId,
|
id: processId,
|
||||||
binary: binaryURL,
|
|
||||||
arguments: args,
|
arguments: args,
|
||||||
workingDirectory: cwd
|
workingDirectory: cwd,
|
||||||
|
onLogBatch: onLogBatch
|
||||||
)
|
)
|
||||||
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
|
let ti2URL = try requireArtefact(cwd.appendingPathComponent("\(cleanBasename).ti2"))
|
||||||
|
|
||||||
guard run.exitCode == 0 else {
|
|
||||||
throw ArgyllRunnerError.processFailed(code: run.exitCode ?? -1, logs: run.lines)
|
|
||||||
}
|
|
||||||
let ti2URL = cwd.appendingPathComponent("\(cleanBasename).ti2")
|
|
||||||
guard FileManager.default.fileExists(atPath: ti2URL.path) else {
|
|
||||||
throw ArgyllRunnerError.missingArtefact(ti2URL.path)
|
|
||||||
}
|
|
||||||
|
|
||||||
let manifest: PrinttargManifest
|
let manifest: PrinttargManifest
|
||||||
do {
|
do {
|
||||||
@@ -339,28 +357,16 @@ public struct ArgyllRunner: Sendable {
|
|||||||
) async throws -> URL {
|
) async throws -> URL {
|
||||||
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
||||||
let args = try AverageArgs.build(config: config)
|
let args = try AverageArgs.build(config: config)
|
||||||
let binaryURL = binaryResolver.resolve("average")
|
|
||||||
let processId = ProcessID.average(config.basename)
|
let processId = ProcessID.average(config.basename)
|
||||||
|
_ = try await runStreamingTool(
|
||||||
await ensureNotRunning(id: processId)
|
name: "average",
|
||||||
let events = processManager.events()
|
|
||||||
try await processManager.runStreaming(
|
|
||||||
id: processId,
|
id: processId,
|
||||||
binary: binaryURL,
|
|
||||||
arguments: args,
|
arguments: args,
|
||||||
workingDirectory: cwd
|
workingDirectory: cwd,
|
||||||
|
onLogBatch: onLogBatch
|
||||||
)
|
)
|
||||||
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
|
|
||||||
|
|
||||||
guard run.exitCode == 0 else {
|
|
||||||
throw ArgyllRunnerError.averageFailed("average exited with code \(run.exitCode ?? -1)")
|
|
||||||
}
|
|
||||||
|
|
||||||
let canonical = cwd.appendingPathComponent("\(config.basename).ti3")
|
let canonical = cwd.appendingPathComponent("\(config.basename).ti3")
|
||||||
guard FileManager.default.fileExists(atPath: canonical.path) else {
|
return try requireArtefact(canonical)
|
||||||
throw ArgyllRunnerError.missingArtefact(canonical.path)
|
|
||||||
}
|
|
||||||
return canonical
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - colprof (Stage 4)
|
// MARK: - colprof (Stage 4)
|
||||||
@@ -374,29 +380,15 @@ public struct ArgyllRunner: Sendable {
|
|||||||
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
|
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
|
||||||
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
||||||
let args = try ColprofArgs.build(config: config)
|
let args = try ColprofArgs.build(config: config)
|
||||||
let binaryURL = binaryResolver.resolve("colprof")
|
|
||||||
let processId = ProcessID.colprof(cleanBasename)
|
let processId = ProcessID.colprof(cleanBasename)
|
||||||
|
_ = try await runStreamingTool(
|
||||||
await ensureNotRunning(id: processId)
|
name: "colprof",
|
||||||
let events = processManager.events()
|
|
||||||
try await processManager.runStreaming(
|
|
||||||
id: processId,
|
id: processId,
|
||||||
binary: binaryURL,
|
|
||||||
arguments: args,
|
arguments: args,
|
||||||
workingDirectory: cwd
|
workingDirectory: cwd,
|
||||||
|
flushPartialLines: true,
|
||||||
|
onLogBatch: onLogBatch
|
||||||
)
|
)
|
||||||
let run = await collect(
|
|
||||||
id: processId,
|
|
||||||
events: events,
|
|
||||||
onLogBatch: onLogBatch,
|
|
||||||
flushPartialLines: true
|
|
||||||
)
|
|
||||||
|
|
||||||
guard run.exitCode == 0 else {
|
|
||||||
throw ArgyllRunnerError.colprofFailed(
|
|
||||||
"colprof exited with code \(run.exitCode ?? -1)"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Argyll may produce `.icm` on Windows, but on macOS we expect `.icc`.
|
// Argyll may produce `.icm` on Windows, but on macOS we expect `.icc`.
|
||||||
// `resolveProfile` checks `.icm` first, then `.icc`, matching #69.
|
// `resolveProfile` checks `.icm` first, then `.icc`, matching #69.
|
||||||
@@ -456,16 +448,20 @@ public struct ArgyllRunner: Sendable {
|
|||||||
if Task.isCancelled {
|
if Task.isCancelled {
|
||||||
throw CancellationError()
|
throw CancellationError()
|
||||||
}
|
}
|
||||||
throw ArgyllRunnerError.applycalFailed(
|
throw ArgyllRunnerError.toolFailed(
|
||||||
result.stderr.isEmpty
|
tool: "applycal",
|
||||||
|
code: result.exitCode,
|
||||||
|
logs: [result.stderr.isEmpty
|
||||||
? "applycal exited with code \(result.exitCode)"
|
? "applycal exited with code \(result.exitCode)"
|
||||||
: result.stderr
|
: result.stderr]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
guard fm.fileExists(atPath: tmpURL.path) else {
|
guard fm.fileExists(atPath: tmpURL.path) else {
|
||||||
throw ArgyllRunnerError.applycalFailed(
|
throw ArgyllRunnerError.toolFailed(
|
||||||
"applycal did not create temp profile"
|
tool: "applycal",
|
||||||
|
code: -1,
|
||||||
|
logs: ["applycal did not create temp profile"]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,8 +469,10 @@ public struct ArgyllRunner: Sendable {
|
|||||||
let size = attrs?[.size] as? UInt64 ?? 0
|
let size = attrs?[.size] as? UInt64 ?? 0
|
||||||
guard size >= 128 else {
|
guard size >= 128 else {
|
||||||
try? fm.removeItem(at: tmpURL)
|
try? fm.removeItem(at: tmpURL)
|
||||||
throw ArgyllRunnerError.applycalFailed(
|
throw ArgyllRunnerError.toolFailed(
|
||||||
"calibrated profile is too small (\(size) bytes)"
|
tool: "applycal",
|
||||||
|
code: -1,
|
||||||
|
logs: ["calibrated profile is too small (\(size) bytes)"]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,7 +484,11 @@ public struct ArgyllRunner: Sendable {
|
|||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
try? fm.removeItem(at: tmpURL)
|
try? fm.removeItem(at: tmpURL)
|
||||||
throw ArgyllRunnerError.applycalFailed(error.localizedDescription)
|
throw ArgyllRunnerError.toolFailed(
|
||||||
|
tool: "applycal",
|
||||||
|
code: -1,
|
||||||
|
logs: [error.localizedDescription]
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return inputURL
|
return inputURL
|
||||||
@@ -503,30 +505,16 @@ public struct ArgyllRunner: Sendable {
|
|||||||
let cwd = profileURL.deletingLastPathComponent()
|
let cwd = profileURL.deletingLastPathComponent()
|
||||||
let stem = profileURL.deletingPathExtension().lastPathComponent
|
let stem = profileURL.deletingPathExtension().lastPathComponent
|
||||||
let args = try IccgamutArgs.build(config: config)
|
let args = try IccgamutArgs.build(config: config)
|
||||||
let binaryURL = binaryResolver.resolve("iccgamut")
|
|
||||||
let processId = ProcessID.iccgamut(stem: stem)
|
let processId = ProcessID.iccgamut(stem: stem)
|
||||||
|
_ = try await runStreamingTool(
|
||||||
await ensureNotRunning(id: processId)
|
name: "iccgamut",
|
||||||
let events = processManager.events()
|
|
||||||
try await processManager.runStreaming(
|
|
||||||
id: processId,
|
id: processId,
|
||||||
binary: binaryURL,
|
|
||||||
arguments: args,
|
arguments: args,
|
||||||
workingDirectory: cwd
|
workingDirectory: cwd,
|
||||||
|
onLogBatch: onLogBatch
|
||||||
)
|
)
|
||||||
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
|
|
||||||
|
|
||||||
guard run.exitCode == 0 else {
|
|
||||||
throw ArgyllRunnerError.iccgamutFailed(
|
|
||||||
"iccgamut exited with code \(run.exitCode ?? -1)"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
let gamURL = cwd.appendingPathComponent("\(stem).gam")
|
let gamURL = cwd.appendingPathComponent("\(stem).gam")
|
||||||
guard FileManager.default.fileExists(atPath: gamURL.path) else {
|
return try requireArtefact(gamURL)
|
||||||
throw ArgyllRunnerError.missingArtefact(gamURL.path)
|
|
||||||
}
|
|
||||||
return gamURL
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - profcheck (Stage 5 verification)
|
// MARK: - profcheck (Stage 5 verification)
|
||||||
@@ -539,30 +527,18 @@ public struct ArgyllRunner: Sendable {
|
|||||||
let cwd = config.ti3URL.deletingLastPathComponent()
|
let cwd = config.ti3URL.deletingLastPathComponent()
|
||||||
let ti3Path = config.ti3URL.path
|
let ti3Path = config.ti3URL.path
|
||||||
|
|
||||||
let iccURL = Self.resolveProfileForVerification(config.iccURL)
|
let iccURL = ArtefactProbe.resolveProfile(config.iccURL)
|
||||||
let config = ProfcheckConfig(ti3URL: config.ti3URL, iccURL: iccURL)
|
let config = ProfcheckConfig(ti3URL: config.ti3URL, iccURL: iccURL)
|
||||||
|
|
||||||
let args = try ProfcheckArgs.build(config: config)
|
let args = try ProfcheckArgs.build(config: config)
|
||||||
let binaryURL = binaryResolver.resolve("profcheck")
|
|
||||||
let processId = ProcessID.profcheck(ti3Path: ti3Path)
|
let processId = ProcessID.profcheck(ti3Path: ti3Path)
|
||||||
|
let run = try await runStreamingTool(
|
||||||
await ensureNotRunning(id: processId)
|
name: "profcheck",
|
||||||
let events = processManager.events()
|
|
||||||
try await processManager.runStreaming(
|
|
||||||
id: processId,
|
id: processId,
|
||||||
binary: binaryURL,
|
|
||||||
arguments: args,
|
arguments: args,
|
||||||
workingDirectory: cwd
|
workingDirectory: cwd,
|
||||||
|
onLogBatch: onLogBatch
|
||||||
)
|
)
|
||||||
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
|
|
||||||
|
|
||||||
guard run.exitCode == 0 else {
|
|
||||||
throw ArgyllRunnerError.profcheckFailed(
|
|
||||||
run.stderr.isEmpty
|
|
||||||
? "profcheck exited with code \(run.exitCode ?? -1)"
|
|
||||||
: run.stderr
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
let output = (run.stdout + "\n" + run.stderr).trimmingCharacters(in: .whitespacesAndNewlines)
|
let output = (run.stdout + "\n" + run.stderr).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let report = ProfcheckParser.parse(output)
|
let report = ProfcheckParser.parse(output)
|
||||||
@@ -572,15 +548,6 @@ public struct ArgyllRunner: Sendable {
|
|||||||
return report
|
return report
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func resolveProfileForVerification(_ url: URL) -> URL {
|
|
||||||
let fm = FileManager.default
|
|
||||||
if fm.fileExists(atPath: url.path) { return url }
|
|
||||||
let alt = url.pathExtension.lowercased() == "icc"
|
|
||||||
? url.deletingPathExtension().appendingPathExtension("icm")
|
|
||||||
: url.deletingPathExtension().appendingPathExtension("icc")
|
|
||||||
return fm.fileExists(atPath: alt.path) ? alt : url
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - chartread (Stage 3 interactive)
|
// MARK: - chartread (Stage 3 interactive)
|
||||||
|
|
||||||
/// Runs `chartread` and returns an `AsyncStream` of typed events.
|
/// Runs `chartread` and returns an `AsyncStream` of typed events.
|
||||||
@@ -596,7 +563,7 @@ public struct ArgyllRunner: Sendable {
|
|||||||
cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
||||||
} catch {
|
} catch {
|
||||||
return AsyncStream { continuation in
|
return AsyncStream { continuation in
|
||||||
continuation.yield(.failed(ArgyllRunnerError.chartreadFailed(error.localizedDescription)))
|
continuation.yield(.failed(ArgyllRunnerError.toolFailed(tool: "chartread", code: -1, logs: [error.localizedDescription])))
|
||||||
continuation.finish()
|
continuation.finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -606,7 +573,7 @@ public struct ArgyllRunner: Sendable {
|
|||||||
args = try ChartreadArgs.build(config: config)
|
args = try ChartreadArgs.build(config: config)
|
||||||
} catch {
|
} catch {
|
||||||
return AsyncStream { continuation in
|
return AsyncStream { continuation in
|
||||||
continuation.yield(.failed(ArgyllRunnerError.chartreadFailed(error.localizedDescription)))
|
continuation.yield(.failed(ArgyllRunnerError.toolFailed(tool: "chartread", code: -1, logs: [error.localizedDescription])))
|
||||||
continuation.finish()
|
continuation.finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -637,7 +604,7 @@ public struct ArgyllRunner: Sendable {
|
|||||||
workingDirectory: cwd
|
workingDirectory: cwd
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
continuation.yield(.failed(ArgyllRunnerError.chartreadFailed(error.localizedDescription)))
|
continuation.yield(.failed(ArgyllRunnerError.toolFailed(tool: "chartread", code: -1, logs: [error.localizedDescription])))
|
||||||
continuation.finish()
|
continuation.finish()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -724,7 +691,11 @@ public struct ArgyllRunner: Sendable {
|
|||||||
continuation.yield(.failed(ArgyllRunnerError.missingArtefact(canonical.path)))
|
continuation.yield(.failed(ArgyllRunnerError.missingArtefact(canonical.path)))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
continuation.yield(.failed(ArgyllRunnerError.chartreadFailed("chartread exited with code \(exitCode ?? -1)")))
|
continuation.yield(.failed(ArgyllRunnerError.toolFailed(
|
||||||
|
tool: "chartread",
|
||||||
|
code: exitCode ?? -1,
|
||||||
|
logs: ["chartread exited with code \(exitCode ?? -1)"]
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
continuation.finish()
|
continuation.finish()
|
||||||
}
|
}
|
||||||
@@ -768,30 +739,19 @@ public struct ArgyllRunner: Sendable {
|
|||||||
) async throws -> URL {
|
) async throws -> URL {
|
||||||
let args = try CalibrationTargenArgs.build(config: config)
|
let args = try CalibrationTargenArgs.build(config: config)
|
||||||
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
||||||
let calBasename = config.basename.hasPrefix("CAL_") ? config.basename : "CAL_\(config.basename)"
|
let cleanBasename = try PathSecurity.sanitizeBasename(
|
||||||
let cleanBasename = try PathSecurity.sanitizeBasename(calBasename)
|
CalibrationIdentity.prefix(config.basename)
|
||||||
let binaryURL = binaryResolver.resolve("targen")
|
)
|
||||||
let processId = ProcessID.targen(cleanBasename)
|
let processId = ProcessID.targen(cleanBasename)
|
||||||
|
_ = try await runStreamingTool(
|
||||||
await ensureNotRunning(id: processId)
|
name: "targen",
|
||||||
let events = processManager.events()
|
id: processId,
|
||||||
try await processManager.runStreaming(
|
arguments: args,
|
||||||
id: processId,
|
workingDirectory: cwd,
|
||||||
binary: binaryURL,
|
onLogBatch: onLogBatch
|
||||||
arguments: args,
|
|
||||||
workingDirectory: cwd
|
|
||||||
)
|
)
|
||||||
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
|
|
||||||
|
|
||||||
guard run.exitCode == 0 else {
|
|
||||||
throw ArgyllRunnerError.processFailed(code: run.exitCode ?? -1, logs: run.lines)
|
|
||||||
}
|
|
||||||
|
|
||||||
let ti1URL = cwd.appendingPathComponent("\(cleanBasename).ti1")
|
let ti1URL = cwd.appendingPathComponent("\(cleanBasename).ti1")
|
||||||
guard FileManager.default.fileExists(atPath: ti1URL.path) else {
|
return try requireArtefact(ti1URL)
|
||||||
throw ArgyllRunnerError.missingArtefact(ti1URL.path)
|
|
||||||
}
|
|
||||||
return ti1URL
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Computes a `.cal` curve from a measured `CAL_*.ti3`.
|
/// Computes a `.cal` curve from a measured `CAL_*.ti3`.
|
||||||
@@ -805,7 +765,7 @@ public struct ArgyllRunner: Sendable {
|
|||||||
let args = try PrintcalArgs.build(config: config)
|
let args = try PrintcalArgs.build(config: config)
|
||||||
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
||||||
let binaryURL = binaryResolver.resolve("printcal")
|
let binaryURL = binaryResolver.resolve("printcal")
|
||||||
let calBasename = config.ti3Basename.hasPrefix("CAL_") ? config.ti3Basename : "CAL_\(config.ti3Basename)"
|
let calBasename = CalibrationIdentity.prefix(config.ti3Basename)
|
||||||
let processId = ProcessID.printcal(calBasename)
|
let processId = ProcessID.printcal(calBasename)
|
||||||
|
|
||||||
await ensureNotRunning(id: processId)
|
await ensureNotRunning(id: processId)
|
||||||
@@ -821,10 +781,12 @@ public struct ArgyllRunner: Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard result.exitCode == 0 else {
|
guard result.exitCode == 0 else {
|
||||||
throw ArgyllRunnerError.printcalFailed(
|
throw ArgyllRunnerError.toolFailed(
|
||||||
result.stderr.isEmpty
|
tool: "printcal",
|
||||||
|
code: result.exitCode,
|
||||||
|
logs: [result.stderr.isEmpty
|
||||||
? "printcal exited with code \(result.exitCode)"
|
? "printcal exited with code \(result.exitCode)"
|
||||||
: result.stderr
|
: result.stderr]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,23 +56,19 @@ public enum PrinttargArgs {
|
|||||||
}
|
}
|
||||||
args.append(contentsOf: ["-R", "\(config.customSeed)"])
|
args.append(contentsOf: ["-R", "\(config.customSeed)"])
|
||||||
case .raster:
|
case .raster:
|
||||||
args.append("-r")
|
args.append(contentsOf: ArgsBuilder.flag("-r", when: true))
|
||||||
}
|
}
|
||||||
|
|
||||||
if let label = config.label?.trimmingCharacters(in: .whitespacesAndNewlines),
|
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-d", config.label))
|
||||||
!label.isEmpty {
|
|
||||||
args.append(contentsOf: ["-d", label])
|
|
||||||
}
|
|
||||||
|
|
||||||
guard (72...600).contains(config.dpi) else {
|
guard (72...600).contains(config.dpi) else {
|
||||||
throw PrinttargArgError.invalidDPI(config.dpi)
|
throw PrinttargArgError.invalidDPI(config.dpi)
|
||||||
}
|
}
|
||||||
args.append(contentsOf: [config.bitDepth.flag, "\(config.dpi)"])
|
args.append(contentsOf: [config.bitDepth.flag, "\(config.dpi)"])
|
||||||
|
|
||||||
if !cleanBasename.hasPrefix("CAL_"),
|
if !CalibrationIdentity.isCalibration(cleanBasename) {
|
||||||
let cal = config.calibrationFile?.trimmingCharacters(in: .whitespacesAndNewlines),
|
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty(
|
||||||
!cal.isEmpty {
|
config.calibrationEmbedOnly ? "-I" : "-K", config.calibrationFile))
|
||||||
args.append(contentsOf: [config.calibrationEmbedOnly ? "-I" : "-K", cal])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
args.append(cleanBasename)
|
args.append(cleanBasename)
|
||||||
|
|||||||
@@ -70,18 +70,12 @@ public enum TargenArgs {
|
|||||||
if let n = config.neutralSteps, n > 0 {
|
if let n = config.neutralSteps, n > 0 {
|
||||||
args.append(contentsOf: ["-n", "\(n)"])
|
args.append(contentsOf: ["-n", "\(n)"])
|
||||||
}
|
}
|
||||||
if let nConc = config.neutralConcentration, abs(nConc - 0.50) >= 0.001 {
|
args.append(contentsOf: ArgsBuilder.optionUnlessApprox("-N", config.neutralConcentration, skip: 0.50))
|
||||||
args.append(contentsOf: ["-N", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), nConc)])
|
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-c", config.preconditioningProfile))
|
||||||
}
|
args.append(contentsOf: ArgsBuilder.flag("-G", when: config.ofpsHighQuality == true))
|
||||||
if let c = config.preconditioningProfile?.trimmingCharacters(in: .whitespacesAndNewlines), !c.isEmpty {
|
args.append(contentsOf: ArgsBuilder.option("-A", config.ofpsAdaptation.map {
|
||||||
args.append(contentsOf: ["-c", c])
|
String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), $0)
|
||||||
}
|
}))
|
||||||
if config.ofpsHighQuality == true {
|
|
||||||
args.append("-G")
|
|
||||||
}
|
|
||||||
if let a = config.ofpsAdaptation {
|
|
||||||
args.append(contentsOf: ["-A", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), a)])
|
|
||||||
}
|
|
||||||
if let algFlag = config.fullSpreadAlgorithm?.flag {
|
if let algFlag = config.fullSpreadAlgorithm?.flag {
|
||||||
args.append(algFlag)
|
args.append(algFlag)
|
||||||
}
|
}
|
||||||
@@ -91,11 +85,9 @@ public enum TargenArgs {
|
|||||||
}
|
}
|
||||||
args.append(contentsOf: ["-l", "\(inkLimit)"])
|
args.append(contentsOf: ["-l", "\(inkLimit)"])
|
||||||
}
|
}
|
||||||
if let v = config.darkEmphasis, abs(v - 1.0) >= 0.001 {
|
args.append(contentsOf: ArgsBuilder.optionUnlessApprox("-V", config.darkEmphasis, skip: 1.0))
|
||||||
args.append(contentsOf: ["-V", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), v)])
|
if let p = config.devicePower, p > 0 {
|
||||||
}
|
args.append(contentsOf: ArgsBuilder.optionUnlessApprox("-p", p, skip: 1.0))
|
||||||
if let p = config.devicePower, p > 0, abs(p - 1.0) >= 0.001 {
|
|
||||||
args.append(contentsOf: ["-p", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), p)])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
args.append(cleanBasename)
|
args.append(cleanBasename)
|
||||||
|
|||||||
@@ -78,6 +78,21 @@ public enum ArtefactProbe {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve an explicit profile URL, flipping `.icc` ↔ `.icm` when the
|
||||||
|
/// requested path is missing (#69 / issue #83). Any other extension
|
||||||
|
/// (`.mpp`, `.txt`, …) is returned unchanged — never rewritten.
|
||||||
|
public static func resolveProfile(
|
||||||
|
_ url: URL,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) -> URL {
|
||||||
|
if fileManager.fileExists(atPath: url.path) { return url }
|
||||||
|
let ext = url.pathExtension.lowercased()
|
||||||
|
guard ext == "icc" || ext == "icm" else { return url }
|
||||||
|
let alt = url.deletingPathExtension()
|
||||||
|
.appendingPathExtension(ext == "icc" ? "icm" : "icc")
|
||||||
|
return fileManager.fileExists(atPath: alt.path) ? alt : url
|
||||||
|
}
|
||||||
|
|
||||||
/// Default extension for a *new* profile on macOS (#69).
|
/// Default extension for a *new* profile on macOS (#69).
|
||||||
public static let defaultProfileExtension = "icc"
|
public static let defaultProfileExtension = "icc"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Policy when a JSON file exists but cannot be decoded.
|
||||||
|
public enum JSONCorruptPolicy: Sendable {
|
||||||
|
/// Return `defaultValue` and leave the file untouched.
|
||||||
|
case replaceWithDefault
|
||||||
|
/// Throw the decode error. Callers must not overwrite the file.
|
||||||
|
case throwCorrupt
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared pretty-printed JSON file façade used by settings, wizard state,
|
||||||
|
/// and verification history.
|
||||||
|
public struct JSONFileStore<T: Codable & Sendable>: Sendable {
|
||||||
|
public let fileURL: URL
|
||||||
|
public let corrupt: JSONCorruptPolicy
|
||||||
|
private let defaultValue: @Sendable () -> T
|
||||||
|
private let encoder: JSONEncoder
|
||||||
|
private let decoder: JSONDecoder
|
||||||
|
|
||||||
|
public init(
|
||||||
|
fileURL: URL,
|
||||||
|
corrupt: JSONCorruptPolicy,
|
||||||
|
defaultValue: @escaping @Sendable () -> T,
|
||||||
|
dateEncoding: JSONEncoder.DateEncodingStrategy = .deferredToDate,
|
||||||
|
dateDecoding: JSONDecoder.DateDecodingStrategy = .deferredToDate
|
||||||
|
) {
|
||||||
|
self.fileURL = fileURL
|
||||||
|
self.corrupt = corrupt
|
||||||
|
self.defaultValue = defaultValue
|
||||||
|
self.encoder = JSONEncoder.icceryPretty(dateEncoding: dateEncoding)
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
decoder.dateDecodingStrategy = dateDecoding
|
||||||
|
self.decoder = decoder
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes `value` with the shared pretty / sorted-keys encoder.
|
||||||
|
public func encodePretty(_ value: T) throws -> Data {
|
||||||
|
try encoder.encode(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func load() throws -> T {
|
||||||
|
let fm = FileManager.default
|
||||||
|
guard fm.fileExists(atPath: fileURL.path) else {
|
||||||
|
return defaultValue()
|
||||||
|
}
|
||||||
|
let data: Data
|
||||||
|
do {
|
||||||
|
data = try Data(contentsOf: fileURL)
|
||||||
|
} catch {
|
||||||
|
switch corrupt {
|
||||||
|
case .replaceWithDefault:
|
||||||
|
return defaultValue()
|
||||||
|
case .throwCorrupt:
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
return try decoder.decode(T.self, from: data)
|
||||||
|
} catch {
|
||||||
|
switch corrupt {
|
||||||
|
case .replaceWithDefault:
|
||||||
|
return defaultValue()
|
||||||
|
case .throwCorrupt:
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func save(_ value: T) throws {
|
||||||
|
try AtomicFileWriter.write(try encodePretty(value), to: fileURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension JSONEncoder {
|
||||||
|
/// Shared pretty-printed, sorted-keys encoder used by `JSONFileStore`
|
||||||
|
/// and preset export.
|
||||||
|
static func icceryPretty(
|
||||||
|
dateEncoding: JSONEncoder.DateEncodingStrategy = .deferredToDate
|
||||||
|
) -> JSONEncoder {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
encoder.dateEncodingStrategy = dateEncoding
|
||||||
|
return encoder
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,58 +51,6 @@ public struct PatchColor: Codable, Sendable, Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct CIEXYZ: Codable, Sendable, Equatable {
|
|
||||||
public let x: Double
|
|
||||||
public let y: Double
|
|
||||||
public let z: Double
|
|
||||||
|
|
||||||
public init(from decoder: Decoder) throws {
|
|
||||||
var container = try decoder.unkeyedContainer()
|
|
||||||
self.x = try container.decode(Double.self)
|
|
||||||
self.y = try container.decode(Double.self)
|
|
||||||
self.z = try container.decode(Double.self)
|
|
||||||
}
|
|
||||||
|
|
||||||
public init(x: Double, y: Double, z: Double) {
|
|
||||||
self.x = x
|
|
||||||
self.y = y
|
|
||||||
self.z = z
|
|
||||||
}
|
|
||||||
|
|
||||||
public func encode(to encoder: Encoder) throws {
|
|
||||||
var container = encoder.unkeyedContainer()
|
|
||||||
try container.encode(x)
|
|
||||||
try container.encode(y)
|
|
||||||
try container.encode(z)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct CIELab: Codable, Sendable, Equatable {
|
|
||||||
public let l: Double
|
|
||||||
public let a: Double
|
|
||||||
public let b: Double
|
|
||||||
|
|
||||||
public init(from decoder: Decoder) throws {
|
|
||||||
var container = try decoder.unkeyedContainer()
|
|
||||||
self.l = try container.decode(Double.self)
|
|
||||||
self.a = try container.decode(Double.self)
|
|
||||||
self.b = try container.decode(Double.self)
|
|
||||||
}
|
|
||||||
|
|
||||||
public init(l: Double, a: Double, b: Double) {
|
|
||||||
self.l = l
|
|
||||||
self.a = a
|
|
||||||
self.b = b
|
|
||||||
}
|
|
||||||
|
|
||||||
public func encode(to encoder: Encoder) throws {
|
|
||||||
var container = encoder.unkeyedContainer()
|
|
||||||
try container.encode(l)
|
|
||||||
try container.encode(a)
|
|
||||||
try container.encode(b)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct SpectralData: Codable, Sendable, Equatable {
|
public struct SpectralData: Codable, Sendable, Equatable {
|
||||||
public let bands: Int
|
public let bands: Int
|
||||||
public let startNM: Double
|
public let startNM: Double
|
||||||
|
|||||||
@@ -154,11 +154,9 @@ public enum ColorDifference {
|
|||||||
|
|
||||||
/// Resolve a Lab from a `PatchColor`, computing it from XYZ when Lab is absent.
|
/// Resolve a Lab from a `PatchColor`, computing it from XYZ when Lab is absent.
|
||||||
public static func resolveLab(_ color: PatchColor) -> LabColor? {
|
public static func resolveLab(_ color: PatchColor) -> LabColor? {
|
||||||
if let lab = color.lab {
|
if let lab = color.lab { return lab }
|
||||||
return LabColor(l: lab.l, a: lab.a, b: lab.b)
|
|
||||||
}
|
|
||||||
guard let xyz = color.xyz else { return nil }
|
guard let xyz = color.xyz else { return nil }
|
||||||
return LabColorMath.xyzToLab(XYZColor(x: xyz.x, y: xyz.y, z: xyz.z))
|
return LabColorMath.xyzToLab(xyz)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func atan2ToDegrees(_ y: Double, _ x: Double) -> Double {
|
private static func atan2ToDegrees(_ y: Double, _ x: Double) -> Double {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// XYZ tristimulus values, stored in the 0–100 scale used by the Argyll fork.
|
/// XYZ tristimulus values, stored in the 0–100 scale used by the Argyll fork.
|
||||||
public struct XYZColor: Sendable, Equatable {
|
/// Unkeyed Codable matches `ROW_COLORS_JSON` `[x, y, z]`.
|
||||||
|
public struct XYZColor: Codable, Sendable, Equatable {
|
||||||
public let x: Double
|
public let x: Double
|
||||||
public let y: Double
|
public let y: Double
|
||||||
public let z: Double
|
public let z: Double
|
||||||
@@ -11,10 +12,24 @@ public struct XYZColor: Sendable, Equatable {
|
|||||||
self.y = y
|
self.y = y
|
||||||
self.z = z
|
self.z = z
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
var container = try decoder.unkeyedContainer()
|
||||||
|
self.x = try container.decode(Double.self)
|
||||||
|
self.y = try container.decode(Double.self)
|
||||||
|
self.z = try container.decode(Double.self)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// CIELab value (D50).
|
public func encode(to encoder: Encoder) throws {
|
||||||
public struct LabColor: Sendable, Equatable {
|
var container = encoder.unkeyedContainer()
|
||||||
|
try container.encode(x)
|
||||||
|
try container.encode(y)
|
||||||
|
try container.encode(z)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CIELab value (D50). Unkeyed Codable matches `ROW_COLORS_JSON` `[L, a, b]`.
|
||||||
|
public struct LabColor: Codable, Sendable, Equatable {
|
||||||
public let l: Double
|
public let l: Double
|
||||||
public let a: Double
|
public let a: Double
|
||||||
public let b: Double
|
public let b: Double
|
||||||
@@ -24,8 +39,26 @@ public struct LabColor: Sendable, Equatable {
|
|||||||
self.a = a
|
self.a = a
|
||||||
self.b = b
|
self.b = b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
var container = try decoder.unkeyedContainer()
|
||||||
|
self.l = try container.decode(Double.self)
|
||||||
|
self.a = try container.decode(Double.self)
|
||||||
|
self.b = try container.decode(Double.self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.unkeyedContainer()
|
||||||
|
try container.encode(l)
|
||||||
|
try container.encode(a)
|
||||||
|
try container.encode(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON aliases used by `chartread` row payloads.
|
||||||
|
public typealias CIEXYZ = XYZColor
|
||||||
|
public typealias CIELab = LabColor
|
||||||
|
|
||||||
/// sRGB colour in 0–1 display space.
|
/// sRGB colour in 0–1 display space.
|
||||||
public struct DisplayRGB: Sendable, Equatable {
|
public struct DisplayRGB: Sendable, Equatable {
|
||||||
public let r: Double
|
public let r: Double
|
||||||
|
|||||||
@@ -136,31 +136,26 @@ public actor ProcessManager {
|
|||||||
) throws {
|
) throws {
|
||||||
guard !isRunning(id) else { throw ProcessError.duplicateID(id) }
|
guard !isRunning(id) else { throw ProcessError.duplicateID(id) }
|
||||||
|
|
||||||
let process = Process()
|
let prepared = makeProcess(
|
||||||
let stdinPipe = Pipe()
|
binary: binary,
|
||||||
let stdoutPipe = Pipe()
|
arguments: arguments,
|
||||||
let stderrPipe = Pipe()
|
workingDirectory: workingDirectory,
|
||||||
process.executableURL = binary
|
environment: environment,
|
||||||
process.arguments = arguments
|
includeStdin: true
|
||||||
process.currentDirectoryURL = workingDirectory
|
|
||||||
process.standardInput = stdinPipe
|
|
||||||
process.standardOutput = stdoutPipe
|
|
||||||
process.standardError = stderrPipe
|
|
||||||
process.environment = childEnvironment(extra: environment)
|
|
||||||
|
|
||||||
AppLogger(category: "process").debug(
|
|
||||||
"spawn \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))"
|
|
||||||
)
|
)
|
||||||
|
let process = prepared.process
|
||||||
|
|
||||||
|
logSpawn(id: id, binary: binary, arguments: arguments, captured: false)
|
||||||
|
|
||||||
children[id] = RunningChild(
|
children[id] = RunningChild(
|
||||||
process: process,
|
process: process,
|
||||||
stdin: stdinPipe.fileHandleForWriting,
|
stdin: prepared.stdinPipe?.fileHandleForWriting,
|
||||||
stdoutDecoder: ProcessLineDecoder(),
|
stdoutDecoder: ProcessLineDecoder(),
|
||||||
stderrDecoder: ProcessLineDecoder()
|
stderrDecoder: ProcessLineDecoder()
|
||||||
)
|
)
|
||||||
|
|
||||||
let stdoutHandle = stdoutPipe.fileHandleForReading
|
let stdoutHandle = prepared.stdoutPipe.fileHandleForReading
|
||||||
let stderrHandle = stderrPipe.fileHandleForReading
|
let stderrHandle = prepared.stderrPipe.fileHandleForReading
|
||||||
stdoutHandle.readabilityHandler = { [weak self] handle in
|
stdoutHandle.readabilityHandler = { [weak self] handle in
|
||||||
let data = handle.availableData
|
let data = handle.availableData
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
@@ -172,27 +167,23 @@ public actor ProcessManager {
|
|||||||
Task { await self.ingestOutput(data, id: id, isStderr: true, handle: handle) }
|
Task { await self.ingestOutput(data, id: id, isStderr: true, handle: handle) }
|
||||||
}
|
}
|
||||||
|
|
||||||
process.terminationHandler = { [weak self] proc in
|
attachTerminationHandler(process) { [weak self] code in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
Task { await self.didTerminate(id: id, code: proc.terminationStatus) }
|
Task { await self.didTerminate(id: id, code: code) }
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try process.run()
|
try process.run()
|
||||||
// Fallback watchdog: very fast child exits can race past the
|
|
||||||
// terminationHandler delivery on a loaded host. waitUntilExit()
|
|
||||||
// blocks the detached thread and guarantees didTerminate runs.
|
|
||||||
Task.detached { [weak self, process] in
|
|
||||||
process.waitUntilExit()
|
|
||||||
guard let self else { return }
|
|
||||||
await self.didTerminate(id: id, code: process.terminationStatus)
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
preKillHooks.removeValue(forKey: id)
|
preKillHooks.removeValue(forKey: id)
|
||||||
children.removeValue(forKey: id)
|
children.removeValue(forKey: id)
|
||||||
emit(.error(id: id, message: error.localizedDescription))
|
emit(.error(id: id, message: error.localizedDescription))
|
||||||
throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)")
|
throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
|
startWaitUntilExitWatchdog(process) { [weak self] code in
|
||||||
|
guard let self else { return }
|
||||||
|
Task { await self.didTerminate(id: id, code: code) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Spawn (captured)
|
// MARK: - Spawn (captured)
|
||||||
@@ -210,19 +201,18 @@ public actor ProcessManager {
|
|||||||
) async throws -> CapturedResult {
|
) async throws -> CapturedResult {
|
||||||
guard !isRunning(id) else { throw ProcessError.duplicateID(id) }
|
guard !isRunning(id) else { throw ProcessError.duplicateID(id) }
|
||||||
|
|
||||||
let process = Process()
|
let prepared = makeProcess(
|
||||||
let stdoutPipe = Pipe()
|
binary: binary,
|
||||||
let stderrPipe = Pipe()
|
arguments: arguments,
|
||||||
process.executableURL = binary
|
workingDirectory: workingDirectory,
|
||||||
process.arguments = arguments
|
environment: environment,
|
||||||
process.currentDirectoryURL = workingDirectory
|
includeStdin: false
|
||||||
process.standardOutput = stdoutPipe
|
|
||||||
process.standardError = stderrPipe
|
|
||||||
process.environment = childEnvironment(extra: environment)
|
|
||||||
|
|
||||||
AppLogger(category: "process").debug(
|
|
||||||
"spawn(captured) \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))"
|
|
||||||
)
|
)
|
||||||
|
let process = prepared.process
|
||||||
|
let stdoutPipe = prepared.stdoutPipe
|
||||||
|
let stderrPipe = prepared.stderrPipe
|
||||||
|
|
||||||
|
logSpawn(id: id, binary: binary, arguments: arguments, captured: true)
|
||||||
|
|
||||||
// Register and set up the termination hand-off before run() so
|
// Register and set up the termination hand-off before run() so
|
||||||
// a very fast exit is never missed (#50, #52).
|
// a very fast exit is never missed (#50, #52).
|
||||||
@@ -275,20 +265,12 @@ public actor ProcessManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let box = Box()
|
let box = Box()
|
||||||
capturedProcess.terminationHandler = { proc in
|
attachTerminationHandler(capturedProcess) { status in
|
||||||
_ = box.resume(with: proc.terminationStatus)
|
_ = box.resume(with: status)
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try process.run()
|
try process.run()
|
||||||
// Fallback watchdog: very fast child exits can race past the
|
|
||||||
// terminationHandler delivery on a loaded host. waitUntilExit()
|
|
||||||
// blocks the detached thread and resumes the box if the handler
|
|
||||||
// did not already do so (#50, #52).
|
|
||||||
Task.detached { [capturedProcess] in
|
|
||||||
capturedProcess.waitUntilExit()
|
|
||||||
_ = box.resume(with: capturedProcess.terminationStatus)
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
_ = box.resume(with: -1)
|
_ = box.resume(with: -1)
|
||||||
captured.removeValue(forKey: id)
|
captured.removeValue(forKey: id)
|
||||||
@@ -296,6 +278,9 @@ public actor ProcessManager {
|
|||||||
emit(.error(id: id, message: error.localizedDescription))
|
emit(.error(id: id, message: error.localizedDescription))
|
||||||
throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)")
|
throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
|
startWaitUntilExitWatchdog(capturedProcess) { status in
|
||||||
|
_ = box.resume(with: status)
|
||||||
|
}
|
||||||
|
|
||||||
// Close the parent write ends so readDataToEndOfFile() gets EOF
|
// Close the parent write ends so readDataToEndOfFile() gets EOF
|
||||||
// as soon as the child exits; the child still has its own copies.
|
// as soon as the child exits; the child still has its own copies.
|
||||||
@@ -371,12 +356,7 @@ public actor ProcessManager {
|
|||||||
guard var child = children[id], !child.finalized else { return }
|
guard var child = children[id], !child.finalized else { return }
|
||||||
|
|
||||||
if let tail = child.stdoutDecoder.flushPartial() {
|
if let tail = child.stdoutDecoder.flushPartial() {
|
||||||
if tail.hasPrefix(Self.rowColorsPrefix) {
|
emitStdoutLine(id: id, line: tail)
|
||||||
let payload = Data(tail.dropFirst(Self.rowColorsPrefix.count).utf8)
|
|
||||||
emit(.jsonRow(id: id, payload: payload))
|
|
||||||
} else {
|
|
||||||
emit(.stdout(id: id, line: tail))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if let tail = child.stderrDecoder.flushPartial() {
|
if let tail = child.stderrDecoder.flushPartial() {
|
||||||
emit(.stderr(id: id, line: tail))
|
emit(.stderr(id: id, line: tail))
|
||||||
@@ -447,6 +427,86 @@ public actor ProcessManager {
|
|||||||
|
|
||||||
// MARK: - Internals
|
// MARK: - Internals
|
||||||
|
|
||||||
|
private struct PreparedProcess {
|
||||||
|
let process: Process
|
||||||
|
let stdinPipe: Pipe?
|
||||||
|
let stdoutPipe: Pipe
|
||||||
|
let stderrPipe: Pipe
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeProcess(
|
||||||
|
binary: URL,
|
||||||
|
arguments: [String],
|
||||||
|
workingDirectory: URL?,
|
||||||
|
environment: [String: String],
|
||||||
|
includeStdin: Bool
|
||||||
|
) -> PreparedProcess {
|
||||||
|
let process = Process()
|
||||||
|
let stdoutPipe = Pipe()
|
||||||
|
let stderrPipe = Pipe()
|
||||||
|
let stdinPipe: Pipe? = includeStdin ? Pipe() : nil
|
||||||
|
process.executableURL = binary
|
||||||
|
process.arguments = arguments
|
||||||
|
process.currentDirectoryURL = workingDirectory
|
||||||
|
process.standardOutput = stdoutPipe
|
||||||
|
process.standardError = stderrPipe
|
||||||
|
if let stdinPipe {
|
||||||
|
process.standardInput = stdinPipe
|
||||||
|
}
|
||||||
|
process.environment = childEnvironment(extra: environment)
|
||||||
|
return PreparedProcess(
|
||||||
|
process: process,
|
||||||
|
stdinPipe: stdinPipe,
|
||||||
|
stdoutPipe: stdoutPipe,
|
||||||
|
stderrPipe: stderrPipe
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated func logSpawn(
|
||||||
|
id: String,
|
||||||
|
binary: URL,
|
||||||
|
arguments: [String],
|
||||||
|
captured: Bool
|
||||||
|
) {
|
||||||
|
let prefix = captured ? "spawn(captured)" : "spawn"
|
||||||
|
AppLogger(category: "process").debug(
|
||||||
|
"\(prefix) \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `terminationHandler` can lose a fast-exit race on a loaded host;
|
||||||
|
/// `waitUntilExit` on a detached thread is the fallback (#50, #52).
|
||||||
|
/// The handler is attached before `run()`; the wait thread starts
|
||||||
|
/// only after a successful launch — `terminationStatus` on an
|
||||||
|
/// unlaunched NSTask raises NSInvalidArgumentException.
|
||||||
|
private func attachTerminationHandler(
|
||||||
|
_ process: Process,
|
||||||
|
onExit: @escaping @Sendable (Int32) -> Void
|
||||||
|
) {
|
||||||
|
process.terminationHandler = { proc in
|
||||||
|
onExit(proc.terminationStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startWaitUntilExitWatchdog(
|
||||||
|
_ process: Process,
|
||||||
|
onExit: @escaping @Sendable (Int32) -> Void
|
||||||
|
) {
|
||||||
|
Task.detached { [process] in
|
||||||
|
process.waitUntilExit()
|
||||||
|
onExit(process.terminationStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func emitStdoutLine(id: String, line: String) {
|
||||||
|
if line.hasPrefix(Self.rowColorsPrefix) {
|
||||||
|
let payload = Data(line.dropFirst(Self.rowColorsPrefix.count).utf8)
|
||||||
|
emit(.jsonRow(id: id, payload: payload))
|
||||||
|
} else {
|
||||||
|
emit(.stdout(id: id, line: line))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func childEnvironment(extra: [String: String]) -> [String: String] {
|
private func childEnvironment(extra: [String: String]) -> [String: String] {
|
||||||
var env = ProcessInfo.processInfo.environment
|
var env = ProcessInfo.processInfo.environment
|
||||||
env["ARGYLL_NOT_INTERACTIVE"] = "1"
|
env["ARGYLL_NOT_INTERACTIVE"] = "1"
|
||||||
@@ -478,15 +538,14 @@ public actor ProcessManager {
|
|||||||
|
|
||||||
let log = AppLogger(category: "subprocess")
|
let log = AppLogger(category: "subprocess")
|
||||||
for line in lines {
|
for line in lines {
|
||||||
if !isStderr, line.hasPrefix(Self.rowColorsPrefix) {
|
if !isStderr {
|
||||||
let payload = Data(line.dropFirst(Self.rowColorsPrefix.count).utf8)
|
emitStdoutLine(id: id, line: line)
|
||||||
emit(.jsonRow(id: id, payload: payload))
|
if !line.hasPrefix(Self.rowColorsPrefix) {
|
||||||
} else if isStderr {
|
log.info("[\(id)] \(line)")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
log.warn("[\(id)] \(line)")
|
log.warn("[\(id)] \(line)")
|
||||||
emit(.stderr(id: id, line: line))
|
emit(.stderr(id: id, line: line))
|
||||||
} else {
|
|
||||||
log.info("[\(id)] \(line)")
|
|
||||||
emit(.stdout(id: id, line: line))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -528,11 +587,7 @@ public actor ProcessManager {
|
|||||||
// Flush unterminated tail lines.
|
// Flush unterminated tail lines.
|
||||||
if var decoder = Optional(child.stdoutDecoder),
|
if var decoder = Optional(child.stdoutDecoder),
|
||||||
let tail = decoder.finish() {
|
let tail = decoder.finish() {
|
||||||
if tail.hasPrefix(Self.rowColorsPrefix) {
|
emitStdoutLine(id: id, line: tail)
|
||||||
emit(.jsonRow(id: id, payload: Data(tail.dropFirst(Self.rowColorsPrefix.count).utf8)))
|
|
||||||
} else {
|
|
||||||
emit(.stdout(id: id, line: tail))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if var decoder = Optional(child.stderrDecoder),
|
if var decoder = Optional(child.stderrDecoder),
|
||||||
let tail = decoder.finish() {
|
let tail = decoder.finish() {
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Canonical `CAL_` / original-stem pairing for Stage 0 (issue #29 / #83).
|
||||||
|
///
|
||||||
|
/// The live wizard basename, the persisted `calibrationOriginalBasename`,
|
||||||
|
/// and the runner all derive identity from this type. Do not add a second
|
||||||
|
/// `hasPrefix("CAL_")` ternary elsewhere.
|
||||||
|
public struct CalibrationIdentity: Equatable, Sendable {
|
||||||
|
/// Never has a `CAL_` prefix. Empty only when the live basename is empty.
|
||||||
|
public var originalBasename: String
|
||||||
|
/// Always `CAL_{original}` when original is non-empty.
|
||||||
|
public var calibrationBasename: String
|
||||||
|
|
||||||
|
public init(originalBasename: String, calibrationBasename: String) {
|
||||||
|
self.originalBasename = originalBasename
|
||||||
|
self.calibrationBasename = calibrationBasename
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func isCalibration(_ basename: String) -> Bool {
|
||||||
|
basename.hasPrefix("CAL_")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The only place that adds a `CAL_` prefix.
|
||||||
|
public static func prefix(_ original: String) -> String {
|
||||||
|
if original.isEmpty { return original }
|
||||||
|
return original.hasPrefix("CAL_") ? original : "CAL_\(original)"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strip a single leading `CAL_` if present.
|
||||||
|
public static func strip(_ basename: String) -> String {
|
||||||
|
basename.hasPrefix("CAL_") ? String(basename.dropFirst(4)) : basename
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive identity from the live wizard basename and the persisted
|
||||||
|
/// original. A non-empty persisted original wins over a `CAL_` live
|
||||||
|
/// name (Force Quit mid-calibration). An empty live basename always
|
||||||
|
/// produces an empty identity — a persisted original must never
|
||||||
|
/// resurrect a target that no longer exists (#83).
|
||||||
|
public static func parse(liveBasename: String, persistedOriginal: String) -> CalibrationIdentity {
|
||||||
|
guard !liveBasename.isEmpty else {
|
||||||
|
return CalibrationIdentity(originalBasename: "", calibrationBasename: "")
|
||||||
|
}
|
||||||
|
let original: String
|
||||||
|
if liveBasename.hasPrefix("CAL_") {
|
||||||
|
original = persistedOriginal.isEmpty ? strip(liveBasename) : persistedOriginal
|
||||||
|
} else {
|
||||||
|
original = liveBasename
|
||||||
|
}
|
||||||
|
return CalibrationIdentity(
|
||||||
|
originalBasename: original,
|
||||||
|
calibrationBasename: prefix(original)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -86,7 +86,7 @@ public enum CalibrationTargenArgs {
|
|||||||
args.append(contentsOf: ["-l", "\(inkLimit)"])
|
args.append(contentsOf: ["-l", "\(inkLimit)"])
|
||||||
}
|
}
|
||||||
|
|
||||||
let calBasename = cleanBasename.hasPrefix("CAL_") ? cleanBasename : "CAL_\(cleanBasename)"
|
let calBasename = CalibrationIdentity.prefix(cleanBasename)
|
||||||
args.append(calBasename)
|
args.append(calBasename)
|
||||||
return args
|
return args
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,18 +26,13 @@ public enum ColprofArgs {
|
|||||||
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
|
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
|
||||||
|
|
||||||
var args: [String] = ["-v"]
|
var args: [String] = ["-v"]
|
||||||
|
|
||||||
args.append(contentsOf: ["-a", config.algorithm])
|
args.append(contentsOf: ["-a", config.algorithm])
|
||||||
args.append(contentsOf: ["-q", config.quality])
|
args.append(contentsOf: ["-q", config.quality])
|
||||||
|
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-t", config.intent))
|
||||||
if let intent = config.intent?.trimmingCharacters(in: .whitespaces), !intent.isEmpty {
|
|
||||||
args.append(contentsOf: ["-t", intent])
|
|
||||||
}
|
|
||||||
|
|
||||||
if let fwa = config.fwa?.trimmingCharacters(in: .whitespaces) {
|
if let fwa = config.fwa?.trimmingCharacters(in: .whitespaces) {
|
||||||
switch fwa.lowercased() {
|
switch fwa.lowercased() {
|
||||||
case "none", "":
|
case "none", "":
|
||||||
// "none" omits the flag; an explicit empty string means bare -f.
|
|
||||||
if fwa.isEmpty {
|
if fwa.isEmpty {
|
||||||
args.append("-f")
|
args.append("-f")
|
||||||
}
|
}
|
||||||
@@ -46,32 +41,20 @@ public enum ColprofArgs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let illuminant = config.illuminant?.trimmingCharacters(in: .whitespaces), !illuminant.isEmpty {
|
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-i", config.illuminant))
|
||||||
args.append(contentsOf: ["-i", illuminant])
|
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-o", config.observer))
|
||||||
}
|
|
||||||
|
|
||||||
if let observer = config.observer?.trimmingCharacters(in: .whitespaces), !observer.isEmpty {
|
|
||||||
args.append(contentsOf: ["-o", observer])
|
|
||||||
}
|
|
||||||
|
|
||||||
if let inputCond = config.inputViewingCond?.trimmingCharacters(in: .whitespaces),
|
if let inputCond = config.inputViewingCond?.trimmingCharacters(in: .whitespaces),
|
||||||
!inputCond.isEmpty, inputCond.lowercased() != "none" {
|
!inputCond.isEmpty, inputCond.lowercased() != "none" {
|
||||||
args.append(contentsOf: ["-c", inputCond])
|
args.append(contentsOf: ["-c", inputCond])
|
||||||
}
|
}
|
||||||
|
|
||||||
if let outputCond = config.outputViewingCond?.trimmingCharacters(in: .whitespaces),
|
if let outputCond = config.outputViewingCond?.trimmingCharacters(in: .whitespaces),
|
||||||
!outputCond.isEmpty, outputCond.lowercased() != "none" {
|
!outputCond.isEmpty, outputCond.lowercased() != "none" {
|
||||||
args.append(contentsOf: ["-d", outputCond])
|
args.append(contentsOf: ["-d", outputCond])
|
||||||
}
|
}
|
||||||
|
|
||||||
let profileDescription = config.description?.trimmingCharacters(in: .whitespaces)
|
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-D", config.description))
|
||||||
if let description = profileDescription, !description.isEmpty {
|
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-C", config.copyright))
|
||||||
args.append(contentsOf: ["-D", description])
|
|
||||||
}
|
|
||||||
|
|
||||||
if let copyright = config.copyright?.trimmingCharacters(in: .whitespaces), !copyright.isEmpty {
|
|
||||||
args.append(contentsOf: ["-C", copyright])
|
|
||||||
}
|
|
||||||
|
|
||||||
args.append(cleanBasename)
|
args.append(cleanBasename)
|
||||||
return args
|
return args
|
||||||
|
|||||||
@@ -79,16 +79,9 @@ public enum PrintcalArgs {
|
|||||||
|
|
||||||
var args: [String] = ["-v", "-e"]
|
var args: [String] = ["-v", "-e"]
|
||||||
|
|
||||||
if config.noInkLimit {
|
args.append(contentsOf: ArgsBuilder.flag("-I", when: config.noInkLimit))
|
||||||
args.append("-I")
|
args.append(contentsOf: ArgsBuilder.flag("-z", when: config.verify))
|
||||||
}
|
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-a", config.previousCalPath))
|
||||||
if config.verify {
|
|
||||||
args.append("-z")
|
|
||||||
}
|
|
||||||
if let previous = config.previousCalPath?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
||||||
!previous.isEmpty {
|
|
||||||
args.append(contentsOf: ["-a", previous])
|
|
||||||
}
|
|
||||||
if let tac = config.totalInkLimit, tac > 0 {
|
if let tac = config.totalInkLimit, tac > 0 {
|
||||||
args.append(contentsOf: ["-m", String(format: "%.1f", tac)])
|
args.append(contentsOf: ["-m", String(format: "%.1f", tac)])
|
||||||
} else if let tac = config.totalInkLimit {
|
} else if let tac = config.totalInkLimit {
|
||||||
@@ -107,7 +100,7 @@ public enum PrintcalArgs {
|
|||||||
}
|
}
|
||||||
args.append(contentsOf: ["-o", config.outputURL.path])
|
args.append(contentsOf: ["-o", config.outputURL.path])
|
||||||
|
|
||||||
let calBasename = cleanBasename.hasPrefix("CAL_") ? cleanBasename : "CAL_\(cleanBasename)"
|
let calBasename = CalibrationIdentity.prefix(cleanBasename)
|
||||||
args.append(calBasename)
|
args.append(calBasename)
|
||||||
return args
|
return args
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ public actor VerificationHistoryStore {
|
|||||||
private var records: [VerificationRecord] = []
|
private var records: [VerificationRecord] = []
|
||||||
|
|
||||||
private let capacity: Int
|
private let capacity: Int
|
||||||
private let encoder: JSONEncoder
|
private let fileStore: JSONFileStore<[VerificationRecord]>
|
||||||
private let decoder: JSONDecoder
|
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
url: URL = AppPaths.appDataDir.appendingPathComponent("verification_history.json"),
|
url: URL = AppPaths.appDataDir.appendingPathComponent("verification_history.json"),
|
||||||
@@ -22,13 +21,13 @@ public actor VerificationHistoryStore {
|
|||||||
) {
|
) {
|
||||||
self.url = url
|
self.url = url
|
||||||
self.capacity = capacity
|
self.capacity = capacity
|
||||||
|
self.fileStore = JSONFileStore(
|
||||||
self.encoder = JSONEncoder()
|
fileURL: url,
|
||||||
self.encoder.dateEncodingStrategy = .iso8601
|
corrupt: .throwCorrupt,
|
||||||
self.encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
defaultValue: { [] },
|
||||||
|
dateEncoding: .iso8601,
|
||||||
self.decoder = JSONDecoder()
|
dateDecoding: .iso8601
|
||||||
self.decoder.dateDecodingStrategy = .iso8601
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Loads records from disk. Returns the existing cache if already loaded.
|
/// Loads records from disk. Returns the existing cache if already loaded.
|
||||||
@@ -37,10 +36,8 @@ public actor VerificationHistoryStore {
|
|||||||
/// is never overwritten in that case.
|
/// is never overwritten in that case.
|
||||||
public func load() throws -> [VerificationRecord] {
|
public func load() throws -> [VerificationRecord] {
|
||||||
guard records.isEmpty else { return records }
|
guard records.isEmpty else { return records }
|
||||||
let fm = FileManager.default
|
guard FileManager.default.fileExists(atPath: url.path) else { return [] }
|
||||||
guard fm.fileExists(atPath: url.path),
|
records = try fileStore.load()
|
||||||
let data = try? Data(contentsOf: url) else { return [] }
|
|
||||||
records = try decoder.decode([VerificationRecord].self, from: data)
|
|
||||||
return records
|
return records
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +73,11 @@ public actor VerificationHistoryStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Removes all history and updates disk.
|
/// Removes all history and updates disk.
|
||||||
|
///
|
||||||
|
/// Loads the existing history first and propagates any load error so an
|
||||||
|
/// unparseable file is never overwritten.
|
||||||
public func clear() throws {
|
public func clear() throws {
|
||||||
|
try load()
|
||||||
try write([])
|
try write([])
|
||||||
records = []
|
records = []
|
||||||
}
|
}
|
||||||
@@ -106,8 +107,7 @@ public actor VerificationHistoryStore {
|
|||||||
|
|
||||||
/// Writes `records` through a temp file and rename.
|
/// Writes `records` through a temp file and rename.
|
||||||
private func write(_ records: [VerificationRecord]) throws {
|
private func write(_ records: [VerificationRecord]) throws {
|
||||||
let data = try encoder.encode(records)
|
try fileStore.save(records)
|
||||||
try AtomicFileWriter.write(data, to: url)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func csvRow(_ fields: [String]) -> String {
|
private func csvRow(_ fields: [String]) -> String {
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension TargenConfig {
|
||||||
|
/// Stage 1 fields of a profiling preset. Optional advanced flags stay
|
||||||
|
/// `nil` when the preset omitted them so argv builders skip the flag.
|
||||||
|
public init(preset: ProfilingPreset, basename: String, workingDirectory: URL?) {
|
||||||
|
self.init(
|
||||||
|
colourSpace: preset.colourSpace.lowercased() == "cmyk" ? .cmyk : .rgb,
|
||||||
|
patchCount: preset.patchCount,
|
||||||
|
whitePatches: preset.whitePatches,
|
||||||
|
blackPatches: preset.blackPatches,
|
||||||
|
greySteps: preset.greySteps,
|
||||||
|
singleChannelSteps: preset.singleChannelSteps,
|
||||||
|
neutralSteps: preset.neutralSteps,
|
||||||
|
neutralConcentration: preset.neutralConcentration,
|
||||||
|
preconditioningProfile: preset.preconditioningProfile,
|
||||||
|
// An explicit `false` is preserved — distinguishable from a
|
||||||
|
// missing key; `-G` is only emitted for `true` (#82).
|
||||||
|
ofpsHighQuality: preset.ofpsHighQuality,
|
||||||
|
ofpsAdaptation: preset.ofpsAdaptation,
|
||||||
|
fullSpreadAlgorithm: preset.fullSpreadAlgorithm.flatMap { FullSpreadAlgorithm(presetValue: $0) }.flatMap { $0 == .ofps ? nil : $0 },
|
||||||
|
totalInkLimit: preset.totalInkLimit,
|
||||||
|
darkEmphasis: preset.darkEmphasis,
|
||||||
|
devicePower: preset.devicePower,
|
||||||
|
basename: basename,
|
||||||
|
workingDirectory: workingDirectory
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PrinttargConfig {
|
||||||
|
public init(
|
||||||
|
preset: ProfilingPreset,
|
||||||
|
basename: String,
|
||||||
|
workingDirectory: URL?,
|
||||||
|
calibrationFile: String?,
|
||||||
|
label: String? = nil
|
||||||
|
) {
|
||||||
|
let page: PageSize
|
||||||
|
let customW: Double
|
||||||
|
let customH: Double
|
||||||
|
if let size = PageSize(rawValue: preset.pageSize) {
|
||||||
|
page = size
|
||||||
|
customW = 210
|
||||||
|
customH = 297
|
||||||
|
} else if let (w, h) = PageSize.parseCustom(preset.pageSize) {
|
||||||
|
page = .custom
|
||||||
|
customW = w
|
||||||
|
customH = h
|
||||||
|
} else {
|
||||||
|
page = .a4
|
||||||
|
customW = 210
|
||||||
|
customH = 297
|
||||||
|
}
|
||||||
|
|
||||||
|
let layout: LayoutOrder
|
||||||
|
let seed = preset.randomSeed ?? 1
|
||||||
|
if preset.noRandomize == true {
|
||||||
|
layout = .raster
|
||||||
|
} else if seed == 1 {
|
||||||
|
layout = .deterministic
|
||||||
|
} else {
|
||||||
|
layout = .customSeed
|
||||||
|
}
|
||||||
|
|
||||||
|
self.init(
|
||||||
|
instrument: PrintInstrument(rawValue: preset.instrument) ?? .i1,
|
||||||
|
pageSize: page,
|
||||||
|
customPageWidth: customW,
|
||||||
|
customPageHeight: customH,
|
||||||
|
bitDepth: preset.bitDepth == 16 ? .sixteen : .eight,
|
||||||
|
dpi: preset.dpi,
|
||||||
|
layoutOrder: layout,
|
||||||
|
customSeed: seed,
|
||||||
|
label: label,
|
||||||
|
calibrationFile: calibrationFile,
|
||||||
|
calibrationEmbedOnly: false,
|
||||||
|
basename: basename,
|
||||||
|
workingDirectory: workingDirectory
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ColprofConfig {
|
||||||
|
public init(
|
||||||
|
preset: ProfilingPreset,
|
||||||
|
basename: String,
|
||||||
|
workingDirectory: URL?,
|
||||||
|
description: String? = nil,
|
||||||
|
copyright: String? = nil
|
||||||
|
) {
|
||||||
|
self.init(
|
||||||
|
algorithm: preset.colprofAlgorithm ?? "l",
|
||||||
|
quality: preset.colprofQuality ?? "m",
|
||||||
|
intent: Self.nilIfEmpty(preset.colprofIntent),
|
||||||
|
fwa: preset.colprofFwa,
|
||||||
|
illuminant: Self.nilIfEmpty(preset.colprofIlluminant),
|
||||||
|
observer: Self.nilIfEmpty(preset.colprofObserver),
|
||||||
|
inputViewingCond: Self.nilIfEmpty(preset.colprofInputViewingCond),
|
||||||
|
outputViewingCond: Self.nilIfEmpty(preset.colprofOutputViewingCond),
|
||||||
|
description: description,
|
||||||
|
copyright: copyright,
|
||||||
|
basename: basename,
|
||||||
|
workingDirectory: workingDirectory
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func nilIfEmpty(_ value: String?) -> String? {
|
||||||
|
guard let value, !value.isEmpty else { return nil }
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User-facing FWA selection for the Stage 4 form, plus the two
|
||||||
|
/// directions of `colprof_fwa` conversion centralised here so the view
|
||||||
|
/// models carry no mapping switches of their own (#82).
|
||||||
|
public enum ColprofFwaSelection: String, CaseIterable, Sendable, Equatable {
|
||||||
|
case none = "none"
|
||||||
|
case empty = ""
|
||||||
|
case D50 = "D50"
|
||||||
|
case D65 = "D65"
|
||||||
|
case custom = "custom"
|
||||||
|
|
||||||
|
public var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .none: return "None"
|
||||||
|
case .empty: return "Bare (-f)"
|
||||||
|
case .D50: return "D50"
|
||||||
|
case .D65: return "D65"
|
||||||
|
case .custom: return "Custom .sp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Preset `colprof_fwa` → selection. `nil`/`"none"` map to `.none`,
|
||||||
|
/// `""` to `.empty`, `D50`/`D65` case-insensitively, and any other
|
||||||
|
/// string is a custom `.sp` path.
|
||||||
|
public init(presetValue: String?) {
|
||||||
|
switch presetValue?.lowercased() {
|
||||||
|
case nil, "none": self = .none
|
||||||
|
case "": self = .empty
|
||||||
|
case "d50": self = .D50
|
||||||
|
case "d65": self = .D65
|
||||||
|
default: self = .custom
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Selection → `colprof_fwa` value. `.custom` returns `customPath`.
|
||||||
|
public func presetValue(customPath: String) -> String? {
|
||||||
|
switch self {
|
||||||
|
case .none: return nil
|
||||||
|
case .empty: return ""
|
||||||
|
case .D50: return "D50"
|
||||||
|
case .D65: return "D65"
|
||||||
|
case .custom: return customPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PageSize {
|
||||||
|
/// `"210x297"` custom page parse used by presets (issue #82).
|
||||||
|
public static func parseCustom(_ raw: String) -> (Double, Double)? {
|
||||||
|
let parts = raw.lowercased().split(separator: "x")
|
||||||
|
guard parts.count == 2,
|
||||||
|
let w = Double(parts[0]), let h = Double(parts[1]),
|
||||||
|
w >= 50, h >= 50 else { return nil }
|
||||||
|
return (w, h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ProfilingPreset {
|
||||||
|
/// Snapshot of the three live configs plus calibration toggles.
|
||||||
|
public init(
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
description: String,
|
||||||
|
targen: TargenConfig,
|
||||||
|
printtarg: PrinttargConfig,
|
||||||
|
colprof: ColprofConfig,
|
||||||
|
calibrationFile: String?,
|
||||||
|
applyCalibration: Bool?
|
||||||
|
) {
|
||||||
|
let pageSize: String
|
||||||
|
if printtarg.pageSize == .custom {
|
||||||
|
pageSize = "\(Int(printtarg.customPageWidth))x\(Int(printtarg.customPageHeight))"
|
||||||
|
} else {
|
||||||
|
pageSize = printtarg.pageSize.rawValue
|
||||||
|
}
|
||||||
|
self.init(
|
||||||
|
id: id,
|
||||||
|
name: name,
|
||||||
|
description: description,
|
||||||
|
colourSpace: targen.colourSpace == .cmyk ? "cmyk" : "rgb",
|
||||||
|
patchCount: targen.patchCount,
|
||||||
|
whitePatches: targen.whitePatches,
|
||||||
|
blackPatches: targen.blackPatches,
|
||||||
|
greySteps: targen.greySteps,
|
||||||
|
singleChannelSteps: targen.singleChannelSteps,
|
||||||
|
neutralSteps: targen.neutralSteps,
|
||||||
|
neutralConcentration: targen.neutralConcentration,
|
||||||
|
preconditioningProfile: targen.preconditioningProfile,
|
||||||
|
ofpsHighQuality: targen.ofpsHighQuality,
|
||||||
|
ofpsAdaptation: targen.ofpsAdaptation,
|
||||||
|
fullSpreadAlgorithm: (targen.fullSpreadAlgorithm ?? .ofps).presetValue,
|
||||||
|
totalInkLimit: targen.totalInkLimit,
|
||||||
|
darkEmphasis: targen.darkEmphasis,
|
||||||
|
devicePower: targen.devicePower,
|
||||||
|
instrument: printtarg.instrument.rawValue,
|
||||||
|
pageSize: pageSize,
|
||||||
|
bitDepth: printtarg.bitDepth.rawValue,
|
||||||
|
dpi: printtarg.dpi,
|
||||||
|
randomSeed: printtarg.layoutOrder == .deterministic ? 1 : printtarg.customSeed,
|
||||||
|
noRandomize: printtarg.layoutOrder == .raster,
|
||||||
|
calibrationFile: calibrationFile,
|
||||||
|
applyCalibration: applyCalibration,
|
||||||
|
colprofAlgorithm: colprof.algorithm,
|
||||||
|
colprofQuality: colprof.quality,
|
||||||
|
colprofIntent: colprof.intent,
|
||||||
|
colprofFwa: colprof.fwa,
|
||||||
|
colprofIlluminant: colprof.illuminant,
|
||||||
|
colprofObserver: colprof.observer,
|
||||||
|
colprofInputViewingCond: colprof.inputViewingCond,
|
||||||
|
colprofOutputViewingCond: colprof.outputViewingCond
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,9 +60,7 @@ public final class PresetStore: Sendable {
|
|||||||
|
|
||||||
/// Single-preset pretty JSON export.
|
/// Single-preset pretty JSON export.
|
||||||
public func export(_ preset: ProfilingPreset) throws -> Data {
|
public func export(_ preset: ProfilingPreset) throws -> Data {
|
||||||
let encoder = JSONEncoder()
|
return try JSONEncoder.icceryPretty().encode(preset)
|
||||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
|
||||||
return try encoder.encode(preset)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parses + validates a preset from JSON. The preset is assigned a
|
/// Parses + validates a preset from JSON. The preset is assigned a
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import Foundation
|
|||||||
/// `~/Library/Application Support/com.gronod.iccery2/settings.json`
|
/// `~/Library/Application Support/com.gronod.iccery2/settings.json`
|
||||||
/// (issue #5 — the v1 path is never read).
|
/// (issue #5 — the v1 path is never read).
|
||||||
///
|
///
|
||||||
/// Writes are atomic (`AtomicFileWriter`). Invalid/corrupt JSON falls
|
/// Writes are atomic (`JSONFileStore` → `AtomicFileWriter`). Invalid/corrupt
|
||||||
/// back to defaults. Saving posts `settingsDidChange` so #20 can
|
/// JSON falls back to defaults. Saving posts `settingsDidChange` so #20 can
|
||||||
/// reclassify swatches.
|
/// reclassify swatches.
|
||||||
public final class SettingsStore: Sendable {
|
public final class SettingsStore: Sendable {
|
||||||
|
|
||||||
@@ -14,18 +14,19 @@ public final class SettingsStore: Sendable {
|
|||||||
Notification.Name("com.gronod.iccery2.settingsDidChange")
|
Notification.Name("com.gronod.iccery2.settingsDidChange")
|
||||||
|
|
||||||
public let fileURL: URL
|
public let fileURL: URL
|
||||||
|
private let store: JSONFileStore<AppSettings>
|
||||||
|
|
||||||
public init(fileURL: URL = AppPaths.appDataDir.appendingPathComponent("settings.json")) {
|
public init(fileURL: URL = AppPaths.appDataDir.appendingPathComponent("settings.json")) {
|
||||||
self.fileURL = fileURL
|
self.fileURL = fileURL
|
||||||
|
self.store = JSONFileStore(
|
||||||
|
fileURL: fileURL,
|
||||||
|
corrupt: .replaceWithDefault,
|
||||||
|
defaultValue: { .default }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func load() -> AppSettings {
|
public func load() -> AppSettings {
|
||||||
guard let data = try? Data(contentsOf: fileURL),
|
(try? store.load()) ?? .default
|
||||||
let settings = try? JSONDecoder().decode(AppSettings.self, from: data)
|
|
||||||
else {
|
|
||||||
return .default
|
|
||||||
}
|
|
||||||
return settings
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validates before persisting — throws `SettingsError` listing
|
/// Validates before persisting — throws `SettingsError` listing
|
||||||
@@ -35,9 +36,7 @@ public final class SettingsStore: Sendable {
|
|||||||
guard errors.isEmpty else {
|
guard errors.isEmpty else {
|
||||||
throw SettingsError.validationFailed(errors)
|
throw SettingsError.validationFailed(errors)
|
||||||
}
|
}
|
||||||
let encoder = JSONEncoder()
|
try store.save(settings)
|
||||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
|
||||||
try AtomicFileWriter.write(encoder.encode(settings), to: fileURL)
|
|
||||||
NotificationCenter.default.post(name: Self.settingsDidChange, object: nil)
|
NotificationCenter.default.post(name: Self.settingsDidChange, object: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,23 +74,24 @@ public struct WizardState: Codable, Equatable, Sendable {
|
|||||||
/// Atomic JSON persistence for `WizardState` (issue #4).
|
/// Atomic JSON persistence for `WizardState` (issue #4).
|
||||||
public final class WizardStateStore: Sendable {
|
public final class WizardStateStore: Sendable {
|
||||||
public let fileURL: URL
|
public let fileURL: URL
|
||||||
|
private let store: JSONFileStore<WizardState>
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
fileURL: URL = AppPaths.appDataDir.appendingPathComponent("wizard_state.json")
|
fileURL: URL = AppPaths.appDataDir.appendingPathComponent("wizard_state.json")
|
||||||
) {
|
) {
|
||||||
self.fileURL = fileURL
|
self.fileURL = fileURL
|
||||||
|
self.store = JSONFileStore(
|
||||||
|
fileURL: fileURL,
|
||||||
|
corrupt: .replaceWithDefault,
|
||||||
|
defaultValue: { .default }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func load() -> WizardState {
|
public func load() -> WizardState {
|
||||||
guard let data = try? Data(contentsOf: fileURL),
|
(try? store.load()) ?? .default
|
||||||
let state = try? JSONDecoder().decode(WizardState.self, from: data)
|
|
||||||
else { return .default }
|
|
||||||
return state
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func save(_ state: WizardState) throws {
|
public func save(_ state: WizardState) throws {
|
||||||
let encoder = JSONEncoder()
|
try store.save(state)
|
||||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
|
||||||
try AtomicFileWriter.write(encoder.encode(state), to: fileURL)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ All measurement, chart generation, and profile mathematics live in the [Gronod A
|
|||||||
| Floor | macOS 14 Sonoma, universal `arm64` + `x86_64` |
|
| Floor | macOS 14 Sonoma, universal `arm64` + `x86_64` |
|
||||||
| 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 | UAT-ready hardening of the v2.0 wizard paths |
|
| M7 | Pre-UAT hardening & baseline consolidation — shipped on `develop` |
|
||||||
|
| M8 | Deduplication/consolidation contracts & UAT-ready hardening (#79–#86) — in flight on `milestone/m8-consolidation` |
|
||||||
| 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
|
||||||
@@ -170,11 +171,11 @@ Agent / branch rules: [`AGENTS.md`](AGENTS.md), [`BUILD-PLAN.md`](BUILD-PLAN.md)
|
|||||||
|
|
||||||
```
|
```
|
||||||
develop
|
develop
|
||||||
└── milestone/mN-<slug> # integration only
|
└── milestone/m8-consolidation # 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. M7 is small; its PRs target `develop` directly. 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 #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.
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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
|
@Bindable var model: CalibrationViewModel
|
||||||
|
@Bindable var wizard: WizardViewModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
@@ -51,11 +52,15 @@ struct CalibrationView: View {
|
|||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Button("Generate Target") { model.generateTarget() }
|
Button("Generate Target") { model.generateTarget() }
|
||||||
.accessibilityIdentifier("btnCalGenerate")
|
.accessibilityIdentifier("btnCalGenerate")
|
||||||
.disabled(!model.canGenerate)
|
.disabled(wizard.basename.isEmpty
|
||||||
|
|| wizard.effectiveWorkingDirectory == nil
|
||||||
|
|| model.isGenerating)
|
||||||
|
|
||||||
Button("Create Layout & Print") { model.createLayout() }
|
Button("Create Layout & Print") { model.createLayout() }
|
||||||
.accessibilityIdentifier("btnCalLayout")
|
.accessibilityIdentifier("btnCalLayout")
|
||||||
.disabled(!model.canGenerate)
|
.disabled(wizard.basename.isEmpty
|
||||||
|
|| wizard.effectiveWorkingDirectory == nil
|
||||||
|
|| model.isGenerating)
|
||||||
|
|
||||||
Button("Measure") { model.measureChart() }
|
Button("Measure") { model.measureChart() }
|
||||||
.accessibilityIdentifier("btnCalMeasure")
|
.accessibilityIdentifier("btnCalMeasure")
|
||||||
@@ -90,13 +95,6 @@ struct CalibrationView: View {
|
|||||||
.frame(minHeight: 80, maxHeight: 120)
|
.frame(minHeight: 80, maxHeight: 120)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let error = model.lastError {
|
|
||||||
Section {
|
|
||||||
Text(error)
|
|
||||||
.foregroundStyle(.red)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ final class CalibrationViewModel {
|
|||||||
var calibrationLog: [String] = []
|
var calibrationLog: [String] = []
|
||||||
var isGenerating = false
|
var isGenerating = false
|
||||||
var isComputing = false
|
var isComputing = false
|
||||||
var lastError: String?
|
|
||||||
|
|
||||||
init(workflow: TargetWorkflowViewModel, profile: ProfileWorkflowViewModel, environment: AppEnvironment) {
|
init(workflow: TargetWorkflowViewModel, profile: ProfileWorkflowViewModel, environment: AppEnvironment) {
|
||||||
self.workflow = workflow
|
self.workflow = workflow
|
||||||
@@ -50,14 +49,15 @@ final class CalibrationViewModel {
|
|||||||
return cwd.appendingPathComponent("\(calBasename).ti3")
|
return cwd.appendingPathComponent("\(calBasename).ti3")
|
||||||
}
|
}
|
||||||
|
|
||||||
private var calBasename: String {
|
private var identity: CalibrationIdentity {
|
||||||
if wizard.basename.hasPrefix("CAL_") { return wizard.basename }
|
CalibrationIdentity.parse(
|
||||||
let original = !wizard.calibrationOriginalBasename.isEmpty
|
liveBasename: wizard.basename,
|
||||||
? wizard.calibrationOriginalBasename
|
persistedOriginal: wizard.calibrationOriginalBasename
|
||||||
: wizard.basename
|
)
|
||||||
return "CAL_\(original)"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var calBasename: String { identity.calibrationBasename }
|
||||||
|
|
||||||
private var calOutputURL: URL? {
|
private var calOutputURL: URL? {
|
||||||
guard let cwd = wizard.effectiveWorkingDirectory else { return nil }
|
guard let cwd = wizard.effectiveWorkingDirectory else { return nil }
|
||||||
return cwd.appendingPathComponent("\(calBasename).cal")
|
return cwd.appendingPathComponent("\(calBasename).cal")
|
||||||
@@ -68,43 +68,38 @@ final class CalibrationViewModel {
|
|||||||
func generateTarget() {
|
func generateTarget() {
|
||||||
guard canGenerate, let cwd = wizard.effectiveWorkingDirectory else { return }
|
guard canGenerate, let cwd = wizard.effectiveWorkingDirectory else { return }
|
||||||
// Snapshot the original (pre-CAL_) basename before changing the live one.
|
// Snapshot the original (pre-CAL_) basename before changing the live one.
|
||||||
if !wizard.basename.hasPrefix("CAL_") {
|
let identity = CalibrationIdentity.parse(
|
||||||
wizard.calibrationOriginalBasename = wizard.basename
|
liveBasename: wizard.basename,
|
||||||
} else if wizard.calibrationOriginalBasename.isEmpty {
|
persistedOriginal: wizard.calibrationOriginalBasename
|
||||||
wizard.calibrationOriginalBasename = String(wizard.basename.dropFirst(4))
|
)
|
||||||
}
|
wizard.calibrationOriginalBasename = identity.originalBasename
|
||||||
let original = wizard.calibrationOriginalBasename
|
wizard.basename = identity.calibrationBasename
|
||||||
wizard.basename = "CAL_\(original)"
|
|
||||||
wizard.sessionMode = .calibration
|
wizard.sessionMode = .calibration
|
||||||
|
|
||||||
isGenerating = true
|
|
||||||
calibrationLog = []
|
|
||||||
lastError = nil
|
|
||||||
|
|
||||||
let config = CalibrationTargenConfig(
|
let config = CalibrationTargenConfig(
|
||||||
colourSpace: colourSpace,
|
colourSpace: colourSpace,
|
||||||
steps: steps,
|
steps: steps,
|
||||||
whitePatches: whitePatches,
|
whitePatches: whitePatches,
|
||||||
includeNeutralEmphasis: includeNeutralEmphasis,
|
includeNeutralEmphasis: includeNeutralEmphasis,
|
||||||
inkLimit: inkLimitValue,
|
inkLimit: inkLimitValue,
|
||||||
basename: original,
|
basename: identity.originalBasename,
|
||||||
workingDirectory: cwd
|
workingDirectory: cwd
|
||||||
)
|
)
|
||||||
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
defer { self.isGenerating = false }
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
_ = try await self.environment.runner.runCalibrationTargen(config: config) { batch in
|
_ = try await ProcessRunSupport.runLogged(
|
||||||
Task { @MainActor [weak self] in
|
setRunning: { self.isGenerating = $0 },
|
||||||
self?.calibrationLog.append(contentsOf: batch)
|
resetLog: { self.calibrationLog = [] },
|
||||||
}
|
onLog: { self.calibrationLog.append(contentsOf: $0) }
|
||||||
|
) { onLog in
|
||||||
|
try await self.environment.runner.runCalibrationTargen(
|
||||||
|
config: config, onLogBatch: onLog)
|
||||||
}
|
}
|
||||||
self.wizard.refreshGating()
|
self.wizard.refreshGating()
|
||||||
self.wizard.showNotice("Calibration target generated.")
|
self.wizard.showNotice("Calibration target generated.")
|
||||||
self.wizard.go(to: .layOutPrint)
|
self.wizard.go(to: .layOutPrint)
|
||||||
} catch {
|
} catch {
|
||||||
self.lastError = error.localizedDescription
|
|
||||||
self.wizard.showNotice(
|
self.wizard.showNotice(
|
||||||
"Calibration target failed: \(error.localizedDescription)",
|
"Calibration target failed: \(error.localizedDescription)",
|
||||||
kind: .error
|
kind: .error
|
||||||
@@ -139,15 +134,13 @@ final class CalibrationViewModel {
|
|||||||
// "already exists" when the user declines overwrite. We do not
|
// "already exists" when the user declines overwrite. We do not
|
||||||
// silently clobber.
|
// silently clobber.
|
||||||
if FileManager.default.fileExists(atPath: outputURL.path) {
|
if FileManager.default.fileExists(atPath: outputURL.path) {
|
||||||
lastError = "\(outputURL.lastPathComponent) already exists. Rename or overwrite it first."
|
wizard.showNotice(
|
||||||
wizard.showNotice(lastError!, kind: .error)
|
"\(outputURL.lastPathComponent) already exists. Rename or overwrite it first.",
|
||||||
|
kind: .error
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
isComputing = true
|
|
||||||
calibrationLog = []
|
|
||||||
lastError = nil
|
|
||||||
|
|
||||||
let config = PrintcalConfig(
|
let config = PrintcalConfig(
|
||||||
ti3Basename: calBasename,
|
ti3Basename: calBasename,
|
||||||
workingDirectory: cwd,
|
workingDirectory: cwd,
|
||||||
@@ -160,13 +153,14 @@ final class CalibrationViewModel {
|
|||||||
)
|
)
|
||||||
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
defer { self.isComputing = false }
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let url = try await self.environment.runner.runPrintcal(config: config) { batch in
|
let url = try await ProcessRunSupport.runLogged(
|
||||||
Task { @MainActor [weak self] in
|
setRunning: { self.isComputing = $0 },
|
||||||
self?.calibrationLog.append(contentsOf: batch)
|
resetLog: { self.calibrationLog = [] },
|
||||||
}
|
onLog: { self.calibrationLog.append(contentsOf: $0) }
|
||||||
|
) { onLog in
|
||||||
|
try await self.environment.runner.runPrintcal(
|
||||||
|
config: config, onLogBatch: onLog)
|
||||||
}
|
}
|
||||||
self.computedCalURL = url
|
self.computedCalURL = url
|
||||||
self.profile.calibrationFile = url.path
|
self.profile.calibrationFile = url.path
|
||||||
@@ -174,7 +168,6 @@ final class CalibrationViewModel {
|
|||||||
self.wizard.showNotice("Calibration curves computed.")
|
self.wizard.showNotice("Calibration curves computed.")
|
||||||
self.wizard.restoreCalibration()
|
self.wizard.restoreCalibration()
|
||||||
} catch {
|
} catch {
|
||||||
self.lastError = error.localizedDescription
|
|
||||||
self.wizard.showNotice(
|
self.wizard.showNotice(
|
||||||
"Calibration curve computation failed: \(error.localizedDescription)",
|
"Calibration curve computation failed: \(error.localizedDescription)",
|
||||||
kind: .error
|
kind: .error
|
||||||
|
|||||||
@@ -29,13 +29,8 @@ final class FileDialogService {
|
|||||||
|
|
||||||
/// `selectTargetFile` — **save** panel for the new `.ti1` target.
|
/// `selectTargetFile` — **save** panel for the new `.ti1` target.
|
||||||
func selectTargetFile(startingAt start: URL? = nil) -> URL? {
|
func selectTargetFile(startingAt start: URL? = nil) -> URL? {
|
||||||
let panel = NSSavePanel()
|
save(named: "target.ti1", extensions: ["ti1"], startingAt: start,
|
||||||
panel.nameFieldStringValue = "target.ti1"
|
message: "Choose the .ti1 target file to create")
|
||||||
panel.allowedContentTypes = utTypes(["ti1"])
|
|
||||||
panel.allowsOtherFileTypes = false
|
|
||||||
panel.directoryURL = start
|
|
||||||
panel.message = "Choose the .ti1 target file to create"
|
|
||||||
return run(panel)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `selectExistingTarget` — open `.ti1`/`.ti2` (docs/06 §Resume, #140).
|
/// `selectExistingTarget` — open `.ti1`/`.ti2` (docs/06 §Resume, #140).
|
||||||
@@ -66,12 +61,7 @@ final class FileDialogService {
|
|||||||
|
|
||||||
/// `selectCsvSavePath` — verification-history CSV export.
|
/// `selectCsvSavePath` — verification-history CSV export.
|
||||||
func selectCsvSavePath(startingAt start: URL? = nil) -> URL? {
|
func selectCsvSavePath(startingAt start: URL? = nil) -> URL? {
|
||||||
let panel = NSSavePanel()
|
save(named: "verification-history.csv", extensions: ["csv"], startingAt: start)
|
||||||
panel.nameFieldStringValue = "verification-history.csv"
|
|
||||||
panel.allowedContentTypes = utTypes(["csv"])
|
|
||||||
panel.allowsOtherFileTypes = false
|
|
||||||
panel.directoryURL = start
|
|
||||||
return run(panel)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `selectCalFile` — `.cal` calibration curves.
|
/// `selectCalFile` — `.cal` calibration curves.
|
||||||
@@ -88,17 +78,27 @@ final class FileDialogService {
|
|||||||
|
|
||||||
/// `btnExportActivePreset` — save a `.json` preset file.
|
/// `btnExportActivePreset` — save a `.json` preset file.
|
||||||
func selectPresetSavePath(name: String, startingAt start: URL? = nil) -> URL? {
|
func selectPresetSavePath(name: String, startingAt start: URL? = nil) -> URL? {
|
||||||
let panel = NSSavePanel()
|
save(named: "\(name).json", extensions: ["json"], startingAt: start,
|
||||||
panel.nameFieldStringValue = "\(name).json"
|
message: "Export this preset as JSON")
|
||||||
panel.allowedContentTypes = utTypes(["json"])
|
|
||||||
panel.allowsOtherFileTypes = false
|
|
||||||
panel.directoryURL = start
|
|
||||||
panel.message = "Export this preset as JSON"
|
|
||||||
return run(panel)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Internals (private — not a shared public picker API)
|
// MARK: - Internals (private — not a shared public picker API)
|
||||||
|
|
||||||
|
private func save(
|
||||||
|
named: String,
|
||||||
|
extensions: [String],
|
||||||
|
startingAt start: URL?,
|
||||||
|
message: String? = nil
|
||||||
|
) -> URL? {
|
||||||
|
let panel = NSSavePanel()
|
||||||
|
panel.nameFieldStringValue = named
|
||||||
|
panel.allowedContentTypes = utTypes(extensions)
|
||||||
|
panel.allowsOtherFileTypes = false
|
||||||
|
panel.directoryURL = start
|
||||||
|
if let message { panel.message = message }
|
||||||
|
return run(panel)
|
||||||
|
}
|
||||||
|
|
||||||
private func open(
|
private func open(
|
||||||
extensions: [String],
|
extensions: [String],
|
||||||
startingAt start: URL?,
|
startingAt start: URL?,
|
||||||
|
|||||||
@@ -36,6 +36,17 @@ struct ICCeryApp: App {
|
|||||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
private var terminationRequested = false
|
private var terminationRequested = false
|
||||||
|
|
||||||
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
|
// SwiftUI `Window` scenes launched by XCTest stay
|
||||||
|
// `.runningBackground` unless the app takes regular activation
|
||||||
|
// and orders the window front (CI run 29804).
|
||||||
|
NSApp.setActivationPolicy(.regular)
|
||||||
|
for window in NSApp.windows {
|
||||||
|
window.makeKeyAndOrderFront(nil)
|
||||||
|
}
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
}
|
||||||
|
|
||||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,15 +63,15 @@ final class MeasurementWorkflowViewModel {
|
|||||||
var rows: [ChartreadRow] = []
|
var rows: [ChartreadRow] = []
|
||||||
var swatchRows: [SwatchRow] = []
|
var swatchRows: [SwatchRow] = []
|
||||||
var showRemoveSheetNotice = false
|
var showRemoveSheetNotice = false
|
||||||
var lastError: String?
|
/// Stage-local chartread error notice (`#chartreadLastError`, #80).
|
||||||
|
var chartreadNotice: Notice?
|
||||||
private var chartreadTask: Task<Void, Never>?
|
private var chartreadTask: Task<Void, Never>?
|
||||||
|
|
||||||
// MARK: - Averaging
|
// MARK: - Averaging
|
||||||
|
|
||||||
var passSnapshots: [URL] = []
|
var passSnapshots: [URL] = []
|
||||||
var isFinishing = false
|
var isFinishing = false
|
||||||
var finishNotice: String?
|
var finishNotice: Notice?
|
||||||
var finishNoticeIsError = false
|
|
||||||
var resumedFromTi2 = false
|
var resumedFromTi2 = false
|
||||||
|
|
||||||
init(wizard: WizardViewModel, environment: AppEnvironment) {
|
init(wizard: WizardViewModel, environment: AppEnvironment) {
|
||||||
@@ -186,7 +186,7 @@ final class MeasurementWorkflowViewModel {
|
|||||||
isChartreadRunning = true
|
isChartreadRunning = true
|
||||||
chartreadState = .idle
|
chartreadState = .idle
|
||||||
currentPrompt = nil
|
currentPrompt = nil
|
||||||
lastError = nil
|
chartreadNotice = nil
|
||||||
chartreadLog.removeAll()
|
chartreadLog.removeAll()
|
||||||
|
|
||||||
// Optional: reset rows when starting a fresh first pass.
|
// Optional: reset rows when starting a fresh first pass.
|
||||||
@@ -228,7 +228,10 @@ final class MeasurementWorkflowViewModel {
|
|||||||
|
|
||||||
case .exit(let code):
|
case .exit(let code):
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
lastError = "chartread exited with code \(code)"
|
chartreadNotice = Notice(
|
||||||
|
kind: .error,
|
||||||
|
text: "chartread exited with code \(code)"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
case .completed(let canonicalURL):
|
case .completed(let canonicalURL):
|
||||||
@@ -236,7 +239,7 @@ final class MeasurementWorkflowViewModel {
|
|||||||
completePass(canonicalURL: canonicalURL)
|
completePass(canonicalURL: canonicalURL)
|
||||||
|
|
||||||
case .failed(let error):
|
case .failed(let error):
|
||||||
lastError = error.localizedDescription
|
chartreadNotice = Notice(kind: .error, text: error.localizedDescription)
|
||||||
chartreadState = .error
|
chartreadState = .error
|
||||||
isChartreadRunning = false
|
isChartreadRunning = false
|
||||||
}
|
}
|
||||||
@@ -382,7 +385,10 @@ final class MeasurementWorkflowViewModel {
|
|||||||
discoverPassSnapshots()
|
discoverPassSnapshots()
|
||||||
wizard.refreshGating()
|
wizard.refreshGating()
|
||||||
} catch {
|
} catch {
|
||||||
lastError = "Could not snapshot pass: \(error.localizedDescription)"
|
chartreadNotice = Notice(
|
||||||
|
kind: .error,
|
||||||
|
text: "Could not snapshot pass: \(error.localizedDescription)"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,33 +404,32 @@ final class MeasurementWorkflowViewModel {
|
|||||||
|
|
||||||
func finishAndAverage() {
|
func finishAndAverage() {
|
||||||
guard !isFinishing, let cwd = workingDirectory, !passSnapshots.isEmpty else { return }
|
guard !isFinishing, let cwd = workingDirectory, !passSnapshots.isEmpty else { return }
|
||||||
isFinishing = true
|
|
||||||
finishNotice = nil
|
finishNotice = nil
|
||||||
finishNoticeIsError = false
|
|
||||||
|
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
do {
|
do {
|
||||||
let canonical: URL
|
// No log reset: prior chartread output must be preserved.
|
||||||
|
let canonical = try await ProcessRunSupport.runLogged(
|
||||||
|
setRunning: { self.isFinishing = $0 },
|
||||||
|
resetLog: {},
|
||||||
|
onLog: { self.chartreadLog.append(contentsOf: $0) }
|
||||||
|
) { onLog in
|
||||||
if self.passSnapshots.count == 1, let pass = self.passSnapshots.first {
|
if self.passSnapshots.count == 1, let pass = self.passSnapshots.first {
|
||||||
canonical = try MeasurementArtefacts.promotePass(
|
return try MeasurementArtefacts.promotePass(
|
||||||
pass: pass,
|
pass: pass,
|
||||||
basename: self.basename,
|
basename: self.basename,
|
||||||
cwd: cwd
|
cwd: cwd
|
||||||
)
|
)
|
||||||
} else {
|
}
|
||||||
let config = AverageConfig(
|
let config = AverageConfig(
|
||||||
workingDirectory: cwd,
|
workingDirectory: cwd,
|
||||||
basename: self.basename,
|
basename: self.basename,
|
||||||
passFiles: self.passSnapshots
|
passFiles: self.passSnapshots
|
||||||
)
|
)
|
||||||
canonical = try await self.environment.runner.runAverage(
|
return try await self.environment.runner.runAverage(
|
||||||
config: config,
|
config: config,
|
||||||
onLogBatch: { [weak self] batch in
|
onLogBatch: onLog
|
||||||
Task { @MainActor [weak self] in
|
|
||||||
self?.chartreadLog.append(contentsOf: batch)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
self.discoverPassSnapshots()
|
self.discoverPassSnapshots()
|
||||||
@@ -432,7 +437,11 @@ final class MeasurementWorkflowViewModel {
|
|||||||
if self.wizard.isUnlocked(.buildProfile) {
|
if self.wizard.isUnlocked(.buildProfile) {
|
||||||
self.wizard.go(to: .buildProfile)
|
self.wizard.go(to: .buildProfile)
|
||||||
} else {
|
} else {
|
||||||
self.finishNotice = "Finished: \(canonical.lastPathComponent) ready."
|
self.finishNotice = Notice(
|
||||||
|
kind: .info,
|
||||||
|
text: "Finished: \(canonical.lastPathComponent) ready.",
|
||||||
|
autoHideAfter: nil
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Fallback to pass 1 promotion if averaging failed.
|
// Fallback to pass 1 promotion if averaging failed.
|
||||||
@@ -445,18 +454,26 @@ final class MeasurementWorkflowViewModel {
|
|||||||
)
|
)
|
||||||
self.discoverPassSnapshots()
|
self.discoverPassSnapshots()
|
||||||
self.wizard.refreshGating()
|
self.wizard.refreshGating()
|
||||||
self.finishNotice = "Averaging failed — promoted first pass."
|
self.finishNotice = Notice(
|
||||||
self.finishNoticeIsError = true
|
kind: .error,
|
||||||
|
text: "Averaging failed — promoted first pass.",
|
||||||
|
autoHideAfter: nil
|
||||||
|
)
|
||||||
} catch {
|
} catch {
|
||||||
self.finishNotice = "Finish failed: \(error.localizedDescription)"
|
self.finishNotice = Notice(
|
||||||
self.finishNoticeIsError = true
|
kind: .error,
|
||||||
|
text: "Finish failed: \(error.localizedDescription)",
|
||||||
|
autoHideAfter: nil
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
self.finishNotice = "Finish failed: \(error.localizedDescription)"
|
self.finishNotice = Notice(
|
||||||
self.finishNoticeIsError = true
|
kind: .error,
|
||||||
}
|
text: "Finish failed: \(error.localizedDescription)",
|
||||||
}
|
autoHideAfter: nil
|
||||||
self.isFinishing = false
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ struct Notice: Identifiable, Equatable {
|
|||||||
case .error: return .red
|
case .error: return .red
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var accessibilityValue: String {
|
||||||
|
switch self {
|
||||||
|
case .info: return "info"
|
||||||
|
case .warning: return "warning"
|
||||||
|
case .error: return "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let id = UUID()
|
let id = UUID()
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// CUPS queue selection, bound print panel, and `lp` spool (issues 12–15, 17 / #85).
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class PrintSessionViewModel {
|
||||||
|
let wizard: WizardViewModel
|
||||||
|
let environment: AppEnvironment
|
||||||
|
|
||||||
|
var printers: [Printer] = []
|
||||||
|
var selectedPrinter = ""
|
||||||
|
var printerCaps = PrinterCapabilities()
|
||||||
|
var selectedTray: Int?
|
||||||
|
var selectedMediaType: String?
|
||||||
|
var printOrientation = "portrait"
|
||||||
|
var capturedCupsOptions: [String: String] = [:]
|
||||||
|
var printNotice: Notice?
|
||||||
|
var isPrinting = false
|
||||||
|
private var printTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
init(wizard: WizardViewModel, environment: AppEnvironment) {
|
||||||
|
self.wizard = wizard
|
||||||
|
self.environment = environment
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshPrinters() {
|
||||||
|
let cups = environment.cupsService
|
||||||
|
Task { @MainActor in
|
||||||
|
do {
|
||||||
|
let list = try await cups.listPrinters()
|
||||||
|
printers = list
|
||||||
|
if !list.contains(where: { $0.name == selectedPrinter }) {
|
||||||
|
selectedPrinter = list.first { $0.isDefault }?.name
|
||||||
|
?? list.first?.name ?? ""
|
||||||
|
}
|
||||||
|
await reloadSelectedCapabilities()
|
||||||
|
} catch {
|
||||||
|
printNotice = Notice(
|
||||||
|
kind: .error,
|
||||||
|
text: "Could not list printers: \(error.localizedDescription)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reloadSelectedCapabilities() async {
|
||||||
|
guard !selectedPrinter.isEmpty else {
|
||||||
|
printerCaps = PrinterCapabilities()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
printerCaps = try await environment.cupsService
|
||||||
|
.capabilities(for: selectedPrinter)
|
||||||
|
if selectedMediaType == nil {
|
||||||
|
selectedMediaType = printerCaps.mediaTypes.first?.id
|
||||||
|
}
|
||||||
|
if selectedTray == nil {
|
||||||
|
selectedTray = printerCaps.trays.first?.id
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
printerCaps = PrinterCapabilities()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func openPrinterPreferences() {
|
||||||
|
guard !selectedPrinter.isEmpty else { return }
|
||||||
|
let queue = selectedPrinter
|
||||||
|
let displayName = printers.first { $0.name == queue }?.displayName
|
||||||
|
let cups = environment.cupsService
|
||||||
|
Task { @MainActor in
|
||||||
|
do {
|
||||||
|
guard let result = try await PrintPanelService()
|
||||||
|
.showProperties(
|
||||||
|
queue: queue, displayName: displayName,
|
||||||
|
cupsService: cups)
|
||||||
|
else {
|
||||||
|
printNotice = Notice(
|
||||||
|
kind: .info,
|
||||||
|
text: "Printer properties dialog cancelled.",
|
||||||
|
autoHideAfter: nil
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let selected = result.selectedPrinter,
|
||||||
|
printers.contains(where: { $0.name == selected }),
|
||||||
|
selected != queue {
|
||||||
|
selectedPrinter = selected
|
||||||
|
await reloadSelectedCapabilities()
|
||||||
|
}
|
||||||
|
if let captured = result.options.cupsOptions {
|
||||||
|
capturedCupsOptions[selectedPrinter] = captured
|
||||||
|
}
|
||||||
|
if let media = result.options.mediaType {
|
||||||
|
selectedMediaType = media
|
||||||
|
}
|
||||||
|
printNotice = Notice(
|
||||||
|
kind: .info,
|
||||||
|
text: "Settings captured for \(selectedPrinter).",
|
||||||
|
autoHideAfter: nil
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
printNotice = Notice(kind: .error, text: error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func printAllPages(from result: PrinttargResult, pageSize: PageSize) {
|
||||||
|
guard !isPrinting else { return }
|
||||||
|
isPrinting = true
|
||||||
|
let task = Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
defer { self.printTask = nil }
|
||||||
|
var printed = 0
|
||||||
|
for page in result.pages {
|
||||||
|
do {
|
||||||
|
try await spool(page, index: page.index, pageSize: pageSize)
|
||||||
|
printed += 1
|
||||||
|
} catch {
|
||||||
|
printNotice = Notice(
|
||||||
|
kind: .error,
|
||||||
|
text: "Print failed on \(page.page.filename): "
|
||||||
|
+ error.localizedDescription
|
||||||
|
)
|
||||||
|
isPrinting = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
printNotice = Notice(
|
||||||
|
kind: .info,
|
||||||
|
text: "Sent \(printed) page(s) to \(selectedPrinter).",
|
||||||
|
autoHideAfter: nil
|
||||||
|
)
|
||||||
|
isPrinting = false
|
||||||
|
}
|
||||||
|
printTask = task
|
||||||
|
}
|
||||||
|
|
||||||
|
func printPage(_ page: GalleryPage, pageSize: PageSize) {
|
||||||
|
guard !isPrinting else { return }
|
||||||
|
isPrinting = true
|
||||||
|
let task = Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
defer { self.printTask = nil }
|
||||||
|
do {
|
||||||
|
try await spool(page, index: page.index, pageSize: pageSize)
|
||||||
|
printNotice = Notice(
|
||||||
|
kind: .info,
|
||||||
|
text: "Sent \(page.page.filename) to \(selectedPrinter).",
|
||||||
|
autoHideAfter: nil
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
printNotice = Notice(
|
||||||
|
kind: .error,
|
||||||
|
text: "Print failed: \(error.localizedDescription)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
isPrinting = false
|
||||||
|
}
|
||||||
|
printTask = task
|
||||||
|
}
|
||||||
|
|
||||||
|
private func spool(_ page: GalleryPage, index: Int, pageSize: PageSize) async throws {
|
||||||
|
guard !selectedPrinter.isEmpty else {
|
||||||
|
throw CupsError.noPrinterSelected
|
||||||
|
}
|
||||||
|
let options = PrintOptions(
|
||||||
|
orientation: printOrientation,
|
||||||
|
paperSize: pageSize == .custom ? nil : pageSize.rawValue,
|
||||||
|
mediaType: selectedMediaType,
|
||||||
|
ppdUncorrectedPassthrough: true,
|
||||||
|
cupsOptions: capturedCupsOptions[selectedPrinter])
|
||||||
|
try await environment.cupsService.printTarget(
|
||||||
|
queue: selectedPrinter,
|
||||||
|
tiffPath: page.fileURL.path,
|
||||||
|
options: options,
|
||||||
|
page: index)
|
||||||
|
wizard.printerName = selectedPrinter
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Shared monospaced process-log disclosure used by Stage 1 and Stage 2.
|
||||||
|
struct ProcessLogView: View {
|
||||||
|
let lines: [String]
|
||||||
|
var minHeight: CGFloat = 120
|
||||||
|
var maxHeight: CGFloat = 200
|
||||||
|
var containerId: String
|
||||||
|
var logId: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
DisclosureGroup("Process log") {
|
||||||
|
ScrollView {
|
||||||
|
Text(lines.joined(separator: "\n"))
|
||||||
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
.frame(minHeight: minHeight, maxHeight: maxHeight)
|
||||||
|
.accessibilityIdentifier(logId)
|
||||||
|
}
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityIdentifier(containerId)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Shared hop for coalesced Argyll log batches (issue #80).
|
||||||
|
/// The runner invokes the sink off the main actor; this is the single hop back.
|
||||||
|
enum ProcessRunSupport {
|
||||||
|
static func logSink(
|
||||||
|
_ apply: @escaping @MainActor @Sendable ([String]) -> Void
|
||||||
|
) -> @Sendable ([String]) -> Void {
|
||||||
|
{ batch in
|
||||||
|
Task { @MainActor in apply(batch) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wraps a runner call with running/log bookkeeping.
|
||||||
|
/// `work` stays on the main actor so `T` does not cross isolation
|
||||||
|
/// (Swift 6: non-Sendable generic return from a nonisolated async fn).
|
||||||
|
@MainActor
|
||||||
|
static func runLogged<T>(
|
||||||
|
setRunning: (Bool) -> Void,
|
||||||
|
resetLog: () -> Void,
|
||||||
|
onLog: @escaping @MainActor @Sendable ([String]) -> Void,
|
||||||
|
work: @MainActor @escaping (@escaping @Sendable ([String]) -> Void) async throws -> T
|
||||||
|
) async throws -> T {
|
||||||
|
setRunning(true)
|
||||||
|
resetLog()
|
||||||
|
defer { setRunning(false) }
|
||||||
|
return try await work(logSink(onLog))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,25 +3,6 @@ import Observation
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import ICCeryCore
|
import ICCeryCore
|
||||||
|
|
||||||
/// User-facing FWA selection for the Stage 4 form.
|
|
||||||
enum ColprofFwaSelection: String, CaseIterable, Sendable, Equatable {
|
|
||||||
case none = "none"
|
|
||||||
case empty = ""
|
|
||||||
case D50 = "D50"
|
|
||||||
case D65 = "D65"
|
|
||||||
case custom = "custom"
|
|
||||||
|
|
||||||
var displayName: String {
|
|
||||||
switch self {
|
|
||||||
case .none: return "None"
|
|
||||||
case .empty: return "Bare (-f)"
|
|
||||||
case .D50: return "D50"
|
|
||||||
case .D65: return "D65"
|
|
||||||
case .custom: return "Custom .sp"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stage 4/5 workflow: build a profile, verify it, track drift, and install.
|
/// Stage 4/5 workflow: build a profile, verify it, track drift, and install.
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
@@ -50,7 +31,6 @@ final class ProfileWorkflowViewModel {
|
|||||||
var isColprofRunning = false
|
var isColprofRunning = false
|
||||||
var colprofLog: [String] = []
|
var colprofLog: [String] = []
|
||||||
var colprofProgress: String?
|
var colprofProgress: String?
|
||||||
var lastError: String?
|
|
||||||
var createdProfileURL: URL?
|
var createdProfileURL: URL?
|
||||||
/// Path to the `.gam` gamut mesh extracted post-`colprof` (issue #28).
|
/// Path to the `.gam` gamut mesh extracted post-`colprof` (issue #28).
|
||||||
var createdGamutURL: URL?
|
var createdGamutURL: URL?
|
||||||
@@ -110,39 +90,31 @@ final class ProfileWorkflowViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var fwaValue: String? {
|
var fwaValue: String? {
|
||||||
switch fwaSelection {
|
fwaSelection.presetValue(customPath: fwaCustomPath)
|
||||||
case .none: return nil
|
|
||||||
case .empty: return ""
|
|
||||||
case .D50: return "D50"
|
|
||||||
case .D65: return "D65"
|
|
||||||
case .custom: return fwaCustomPath
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Preset application
|
// MARK: - Preset application
|
||||||
|
|
||||||
func applyPreset(_ preset: ProfilingPreset?) {
|
func applyPreset(_ preset: ProfilingPreset?) {
|
||||||
guard let preset else { return }
|
guard let preset else { return }
|
||||||
algorithm = preset.colprofAlgorithm ?? "l"
|
let config = ColprofConfig(
|
||||||
quality = preset.colprofQuality ?? "m"
|
preset: preset,
|
||||||
intent = preset.colprofIntent ?? ""
|
basename: wizard.basename,
|
||||||
|
workingDirectory: wizard.effectiveWorkingDirectory
|
||||||
if let fwa = preset.colprofFwa {
|
)
|
||||||
switch fwa.lowercased() {
|
algorithm = config.algorithm
|
||||||
case "none": fwaSelection = .none
|
quality = config.quality
|
||||||
case "": fwaSelection = .empty
|
intent = config.intent ?? ""
|
||||||
case "d50": fwaSelection = .D50
|
fwaSelection = ColprofFwaSelection(presetValue: config.fwa)
|
||||||
case "d65": fwaSelection = .D65
|
fwaCustomPath = fwaSelection == .custom ? (config.fwa ?? "") : ""
|
||||||
default:
|
illuminant = config.illuminant ?? ""
|
||||||
fwaSelection = .custom
|
observer = config.observer ?? ""
|
||||||
fwaCustomPath = fwa
|
inputViewingCond = config.inputViewingCond ?? ""
|
||||||
}
|
outputViewingCond = config.outputViewingCond ?? ""
|
||||||
}
|
profileDescription = ""
|
||||||
|
copyright = ""
|
||||||
illuminant = preset.colprofIlluminant ?? ""
|
applyCalibration = preset.applyCalibration == true
|
||||||
observer = preset.colprofObserver ?? ""
|
calibrationFile = preset.calibrationFile ?? ""
|
||||||
inputViewingCond = preset.colprofInputViewingCond ?? ""
|
|
||||||
outputViewingCond = preset.colprofOutputViewingCond ?? ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stage 4 form values for saving into a custom preset.
|
/// Stage 4 form values for saving into a custom preset.
|
||||||
@@ -192,29 +164,25 @@ final class ProfileWorkflowViewModel {
|
|||||||
guard canCreateProfile, let _ = wizard.effectiveWorkingDirectory else { return }
|
guard canCreateProfile, let _ = wizard.effectiveWorkingDirectory else { return }
|
||||||
let config = buildColprofConfig()
|
let config = buildColprofConfig()
|
||||||
|
|
||||||
isColprofRunning = true
|
|
||||||
colprofLog = []
|
|
||||||
colprofProgress = nil
|
colprofProgress = nil
|
||||||
lastError = nil
|
|
||||||
createdProfileURL = nil
|
createdProfileURL = nil
|
||||||
createdGamutURL = nil
|
createdGamutURL = nil
|
||||||
|
|
||||||
let runner = environment.runner
|
let runner = environment.runner
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
defer { self.isColprofRunning = false }
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let url = try await runner.runColprof(config: config) { [weak self] batch in
|
let outcome = try await ProcessRunSupport.runLogged(
|
||||||
Task { @MainActor [weak self] in
|
setRunning: { self.isColprofRunning = $0 },
|
||||||
guard let self else { return }
|
resetLog: { self.colprofLog = [] },
|
||||||
|
onLog: { batch in
|
||||||
self.colprofLog.append(contentsOf: batch)
|
self.colprofLog.append(contentsOf: batch)
|
||||||
if let last = batch.last {
|
if let last = batch.last {
|
||||||
let progress = ColprofProgressClassifier.classify(line: last)
|
self.updateProgress(ColprofProgressClassifier.classify(line: last))
|
||||||
self.updateProgress(progress)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
) { onLog in
|
||||||
|
let url = try await runner.runColprof(config: config, onLogBatch: onLog)
|
||||||
|
|
||||||
var finalProfileURL = url
|
var finalProfileURL = url
|
||||||
|
|
||||||
@@ -229,28 +197,26 @@ final class ProfileWorkflowViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Gamut extraction is best-effort for Stage 5 / M6 viewer.
|
// Gamut extraction is best-effort for Stage 5 / M6 viewer.
|
||||||
|
var gamutURL: URL?
|
||||||
do {
|
do {
|
||||||
let gamConfig = IccgamutConfig(profileURL: finalProfileURL)
|
let gamConfig = IccgamutConfig(profileURL: finalProfileURL)
|
||||||
let gamURL = try await runner.runIccgamut(config: gamConfig) { [weak self] batch in
|
let url = try await runner.runIccgamut(config: gamConfig, onLogBatch: onLog)
|
||||||
Task { @MainActor [weak self] in
|
gamutURL = url
|
||||||
self?.colprofLog.append(contentsOf: batch)
|
self.colprofLog.append("Gamut mesh extracted: \(url.lastPathComponent)")
|
||||||
}
|
|
||||||
}
|
|
||||||
self.createdGamutURL = gamURL
|
|
||||||
self.colprofLog.append("Gamut mesh extracted: \(gamURL.lastPathComponent)")
|
|
||||||
} catch {
|
} catch {
|
||||||
self.wizard.showNotice(
|
self.wizard.showNotice(
|
||||||
"Gamut extraction skipped: \(error.localizedDescription)",
|
"Gamut extraction skipped: \(error.localizedDescription)",
|
||||||
kind: .info
|
kind: .info
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
return (profileURL: finalProfileURL, gamutURL: gamutURL)
|
||||||
self.createdProfileURL = finalProfileURL
|
}
|
||||||
|
self.createdProfileURL = outcome.profileURL
|
||||||
|
self.createdGamutURL = outcome.gamutURL
|
||||||
self.wizard.refreshGating()
|
self.wizard.refreshGating()
|
||||||
self.wizard.showNotice("Profile created: \(finalProfileURL.lastPathComponent)")
|
self.wizard.showNotice("Profile created: \(outcome.profileURL.lastPathComponent)")
|
||||||
self.wizard.go(to: .verifyInstall)
|
self.wizard.go(to: .verifyInstall)
|
||||||
} catch {
|
} catch {
|
||||||
self.lastError = error.localizedDescription
|
|
||||||
self.wizard.showNotice(
|
self.wizard.showNotice(
|
||||||
"Profile creation failed: \(error.localizedDescription)",
|
"Profile creation failed: \(error.localizedDescription)",
|
||||||
kind: .error
|
kind: .error
|
||||||
@@ -317,25 +283,28 @@ final class ProfileWorkflowViewModel {
|
|||||||
let ti3URL = ArtefactProbe.artefact(wizard.basename, "ti3", cwd)
|
let ti3URL = ArtefactProbe.artefact(wizard.basename, "ti3", cwd)
|
||||||
let config = ProfcheckConfig(ti3URL: ti3URL, iccURL: profileURL)
|
let config = ProfcheckConfig(ti3URL: ti3URL, iccURL: profileURL)
|
||||||
|
|
||||||
isProfcheckRunning = true
|
|
||||||
profcheckReport = nil
|
profcheckReport = nil
|
||||||
profcheckWarning = nil
|
profcheckWarning = nil
|
||||||
|
|
||||||
let runner = environment.runner
|
let runner = environment.runner
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
defer { self.isProfcheckRunning = false }
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let report = try await runner.runProfcheck(config: config) { [weak self] batch in
|
let outcome = try await ProcessRunSupport.runLogged(
|
||||||
Task { @MainActor [weak self] in
|
setRunning: { self.isProfcheckRunning = $0 },
|
||||||
self?.colprofLog.append(contentsOf: batch)
|
resetLog: {},
|
||||||
}
|
onLog: { self.colprofLog.append(contentsOf: $0) }
|
||||||
}
|
) { onLog in
|
||||||
self.profcheckReport = report
|
let report = try await runner.runProfcheck(config: config, onLogBatch: onLog)
|
||||||
|
var history: [VerificationRecord]?
|
||||||
if let record = self.makeVerificationRecord(from: report) {
|
if let record = self.makeVerificationRecord(from: report) {
|
||||||
let updated = try await self.environment.historyStore.append(record)
|
history = try await self.environment.historyStore.append(record)
|
||||||
self.verificationHistory = updated
|
}
|
||||||
|
return (report: report, history: history)
|
||||||
|
}
|
||||||
|
self.profcheckReport = outcome.report
|
||||||
|
if let history = outcome.history {
|
||||||
|
self.verificationHistory = history
|
||||||
self.driftAlert = DriftAlert.compute(from: self.filteredHistory)
|
self.driftAlert = DriftAlert.compute(from: self.filteredHistory)
|
||||||
}
|
}
|
||||||
} catch let error as ArgyllRunnerError where error == .profcheckUnparseable {
|
} catch let error as ArgyllRunnerError where error == .profcheckUnparseable {
|
||||||
|
|||||||
@@ -83,9 +83,9 @@ private struct WizardStageContent: View {
|
|||||||
case .verifyInstall:
|
case .verifyInstall:
|
||||||
Stage5View(model: workflow.profile)
|
Stage5View(model: workflow.profile)
|
||||||
case .calibrate:
|
case .calibrate:
|
||||||
CalibrationView(model: workflow.calibration)
|
CalibrationView(model: workflow.calibration, wizard: workflow.wizard)
|
||||||
@unknown default:
|
@unknown default:
|
||||||
StagePlaceholderView(stage: model.stage)
|
Stage1View(workflow: workflow)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -238,19 +238,12 @@ struct Stage1View: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var logSection: some View {
|
private var logSection: some View {
|
||||||
DisclosureGroup("Process log") {
|
ProcessLogView(
|
||||||
ScrollView {
|
lines: workflow.targenLog,
|
||||||
Text(workflow.targenLog.joined(separator: "\n"))
|
minHeight: 120,
|
||||||
.font(.system(.caption, design: .monospaced))
|
maxHeight: 200,
|
||||||
.foregroundStyle(Theme.text)
|
containerId: "targenLogContainer",
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
logId: "targenLog"
|
||||||
.textSelection(.enabled)
|
)
|
||||||
}
|
|
||||||
.frame(minHeight: 120, maxHeight: 200)
|
|
||||||
.accessibilityIdentifier("targenLog")
|
|
||||||
}
|
|
||||||
.foregroundStyle(Theme.text)
|
|
||||||
.accessibilityElement(children: .contain)
|
|
||||||
.accessibilityIdentifier("targenLogContainer")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,20 +171,13 @@ struct Stage2View: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var logSection: some View {
|
private var logSection: some View {
|
||||||
DisclosureGroup("Process log") {
|
ProcessLogView(
|
||||||
ScrollView {
|
lines: workflow.printtargLog,
|
||||||
Text(workflow.printtargLog.joined(separator: "\n"))
|
minHeight: 100,
|
||||||
.font(.system(.caption, design: .monospaced))
|
maxHeight: 180,
|
||||||
.foregroundStyle(Theme.text)
|
containerId: "printtargLogContainer",
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
logId: "printtargLog"
|
||||||
.textSelection(.enabled)
|
)
|
||||||
}
|
|
||||||
.frame(minHeight: 100, maxHeight: 180)
|
|
||||||
.accessibilityIdentifier("printtargLog")
|
|
||||||
}
|
|
||||||
.foregroundStyle(Theme.text)
|
|
||||||
.accessibilityElement(children: .contain)
|
|
||||||
.accessibilityIdentifier("printtargLogContainer")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - TIFF gallery (#tiffGallery) — host-side PNG only (#58)
|
// MARK: - TIFF gallery (#tiffGallery) — host-side PNG only (#58)
|
||||||
@@ -219,18 +212,19 @@ struct Stage2View: View {
|
|||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Text("Print").font(.headline).foregroundStyle(Theme.text)
|
Text("Print").font(.headline).foregroundStyle(Theme.text)
|
||||||
if let notice = workflow.printNotice {
|
if let notice = workflow.print.printNotice {
|
||||||
Image(systemName: workflow.printNoticeIsError
|
Image(systemName: notice.kind == .error
|
||||||
? "xmark.circle.fill" : "info.circle.fill")
|
? "xmark.circle.fill" : "info.circle.fill")
|
||||||
.foregroundStyle(workflow.printNoticeIsError
|
.foregroundStyle(notice.kind == .error
|
||||||
? .red : .blue)
|
? .red : .blue)
|
||||||
.accessibilityIdentifier("printNotificationIcon")
|
.accessibilityIdentifier("printNotificationIcon")
|
||||||
Text(notice)
|
.accessibilityValue(notice.kind.accessibilityValue)
|
||||||
|
Text(notice.text)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(workflow.printNoticeIsError
|
.foregroundStyle(notice.kind == .error
|
||||||
? .red : .secondary)
|
? .red : .secondary)
|
||||||
.accessibilityIdentifier("printNotificationText")
|
.accessibilityIdentifier("printNotificationText")
|
||||||
.accessibilityValue(notice)
|
.accessibilityValue(notice.text)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
@@ -238,21 +232,21 @@ struct Stage2View: View {
|
|||||||
|
|
||||||
// Printer row: select + status + refresh + Preferences.
|
// Printer row: select + status + refresh + Preferences.
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
Picker("Printer", selection: $workflow.selectedPrinter) {
|
Picker("Printer", selection: $workflow.print.selectedPrinter) {
|
||||||
ForEach(workflow.printers, id: \.name) { printer in
|
ForEach(workflow.print.printers, id: \.name) { printer in
|
||||||
Text(printer.displayName ?? printer.name)
|
Text(printer.displayName ?? printer.name)
|
||||||
.tag(printer.name)
|
.tag(printer.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: 320)
|
.frame(maxWidth: 320)
|
||||||
.accessibilityIdentifier("printerSelect")
|
.accessibilityIdentifier("printerSelect")
|
||||||
.onChange(of: workflow.selectedPrinter) { _, _ in
|
.onChange(of: workflow.print.selectedPrinter) { _, _ in
|
||||||
workflow.selectedTray = nil
|
workflow.print.selectedTray = nil
|
||||||
workflow.selectedMediaType = nil
|
workflow.print.selectedMediaType = nil
|
||||||
Task { @MainActor in await workflow.reloadSelectedCapabilities() }
|
Task { @MainActor in await workflow.print.reloadSelectedCapabilities() }
|
||||||
}
|
}
|
||||||
if let selected = workflow.printers
|
if let selected = workflow.print.printers
|
||||||
.first(where: { $0.name == workflow.selectedPrinter }) {
|
.first(where: { $0.name == workflow.print.selectedPrinter }) {
|
||||||
Text(selected.status.rawValue)
|
Text(selected.status.rawValue)
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
.padding(.horizontal, 8).padding(.vertical, 3)
|
.padding(.horizontal, 8).padding(.vertical, 3)
|
||||||
@@ -260,33 +254,33 @@ struct Stage2View: View {
|
|||||||
.clipShape(Capsule())
|
.clipShape(Capsule())
|
||||||
.accessibilityIdentifier("printerStatusBadge")
|
.accessibilityIdentifier("printerStatusBadge")
|
||||||
}
|
}
|
||||||
Button(action: workflow.refreshPrinters) {
|
Button(action: workflow.print.refreshPrinters) {
|
||||||
Image(systemName: "arrow.clockwise")
|
Image(systemName: "arrow.clockwise")
|
||||||
}
|
}
|
||||||
.help("Refresh printer list")
|
.help("Refresh printer list")
|
||||||
.accessibilityIdentifier("btnRefreshPrinters")
|
.accessibilityIdentifier("btnRefreshPrinters")
|
||||||
Button(action: workflow.openPrinterPreferences) {
|
Button(action: workflow.print.openPrinterPreferences) {
|
||||||
Image(systemName: "gearshape")
|
Image(systemName: "gearshape")
|
||||||
}
|
}
|
||||||
.help("Printer properties — bound NSPrintPanel")
|
.help("Printer properties — bound NSPrintPanel")
|
||||||
.disabled(workflow.selectedPrinter.isEmpty)
|
.disabled(workflow.print.selectedPrinter.isEmpty)
|
||||||
.accessibilityIdentifier("btnPrinterProperties")
|
.accessibilityIdentifier("btnPrinterProperties")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tray / media / orientation — from queue capabilities.
|
// Tray / media / orientation — from queue capabilities.
|
||||||
HStack(spacing: 14) {
|
HStack(spacing: 14) {
|
||||||
if !workflow.printerCaps.trays.isEmpty {
|
if !workflow.print.printerCaps.trays.isEmpty {
|
||||||
Picker("Tray", selection: $workflow.selectedTray) {
|
Picker("Tray", selection: $workflow.print.selectedTray) {
|
||||||
ForEach(workflow.printerCaps.trays, id: \.id) {
|
ForEach(workflow.print.printerCaps.trays, id: \.id) {
|
||||||
Text($0.name).tag(Optional($0.id))
|
Text($0.name).tag(Optional($0.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: 200)
|
.frame(maxWidth: 200)
|
||||||
.accessibilityIdentifier("printerTraySelect")
|
.accessibilityIdentifier("printerTraySelect")
|
||||||
}
|
}
|
||||||
if !workflow.printerCaps.mediaTypes.isEmpty {
|
if !workflow.print.printerCaps.mediaTypes.isEmpty {
|
||||||
Picker("Media", selection: $workflow.selectedMediaType) {
|
Picker("Media", selection: $workflow.print.selectedMediaType) {
|
||||||
ForEach(workflow.printerCaps.mediaTypes, id: \.id) {
|
ForEach(workflow.print.printerCaps.mediaTypes, id: \.id) {
|
||||||
Text($0.name).tag(Optional($0.id))
|
Text($0.name).tag(Optional($0.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -296,27 +290,31 @@ struct Stage2View: View {
|
|||||||
.accessibilityIdentifier("printerMediaTypeSelect")
|
.accessibilityIdentifier("printerMediaTypeSelect")
|
||||||
}
|
}
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
Button("Portrait") { workflow.printOrientation = "portrait" }
|
Button("Portrait") { workflow.print.printOrientation = "portrait" }
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.tint(workflow.printOrientation == "portrait" ? .accentColor : .gray)
|
.tint(workflow.print.printOrientation == "portrait" ? .accentColor : .gray)
|
||||||
.accessibilityIdentifier("btnOrientPortrait")
|
.accessibilityIdentifier("btnOrientPortrait")
|
||||||
Button("Landscape") { workflow.printOrientation = "landscape" }
|
Button("Landscape") { workflow.print.printOrientation = "landscape" }
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.tint(workflow.printOrientation == "landscape" ? .accentColor : .gray)
|
.tint(workflow.print.printOrientation == "landscape" ? .accentColor : .gray)
|
||||||
.accessibilityIdentifier("btnOrientLandscape")
|
.accessibilityIdentifier("btnOrientLandscape")
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Button(action: workflow.printAllPages) {
|
Button(action: {
|
||||||
Label(workflow.isPrinting ? "Printing…" : "Print All",
|
if let result = workflow.printtargResult {
|
||||||
|
workflow.print.printAllPages(from: result, pageSize: workflow.pageSize)
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Label(workflow.print.isPrinting ? "Printing…" : "Print All",
|
||||||
systemImage: "printer")
|
systemImage: "printer")
|
||||||
}
|
}
|
||||||
.controlSize(.large)
|
.controlSize(.large)
|
||||||
.disabled(workflow.isPrinting
|
.disabled(workflow.print.isPrinting
|
||||||
|| workflow.printtargResult == nil
|
|| workflow.printtargResult == nil
|
||||||
|| workflow.selectedPrinter.isEmpty)
|
|| workflow.print.selectedPrinter.isEmpty)
|
||||||
.accessibilityIdentifier("btnPrintAll")
|
.accessibilityIdentifier("btnPrintAll")
|
||||||
Spacer()
|
Spacer()
|
||||||
Button("Advance to Stage 3") { workflow.advanceToStage3() }
|
Button("Advance to Stage 3") { workflow.advanceToStage3() }
|
||||||
@@ -333,8 +331,8 @@ struct Stage2View: View {
|
|||||||
.task(id: workflow.printtargResult?.pages.count) {
|
.task(id: workflow.printtargResult?.pages.count) {
|
||||||
// Auto-enumerate once a manifest exists and whenever it
|
// Auto-enumerate once a manifest exists and whenever it
|
||||||
// changes (e.g. resume from .ti2).
|
// changes (e.g. resume from .ti2).
|
||||||
if workflow.printers.isEmpty, workflow.printtargResult != nil {
|
if workflow.print.printers.isEmpty, workflow.printtargResult != nil {
|
||||||
workflow.refreshPrinters()
|
workflow.print.refreshPrinters()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -364,9 +362,9 @@ private struct GalleryPageView: View {
|
|||||||
Text("\(page.page.patches) patches · " +
|
Text("\(page.page.patches) patches · " +
|
||||||
"\(Int(page.page.widthMm))×\(Int(page.page.heightMm)) mm")
|
"\(Int(page.page.widthMm))×\(Int(page.page.heightMm)) mm")
|
||||||
.font(.caption2).foregroundStyle(.secondary)
|
.font(.caption2).foregroundStyle(.secondary)
|
||||||
Button("Print") { workflow.printPage(page) }
|
Button("Print") { workflow.print.printPage(page, pageSize: workflow.pageSize) }
|
||||||
.disabled(workflow.isPrinting
|
.disabled(workflow.print.isPrinting
|
||||||
|| workflow.selectedPrinter.isEmpty)
|
|| workflow.print.selectedPrinter.isEmpty)
|
||||||
.accessibilityIdentifier("btnPrintPage-\(page.index)")
|
.accessibilityIdentifier("btnPrintPage-\(page.index)")
|
||||||
}
|
}
|
||||||
.padding(8)
|
.padding(8)
|
||||||
|
|||||||
@@ -164,28 +164,22 @@ struct Stage3View: View {
|
|||||||
.foregroundStyle(Theme.accent)
|
.foregroundStyle(Theme.accent)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let lastError = model.lastError {
|
if let notice = model.chartreadNotice {
|
||||||
Text(lastError)
|
Text(notice.text)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.red)
|
.foregroundStyle(notice.kind.tint)
|
||||||
.accessibilityIdentifier("chartreadLastError")
|
.accessibilityIdentifier("chartreadLastError")
|
||||||
.accessibilityValue(lastError)
|
.accessibilityValue(notice.text)
|
||||||
}
|
}
|
||||||
|
|
||||||
controlButtons
|
controlButtons
|
||||||
|
|
||||||
if !model.chartreadLog.isEmpty {
|
if !model.chartreadLog.isEmpty {
|
||||||
DisclosureGroup("Log") {
|
ProcessLogView(
|
||||||
VStack(alignment: .leading) {
|
lines: model.chartreadLog,
|
||||||
ForEach(model.chartreadLog, id: \.self) { line in
|
containerId: "chartreadLogContainer",
|
||||||
Text(line)
|
logId: "chartreadLog"
|
||||||
.font(.system(.caption, design: .monospaced))
|
)
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.foregroundStyle(Theme.text)
|
|
||||||
.accessibilityIdentifier("chartreadLogContainer")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(16)
|
||||||
@@ -371,9 +365,11 @@ struct Stage3View: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let notice = model.finishNotice {
|
if let notice = model.finishNotice {
|
||||||
Text(notice)
|
Text(notice.text)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(model.finishNoticeIsError ? .red : .green)
|
.foregroundStyle(notice.kind == .error ? .red : .green)
|
||||||
|
.accessibilityIdentifier("chartreadFinishNotice")
|
||||||
|
.accessibilityValue(notice.kind.accessibilityValue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(16)
|
||||||
|
|||||||
@@ -166,27 +166,14 @@ struct Stage4View: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
if let lastError = model.lastError {
|
|
||||||
Text(lastError)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.red)
|
|
||||||
.accessibilityIdentifier("colprofLastError")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !model.colprofLog.isEmpty {
|
if !model.colprofLog.isEmpty {
|
||||||
DisclosureGroup("Log") {
|
ProcessLogView(
|
||||||
VStack(alignment: .leading) {
|
lines: model.colprofLog,
|
||||||
ForEach(model.colprofLog, id: \.self) { line in
|
containerId: "colprofLogContainer",
|
||||||
Text(line)
|
logId: "colprofLog"
|
||||||
.font(.system(.caption, design: .monospaced))
|
)
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.foregroundStyle(Theme.text)
|
|
||||||
.accessibilityIdentifier("colprofLogContainer")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(16)
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
import ICCeryCore
|
|
||||||
|
|
||||||
/// Placeholder stage surface for M1. Real stage UIs arrive in M2–M5
|
|
||||||
/// (issues #7–#31); Stage 0 lands in M6 (issue #29).
|
|
||||||
struct StagePlaceholderView: View {
|
|
||||||
let stage: WizardStage
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(spacing: 16) {
|
|
||||||
Image(systemName: stage.symbolName)
|
|
||||||
.font(.system(size: 44))
|
|
||||||
.foregroundStyle(Theme.accent)
|
|
||||||
Text(stage.title)
|
|
||||||
.font(.title2)
|
|
||||||
.foregroundStyle(Theme.text)
|
|
||||||
Text("This stage is not implemented yet — see the milestone plan.")
|
|
||||||
.font(.callout)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
.background(Theme.background)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -89,30 +89,6 @@ final class TargetWorkflowViewModel {
|
|||||||
/// Stage 3 (`#stage3LoadedTargetBanner` data).
|
/// Stage 3 (`#stage3LoadedTargetBanner` data).
|
||||||
var resumedFromTi2 = false
|
var resumedFromTi2 = false
|
||||||
|
|
||||||
// MARK: - Print panel (issue 17)
|
|
||||||
|
|
||||||
/// CUPS destinations from `lpstat` (#printerSelect).
|
|
||||||
var printers: [Printer] = []
|
|
||||||
/// Selected queue name.
|
|
||||||
var selectedPrinter = ""
|
|
||||||
/// Capabilities of the selected queue (#printerTraySelect /
|
|
||||||
/// #printerMediaTypeSelect / PageSize source).
|
|
||||||
var printerCaps = PrinterCapabilities()
|
|
||||||
var selectedTray: Int?
|
|
||||||
var selectedMediaType: String?
|
|
||||||
/// "portrait" | "landscape" (#btnOrientPortrait/#btnOrientLandscape).
|
|
||||||
var printOrientation = "portrait"
|
|
||||||
/// Per-queue captured `key=value` strings from Preferences — replayed
|
|
||||||
/// on `lp` (session-only, docs/11 §capturedCupsOptions).
|
|
||||||
var capturedCupsOptions: [String: String] = [:]
|
|
||||||
/// In-panel notice (#printNotification) — cancel → info, not error.
|
|
||||||
var printNotice: String?
|
|
||||||
var printNoticeIsError = false
|
|
||||||
var isPrinting = false
|
|
||||||
/// Strong reference to the active print task so the unstructured
|
|
||||||
/// `Task` is not dropped before it resumes.
|
|
||||||
private var printTask: Task<Void, Never>?
|
|
||||||
|
|
||||||
// MARK: - Presets
|
// MARK: - Presets
|
||||||
|
|
||||||
var presets: [ProfilingPreset] = []
|
var presets: [ProfilingPreset] = []
|
||||||
@@ -130,6 +106,8 @@ final class TargetWorkflowViewModel {
|
|||||||
var profile: ProfileWorkflowViewModel
|
var profile: ProfileWorkflowViewModel
|
||||||
/// Stage 0 calibration workflow.
|
/// Stage 0 calibration workflow.
|
||||||
var calibration: CalibrationViewModel!
|
var calibration: CalibrationViewModel!
|
||||||
|
/// Stage 2 unmanaged print session.
|
||||||
|
var print: PrintSessionViewModel!
|
||||||
|
|
||||||
init(environment: AppEnvironment = .live()) {
|
init(environment: AppEnvironment = .live()) {
|
||||||
self.environment = environment
|
self.environment = environment
|
||||||
@@ -142,6 +120,7 @@ final class TargetWorkflowViewModel {
|
|||||||
wizard: wizard,
|
wizard: wizard,
|
||||||
environment: environment
|
environment: environment
|
||||||
)
|
)
|
||||||
|
self.print = PrintSessionViewModel(wizard: wizard, environment: environment)
|
||||||
self.calibration = nil
|
self.calibration = nil
|
||||||
self.calibration = CalibrationViewModel(
|
self.calibration = CalibrationViewModel(
|
||||||
workflow: self,
|
workflow: self,
|
||||||
@@ -227,16 +206,16 @@ final class TargetWorkflowViewModel {
|
|||||||
func generateTarget() {
|
func generateTarget() {
|
||||||
guard canGenerate, !targenRunning else { return }
|
guard canGenerate, !targenRunning else { return }
|
||||||
let config = buildTargenConfig()
|
let config = buildTargenConfig()
|
||||||
targenRunning = true
|
|
||||||
targenLog = []
|
|
||||||
resumedFromTi2 = false
|
resumedFromTi2 = false
|
||||||
let runner = environment.runner
|
let runner = environment.runner
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
do {
|
do {
|
||||||
let url = try await runner.runTargen(config: config) { [weak self] batch in
|
let url = try await ProcessRunSupport.runLogged(
|
||||||
Task { @MainActor [weak self] in
|
setRunning: { self.targenRunning = $0 },
|
||||||
self?.targenLog.append(contentsOf: batch)
|
resetLog: { self.targenLog = [] },
|
||||||
}
|
onLog: { self.targenLog.append(contentsOf: $0) }
|
||||||
|
) { onLog in
|
||||||
|
try await runner.runTargen(config: config, onLogBatch: onLog)
|
||||||
}
|
}
|
||||||
wizard.setTarget(
|
wizard.setTarget(
|
||||||
basename: config.basename,
|
basename: config.basename,
|
||||||
@@ -248,7 +227,6 @@ final class TargetWorkflowViewModel {
|
|||||||
wizard.showNotice(
|
wizard.showNotice(
|
||||||
"targen failed: \(error.localizedDescription)", kind: .error)
|
"targen failed: \(error.localizedDescription)", kind: .error)
|
||||||
}
|
}
|
||||||
targenRunning = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,7 +239,12 @@ final class TargetWorkflowViewModel {
|
|||||||
? UITestHooks.datasetImportURL
|
? UITestHooks.datasetImportURL
|
||||||
: fileDialogs.selectDatasetFile()
|
: fileDialogs.selectDatasetFile()
|
||||||
guard let url else { return }
|
guard let url else { return }
|
||||||
|
importMeasurementDataset(from: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test seam (issue #80): unit tests pass missing or malformed URLs
|
||||||
|
/// directly instead of mutating the global environment.
|
||||||
|
func importMeasurementDataset(from url: URL) {
|
||||||
do {
|
do {
|
||||||
let dataset = try CGATSParser.parse(url: url)
|
let dataset = try CGATSParser.parse(url: url)
|
||||||
guard let directory = targetDirectory ?? wizard.effectiveWorkingDirectory else {
|
guard let directory = targetDirectory ?? wizard.effectiveWorkingDirectory else {
|
||||||
@@ -284,8 +267,6 @@ final class TargetWorkflowViewModel {
|
|||||||
} else {
|
} else {
|
||||||
wizard.showNotice("Imported dataset is not ready for profiling.", kind: .warning)
|
wizard.showNotice("Imported dataset is not ready for profiling.", kind: .warning)
|
||||||
}
|
}
|
||||||
} catch let error as CGATSParseError {
|
|
||||||
wizard.showNotice("Import failed: \(error.localizedDescription)", kind: .error)
|
|
||||||
} catch {
|
} catch {
|
||||||
wizard.showNotice("Import failed: \(error.localizedDescription)", kind: .error)
|
wizard.showNotice("Import failed: \(error.localizedDescription)", kind: .error)
|
||||||
}
|
}
|
||||||
@@ -360,28 +341,25 @@ final class TargetWorkflowViewModel {
|
|||||||
func createLayout() {
|
func createLayout() {
|
||||||
guard wizard.isUnlocked(.layOutPrint), !printtargRunning else { return }
|
guard wizard.isUnlocked(.layOutPrint), !printtargRunning else { return }
|
||||||
let config = buildPrinttargConfig()
|
let config = buildPrinttargConfig()
|
||||||
printtargRunning = true
|
|
||||||
printtargLog = []
|
|
||||||
printtargResult = nil
|
printtargResult = nil
|
||||||
let runner = environment.runner
|
let runner = environment.runner
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
do {
|
do {
|
||||||
let result = try await runner.runPrinttarg(config: config) { [weak self] batch in
|
let result = try await ProcessRunSupport.runLogged(
|
||||||
Task { @MainActor [weak self] in
|
setRunning: { self.printtargRunning = $0 },
|
||||||
self?.printtargLog.append(contentsOf: batch)
|
resetLog: { self.printtargLog = [] },
|
||||||
}
|
onLog: { self.printtargLog.append(contentsOf: $0) }
|
||||||
|
) { onLog in
|
||||||
|
try await runner.runPrinttarg(config: config, onLogBatch: onLog)
|
||||||
}
|
}
|
||||||
printtargResult = result
|
printtargResult = result
|
||||||
wizard.refreshGating()
|
wizard.refreshGating()
|
||||||
wizard.showNotice(
|
wizard.showNotice(
|
||||||
"Layout created — \(result.manifest.pages.count) page(s) ready.")
|
"Layout created — \(result.manifest.pages.count) page(s) ready.")
|
||||||
} catch {
|
} catch {
|
||||||
// Stay on Stage 2: non-zero exit, malformed manifest, or
|
|
||||||
// missing .ti2 must never advance the wizard (#156).
|
|
||||||
wizard.showNotice(
|
wizard.showNotice(
|
||||||
"printtarg failed: \(error.localizedDescription)", kind: .error)
|
"printtarg failed: \(error.localizedDescription)", kind: .error)
|
||||||
}
|
}
|
||||||
printtargRunning = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,160 +369,6 @@ final class TargetWorkflowViewModel {
|
|||||||
wizard.go(to: .measure)
|
wizard.go(to: .measure)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Print panel actions (issue 17)
|
|
||||||
|
|
||||||
/// `#btnRefreshPrinters` — re-enumerate CUPS destinations and load
|
|
||||||
/// capabilities for the selection. Auto-runs when the panel first
|
|
||||||
/// appears with a manifest.
|
|
||||||
func refreshPrinters() {
|
|
||||||
let cups = environment.cupsService
|
|
||||||
Task { @MainActor in
|
|
||||||
do {
|
|
||||||
let list = try await cups.listPrinters()
|
|
||||||
printers = list
|
|
||||||
if !list.contains(where: { $0.name == selectedPrinter }) {
|
|
||||||
selectedPrinter = list.first { $0.isDefault }?.name
|
|
||||||
?? list.first?.name ?? ""
|
|
||||||
}
|
|
||||||
await reloadSelectedCapabilities()
|
|
||||||
} catch {
|
|
||||||
printNotice = "Could not list printers: \(error.localizedDescription)"
|
|
||||||
printNoticeIsError = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Capabilities for `selectedPrinter` — trays / media / sizes feed
|
|
||||||
/// the selects.
|
|
||||||
func reloadSelectedCapabilities() async {
|
|
||||||
guard !selectedPrinter.isEmpty else {
|
|
||||||
printerCaps = PrinterCapabilities()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
printerCaps = try await environment.cupsService
|
|
||||||
.capabilities(for: selectedPrinter)
|
|
||||||
// Default selections only when the captured options didn't
|
|
||||||
// already pin them (Preferences round-trip wins).
|
|
||||||
if selectedMediaType == nil {
|
|
||||||
selectedMediaType = printerCaps.mediaTypes.first?.id
|
|
||||||
}
|
|
||||||
if selectedTray == nil {
|
|
||||||
selectedTray = printerCaps.trays.first?.id
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
printerCaps = PrinterCapabilities()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `#btnPrinterProperties` — bound NSPrintPanel ("Use Settings").
|
|
||||||
/// Cancel → info notice, never an error, cache untouched. On OK the
|
|
||||||
/// captured options are stored per-queue; a panel-side queue switch
|
|
||||||
/// updates `printerSelect` when the returned CUPS id is in the list.
|
|
||||||
func openPrinterPreferences() {
|
|
||||||
guard !selectedPrinter.isEmpty else { return }
|
|
||||||
let queue = selectedPrinter
|
|
||||||
let displayName = printers.first { $0.name == queue }?.displayName
|
|
||||||
let cups = environment.cupsService
|
|
||||||
Task { @MainActor in
|
|
||||||
do {
|
|
||||||
guard let result = try await PrintPanelService()
|
|
||||||
.showProperties(
|
|
||||||
queue: queue, displayName: displayName,
|
|
||||||
cupsService: cups)
|
|
||||||
else {
|
|
||||||
printNotice = "Printer properties dialog cancelled."
|
|
||||||
printNoticeIsError = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if let selected = result.selectedPrinter,
|
|
||||||
printers.contains(where: { $0.name == selected }),
|
|
||||||
selected != queue {
|
|
||||||
selectedPrinter = selected
|
|
||||||
await reloadSelectedCapabilities()
|
|
||||||
}
|
|
||||||
if let captured = result.options.cupsOptions {
|
|
||||||
capturedCupsOptions[selectedPrinter] = captured
|
|
||||||
}
|
|
||||||
if let media = result.options.mediaType {
|
|
||||||
selectedMediaType = media
|
|
||||||
}
|
|
||||||
printNotice = "Settings captured for \(selectedPrinter)."
|
|
||||||
printNoticeIsError = false
|
|
||||||
} catch {
|
|
||||||
printNotice = error.localizedDescription
|
|
||||||
printNoticeIsError = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `#btnPrintAll` — spool every gallery TIFF, sequentially. Stops on
|
|
||||||
/// the first failure so the user sees which page failed.
|
|
||||||
func printAllPages() {
|
|
||||||
guard let result = printtargResult, !isPrinting else { return }
|
|
||||||
isPrinting = true
|
|
||||||
let task = Task { @MainActor [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
defer { self.printTask = nil }
|
|
||||||
var printed = 0
|
|
||||||
for page in result.pages {
|
|
||||||
do {
|
|
||||||
try await spool(page, index: page.index)
|
|
||||||
printed += 1
|
|
||||||
} catch {
|
|
||||||
printNotice = "Print failed on \(page.page.filename): "
|
|
||||||
+ error.localizedDescription
|
|
||||||
printNoticeIsError = true
|
|
||||||
isPrinting = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
printNotice = "Sent \(printed) page(s) to \(selectedPrinter)."
|
|
||||||
printNoticeIsError = false
|
|
||||||
isPrinting = false
|
|
||||||
}
|
|
||||||
printTask = task
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `#btnPrintPage-N` — one TIFF.
|
|
||||||
func printPage(_ page: GalleryPage) {
|
|
||||||
guard !isPrinting else { return }
|
|
||||||
isPrinting = true
|
|
||||||
let task = Task { @MainActor [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
defer { self.printTask = nil }
|
|
||||||
do {
|
|
||||||
try await spool(page, index: page.index)
|
|
||||||
printNotice = "Sent \(page.page.filename) to \(selectedPrinter)."
|
|
||||||
printNoticeIsError = false
|
|
||||||
} catch {
|
|
||||||
printNotice = "Print failed: \(error.localizedDescription)"
|
|
||||||
printNoticeIsError = true
|
|
||||||
}
|
|
||||||
isPrinting = false
|
|
||||||
}
|
|
||||||
printTask = task
|
|
||||||
}
|
|
||||||
|
|
||||||
private func spool(_ page: GalleryPage, index: Int) async throws {
|
|
||||||
guard !selectedPrinter.isEmpty else {
|
|
||||||
throw CupsError.noPrinterSelected
|
|
||||||
}
|
|
||||||
let options = PrintOptions(
|
|
||||||
orientation: printOrientation,
|
|
||||||
paperSize: pageSize == .custom ? nil : pageSize.rawValue,
|
|
||||||
mediaType: selectedMediaType,
|
|
||||||
ppdUncorrectedPassthrough: true,
|
|
||||||
cupsOptions: capturedCupsOptions[selectedPrinter])
|
|
||||||
try await environment.cupsService.printTarget(
|
|
||||||
queue: selectedPrinter,
|
|
||||||
tiffPath: page.fileURL.path,
|
|
||||||
options: options,
|
|
||||||
page: index)
|
|
||||||
// For Stage 5 history (#95): record which queue printed.
|
|
||||||
wizard.printerName = selectedPrinter
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Presets
|
// MARK: - Presets
|
||||||
|
|
||||||
func reloadPresets() {
|
func reloadPresets() {
|
||||||
@@ -554,55 +378,61 @@ final class TargetWorkflowViewModel {
|
|||||||
/// Applies every Stage 1/2 field of the preset to the live form
|
/// Applies every Stage 1/2 field of the preset to the live form
|
||||||
/// (bidirectional — the draft preset's dpi=150 must be visible).
|
/// (bidirectional — the draft preset's dpi=150 must be visible).
|
||||||
func applyPreset(_ preset: ProfilingPreset) {
|
func applyPreset(_ preset: ProfilingPreset) {
|
||||||
colourSpace = preset.colourSpace == "cmyk" ? .cmyk : .rgb
|
let targen = TargenConfig(preset: preset, basename: targetBasename, workingDirectory: targetDirectory)
|
||||||
patchPreset = PatchCountPreset(rawValue: "\(preset.patchCount)") ?? .custom
|
applyTargenForm(targen)
|
||||||
customPatchCount = preset.patchCount
|
// Stage 4 state (incl. calibration) is applied before Stage 2 so
|
||||||
whitePatches = preset.whitePatches
|
// the layout config receives the preset's calibration path, not
|
||||||
blackPatches = preset.blackPatches
|
// stale live state (#82).
|
||||||
greySteps = preset.greySteps ?? 5; greyStepsEnabled = preset.greySteps != nil
|
|
||||||
singleChannelSteps = preset.singleChannelSteps ?? 5
|
|
||||||
singleChannelEnabled = preset.singleChannelSteps != nil
|
|
||||||
neutralSteps = preset.neutralSteps ?? 3
|
|
||||||
neutralStepsEnabled = preset.neutralSteps != nil
|
|
||||||
neutralConcentration = preset.neutralConcentration ?? 0.50
|
|
||||||
neutralConcEnabled = preset.neutralConcentration != nil
|
|
||||||
preconditioningProfile = preset.preconditioningProfile
|
|
||||||
highQuality = preset.ofpsHighQuality == true
|
|
||||||
adaptation = preset.ofpsAdaptation ?? 0.10
|
|
||||||
adaptationEnabled = preset.ofpsAdaptation != nil
|
|
||||||
algorithm = preset.fullSpreadAlgorithm
|
|
||||||
.flatMap { FullSpreadAlgorithm(presetValue: $0) } ?? .ofps
|
|
||||||
totalInkLimit = preset.totalInkLimit ?? 320
|
|
||||||
inkLimitEnabled = preset.totalInkLimit != nil
|
|
||||||
darkEmphasis = preset.darkEmphasis ?? 1.0
|
|
||||||
darkEmphasisEnabled = preset.darkEmphasis != nil
|
|
||||||
devicePower = preset.devicePower ?? 1.0
|
|
||||||
devicePowerEnabled = preset.devicePower != nil
|
|
||||||
|
|
||||||
instrument = PrintInstrument(rawValue: preset.instrument) ?? .i1
|
|
||||||
if let size = PageSize(rawValue: preset.pageSize) {
|
|
||||||
pageSize = size
|
|
||||||
} else if let (w, h) = Self.parseCustomPage(preset.pageSize) {
|
|
||||||
pageSize = .custom; customPageW = w; customPageH = h
|
|
||||||
} else {
|
|
||||||
pageSize = .a4
|
|
||||||
}
|
|
||||||
bitDepth = preset.bitDepth == 16 ? .sixteen : .eight
|
|
||||||
tiffDpi = preset.dpi
|
|
||||||
if preset.noRandomize == true {
|
|
||||||
layoutOrder = .raster
|
|
||||||
} else if (preset.randomSeed ?? 1) == 1 {
|
|
||||||
layoutOrder = .deterministic
|
|
||||||
} else {
|
|
||||||
layoutOrder = .customSeed
|
|
||||||
}
|
|
||||||
customSeed = preset.randomSeed ?? 1
|
|
||||||
|
|
||||||
profile.applyPreset(preset)
|
profile.applyPreset(preset)
|
||||||
|
let printtarg = PrinttargConfig(
|
||||||
|
preset: preset,
|
||||||
|
basename: wizard.basename,
|
||||||
|
workingDirectory: wizard.effectiveWorkingDirectory,
|
||||||
|
calibrationFile: profile.applyCalibration && !profile.calibrationFile.isEmpty
|
||||||
|
? profile.calibrationFile : nil
|
||||||
|
)
|
||||||
|
applyPrinttargForm(printtarg)
|
||||||
selectedPresetID = preset.id
|
selectedPresetID = preset.id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func applyTargenForm(_ config: TargenConfig) {
|
||||||
|
colourSpace = config.colourSpace
|
||||||
|
patchPreset = PatchCountPreset(rawValue: "\(config.patchCount)") ?? .custom
|
||||||
|
customPatchCount = config.patchCount
|
||||||
|
whitePatches = config.whitePatches
|
||||||
|
blackPatches = config.blackPatches
|
||||||
|
greySteps = config.greySteps ?? 5
|
||||||
|
greyStepsEnabled = config.greySteps != nil
|
||||||
|
singleChannelSteps = config.singleChannelSteps ?? 5
|
||||||
|
singleChannelEnabled = config.singleChannelSteps != nil
|
||||||
|
neutralSteps = config.neutralSteps ?? 3
|
||||||
|
neutralStepsEnabled = config.neutralSteps != nil
|
||||||
|
neutralConcentration = config.neutralConcentration ?? 0.50
|
||||||
|
neutralConcEnabled = config.neutralConcentration != nil
|
||||||
|
preconditioningProfile = config.preconditioningProfile
|
||||||
|
highQuality = config.ofpsHighQuality == true
|
||||||
|
adaptation = config.ofpsAdaptation ?? 0.10
|
||||||
|
adaptationEnabled = config.ofpsAdaptation != nil
|
||||||
|
algorithm = config.fullSpreadAlgorithm ?? .ofps
|
||||||
|
totalInkLimit = config.totalInkLimit ?? 320
|
||||||
|
inkLimitEnabled = config.totalInkLimit != nil
|
||||||
|
darkEmphasis = config.darkEmphasis ?? 1.0
|
||||||
|
darkEmphasisEnabled = config.darkEmphasis != nil
|
||||||
|
devicePower = config.devicePower ?? 1.0
|
||||||
|
devicePowerEnabled = config.devicePower != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyPrinttargForm(_ config: PrinttargConfig) {
|
||||||
|
instrument = config.instrument
|
||||||
|
pageSize = config.pageSize
|
||||||
|
customPageW = config.customPageWidth
|
||||||
|
customPageH = config.customPageHeight
|
||||||
|
bitDepth = config.bitDepth
|
||||||
|
tiffDpi = config.dpi
|
||||||
|
layoutOrder = config.layoutOrder
|
||||||
|
customSeed = config.customSeed
|
||||||
|
}
|
||||||
|
|
||||||
/// Snapshot of the live Stage 1/2 form as a custom preset.
|
/// Snapshot of the live Stage 1/2 form as a custom preset.
|
||||||
func saveCurrentAsPreset() {
|
func saveCurrentAsPreset() {
|
||||||
let name = savePresetName.trimmingCharacters(in: .whitespacesAndNewlines)
|
let name = savePresetName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
@@ -614,39 +444,11 @@ final class TargetWorkflowViewModel {
|
|||||||
id: "custom-\(UUID().uuidString.lowercased())",
|
id: "custom-\(UUID().uuidString.lowercased())",
|
||||||
name: name,
|
name: name,
|
||||||
description: savePresetDesc.trimmingCharacters(in: .whitespacesAndNewlines),
|
description: savePresetDesc.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
colourSpace: colourSpace == .cmyk ? "cmyk" : "rgb",
|
targen: buildTargenConfig(),
|
||||||
patchCount: effectivePatchCount,
|
printtarg: buildPrinttargConfig(),
|
||||||
whitePatches: whitePatches,
|
colprof: profile.buildColprofConfig(),
|
||||||
blackPatches: blackPatches,
|
|
||||||
greySteps: greyStepsEnabled ? greySteps : nil,
|
|
||||||
singleChannelSteps: singleChannelEnabled ? singleChannelSteps : nil,
|
|
||||||
neutralSteps: neutralStepsEnabled ? neutralSteps : nil,
|
|
||||||
neutralConcentration: neutralConcEnabled ? neutralConcentration : nil,
|
|
||||||
preconditioningProfile: preconditioningProfile,
|
|
||||||
ofpsHighQuality: highQuality ? true : nil,
|
|
||||||
ofpsAdaptation: adaptationEnabled ? adaptation : nil,
|
|
||||||
fullSpreadAlgorithm: algorithm.presetValue,
|
|
||||||
totalInkLimit: inkLimitEnabled ? totalInkLimit : nil,
|
|
||||||
darkEmphasis: darkEmphasisEnabled ? darkEmphasis : nil,
|
|
||||||
devicePower: devicePowerEnabled ? devicePower : nil,
|
|
||||||
instrument: instrument.rawValue,
|
|
||||||
pageSize: pageSize == .custom
|
|
||||||
? "\(Int(customPageW))x\(Int(customPageH))"
|
|
||||||
: pageSize.rawValue,
|
|
||||||
bitDepth: bitDepth.rawValue,
|
|
||||||
dpi: tiffDpi,
|
|
||||||
randomSeed: layoutOrder == .deterministic ? 1 : customSeed,
|
|
||||||
noRandomize: layoutOrder == .raster,
|
|
||||||
calibrationFile: profile.calibrationFile.isEmpty ? nil : profile.calibrationFile,
|
calibrationFile: profile.calibrationFile.isEmpty ? nil : profile.calibrationFile,
|
||||||
applyCalibration: profile.applyCalibration ? true : nil,
|
applyCalibration: profile.applyCalibration ? true : nil
|
||||||
colprofAlgorithm: profile.algorithm,
|
|
||||||
colprofQuality: profile.quality,
|
|
||||||
colprofIntent: profile.intent.isEmpty ? nil : profile.intent,
|
|
||||||
colprofFwa: profile.fwaValue,
|
|
||||||
colprofIlluminant: profile.illuminant.isEmpty ? nil : profile.illuminant,
|
|
||||||
colprofObserver: profile.observer.isEmpty ? nil : profile.observer,
|
|
||||||
colprofInputViewingCond: profile.inputViewingCond.isEmpty ? nil : profile.inputViewingCond,
|
|
||||||
colprofOutputViewingCond: profile.outputViewingCond.isEmpty ? nil : profile.outputViewingCond
|
|
||||||
)
|
)
|
||||||
do {
|
do {
|
||||||
try environment.presetStore.saveCustom(preset)
|
try environment.presetStore.saveCustom(preset)
|
||||||
@@ -710,10 +512,6 @@ final class TargetWorkflowViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func parseCustomPage(_ raw: String) -> (Double, Double)? {
|
static func parseCustomPage(_ raw: String) -> (Double, Double)? {
|
||||||
let parts = raw.lowercased().split(separator: "x")
|
PageSize.parseCustom(raw)
|
||||||
guard parts.count == 2,
|
|
||||||
let w = Double(parts[0]), let h = Double(parts[1]),
|
|
||||||
w >= 50, h >= 50 else { return nil }
|
|
||||||
return (w, h)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,8 +67,12 @@ final class WizardViewModel {
|
|||||||
self.calibrationOriginalBasename = s.calibrationOriginalBasename
|
self.calibrationOriginalBasename = s.calibrationOriginalBasename
|
||||||
// A Force Quit mid-calibration leaves a CAL_ basename behind; restore
|
// A Force Quit mid-calibration leaves a CAL_ basename behind; restore
|
||||||
// the original before the UI can do anything with it (#29).
|
// the original before the UI can do anything with it (#29).
|
||||||
if basename.hasPrefix("CAL_"), !calibrationOriginalBasename.isEmpty {
|
if CalibrationIdentity.isCalibration(basename), !calibrationOriginalBasename.isEmpty {
|
||||||
basename = calibrationOriginalBasename
|
let identity = CalibrationIdentity.parse(
|
||||||
|
liveBasename: basename,
|
||||||
|
persistedOriginal: calibrationOriginalBasename
|
||||||
|
)
|
||||||
|
basename = identity.originalBasename
|
||||||
calibrationOriginalBasename = ""
|
calibrationOriginalBasename = ""
|
||||||
sessionMode = .profile
|
sessionMode = .profile
|
||||||
stage = .generate
|
stage = .generate
|
||||||
@@ -128,7 +132,7 @@ final class WizardViewModel {
|
|||||||
/// refused and the original basename is restored (#29).
|
/// refused and the original basename is restored (#29).
|
||||||
func go(to target: WizardStage) {
|
func go(to target: WizardStage) {
|
||||||
guard target != .calibrate else { enterCalibration(); return }
|
guard target != .calibrate else { enterCalibration(); return }
|
||||||
if basename.hasPrefix("CAL_") {
|
if CalibrationIdentity.isCalibration(basename) {
|
||||||
guard !calibrationOriginalBasename.isEmpty else {
|
guard !calibrationOriginalBasename.isEmpty else {
|
||||||
showNotice(
|
showNotice(
|
||||||
"Cannot leave calibration — the original target name is missing.",
|
"Cannot leave calibration — the original target name is missing.",
|
||||||
@@ -152,7 +156,7 @@ final class WizardViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func enterCalibration() {
|
func enterCalibration() {
|
||||||
if !basename.isEmpty, !basename.hasPrefix("CAL_"), calibrationOriginalBasename.isEmpty {
|
if !basename.isEmpty, !CalibrationIdentity.isCalibration(basename), calibrationOriginalBasename.isEmpty {
|
||||||
calibrationOriginalBasename = basename
|
calibrationOriginalBasename = basename
|
||||||
}
|
}
|
||||||
sessionMode = .calibration
|
sessionMode = .calibration
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import ICCeryCore
|
||||||
|
|
||||||
|
@Suite("ArgsBuilder")
|
||||||
|
struct ArgsBuilderTests {
|
||||||
|
|
||||||
|
// MARK: - option
|
||||||
|
|
||||||
|
@Test("option: nil emits nothing")
|
||||||
|
func optionNil() {
|
||||||
|
#expect(ArgsBuilder.option("-f", nil) == [])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("option: present value emits flag and value verbatim")
|
||||||
|
func optionPresent() {
|
||||||
|
#expect(ArgsBuilder.option("-f", "abc") == ["-f", "abc"])
|
||||||
|
#expect(ArgsBuilder.option("-f", "") == ["-f", ""])
|
||||||
|
#expect(ArgsBuilder.option("-f", " padded ") == ["-f", " padded "])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - optionIfNonEmpty
|
||||||
|
|
||||||
|
@Test("optionIfNonEmpty: nil and empty emit nothing")
|
||||||
|
func optionIfNonEmptyNilEmpty() {
|
||||||
|
#expect(ArgsBuilder.optionIfNonEmpty("-d", nil) == [])
|
||||||
|
#expect(ArgsBuilder.optionIfNonEmpty("-d", "") == [])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("optionIfNonEmpty: whitespace-only emits nothing")
|
||||||
|
func optionIfNonEmptyWhitespace() {
|
||||||
|
#expect(ArgsBuilder.optionIfNonEmpty("-d", " ") == [])
|
||||||
|
#expect(ArgsBuilder.optionIfNonEmpty("-d", " \t\n ") == [])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("optionIfNonEmpty: trims surrounding whitespace")
|
||||||
|
func optionIfNonEmptyTrims() {
|
||||||
|
#expect(ArgsBuilder.optionIfNonEmpty("-d", " label ") == ["-d", "label"])
|
||||||
|
#expect(ArgsBuilder.optionIfNonEmpty("-d", "\tcal.cal\n") == ["-d", "cal.cal"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - optionUnlessApprox
|
||||||
|
|
||||||
|
@Test("optionUnlessApprox: nil emits nothing")
|
||||||
|
func optionUnlessApproxNil() {
|
||||||
|
#expect(ArgsBuilder.optionUnlessApprox("-N", nil, skip: 0.50) == [])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("optionUnlessApprox: exact skip value emits nothing")
|
||||||
|
func optionUnlessApproxExactSkip() {
|
||||||
|
#expect(ArgsBuilder.optionUnlessApprox("-N", 0.50, skip: 0.50) == [])
|
||||||
|
#expect(ArgsBuilder.optionUnlessApprox("-V", 1.0, skip: 1.0) == [])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("optionUnlessApprox: within epsilon emits nothing")
|
||||||
|
func optionUnlessApproxWithinEpsilon() {
|
||||||
|
#expect(ArgsBuilder.optionUnlessApprox("-N", 0.5005, skip: 0.50) == [])
|
||||||
|
#expect(ArgsBuilder.optionUnlessApprox("-V", 0.9995, skip: 1.0) == [])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("optionUnlessApprox: outside epsilon emits flag")
|
||||||
|
func optionUnlessApproxOutsideEpsilon() {
|
||||||
|
#expect(ArgsBuilder.optionUnlessApprox("-N", 0.75, skip: 0.50) == ["-N", "0.75"])
|
||||||
|
#expect(ArgsBuilder.optionUnlessApprox("-V", 1.50, skip: 1.0) == ["-V", "1.50"])
|
||||||
|
#expect(ArgsBuilder.optionUnlessApprox("-N", 0.498, skip: 0.50) == ["-N", "0.50"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("optionUnlessApprox: POSIX formatting is locale-stable")
|
||||||
|
func optionUnlessApproxPOSIX() {
|
||||||
|
// 1234.5 must never produce a grouping separator or comma decimal.
|
||||||
|
#expect(ArgsBuilder.optionUnlessApprox("-p", 1234.5, skip: 1.0) == ["-p", "1234.50"])
|
||||||
|
#expect(ArgsBuilder.optionUnlessApprox("-p", 2.0, skip: 1.0) == ["-p", "2.00"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("optionUnlessApprox: custom epsilon and format honoured")
|
||||||
|
func optionUnlessApproxCustom() {
|
||||||
|
#expect(ArgsBuilder.optionUnlessApprox("-x", 1.005, skip: 1.0, epsilon: 0.01) == [])
|
||||||
|
#expect(ArgsBuilder.optionUnlessApprox("-x", 1.5, skip: 1.0, format: "%.1f") == ["-x", "1.5"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - flag
|
||||||
|
|
||||||
|
@Test("flag: true emits the bare flag")
|
||||||
|
func flagTrue() {
|
||||||
|
#expect(ArgsBuilder.flag("-G", when: true) == ["-G"])
|
||||||
|
#expect(ArgsBuilder.flag("-r", when: true) == ["-r"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("flag: false emits nothing")
|
||||||
|
func flagFalse() {
|
||||||
|
#expect(ArgsBuilder.flag("-G", when: false) == [])
|
||||||
|
#expect(ArgsBuilder.flag("-r", when: false) == [])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,13 +5,13 @@ import Testing
|
|||||||
@Suite("ArgyllRunner Calibration")
|
@Suite("ArgyllRunner Calibration")
|
||||||
struct ArgyllRunnerCalibrationTests {
|
struct ArgyllRunnerCalibrationTests {
|
||||||
|
|
||||||
private func makeRunner() -> ArgyllRunner {
|
private func makeRunner(processManager: ProcessManager = ProcessManager()) -> ArgyllRunner {
|
||||||
let binDir = URL(fileURLWithPath: #filePath)
|
let binDir = URL(fileURLWithPath: #filePath)
|
||||||
.deletingLastPathComponent()
|
.deletingLastPathComponent()
|
||||||
.deletingLastPathComponent()
|
.deletingLastPathComponent()
|
||||||
.appendingPathComponent("ICCeryUITests/Fixtures/bin")
|
.appendingPathComponent("ICCeryUITests/Fixtures/bin")
|
||||||
return ArgyllRunner(
|
return ArgyllRunner(
|
||||||
processManager: .shared,
|
processManager: processManager,
|
||||||
binaryResolver: BinaryResolver(overrideDir: binDir)
|
binaryResolver: BinaryResolver(overrideDir: binDir)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -41,6 +41,35 @@ struct ArgyllRunnerCalibrationTests {
|
|||||||
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 calibrationTargenProcessId() async throws {
|
||||||
|
let testRoot = try makeTestDir()
|
||||||
|
let pm = ProcessManager()
|
||||||
|
let runner = makeRunner(processManager: pm)
|
||||||
|
let events = pm.events()
|
||||||
|
// Subscribed before spawn; the exit event is emitted before
|
||||||
|
// runCalibrationTargen returns, so this always terminates.
|
||||||
|
let sawExit = Task {
|
||||||
|
for await event in events {
|
||||||
|
guard event.id == "targen_CAL_foo" else { continue }
|
||||||
|
if case .exit = event { return true }
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
let config = CalibrationTargenConfig(
|
||||||
|
colourSpace: .rgb,
|
||||||
|
steps: 21,
|
||||||
|
basename: "foo",
|
||||||
|
workingDirectory: testRoot
|
||||||
|
)
|
||||||
|
|
||||||
|
let url = try await runner.runCalibrationTargen(config: config)
|
||||||
|
|
||||||
|
#expect(url.lastPathComponent == "CAL_foo.ti1")
|
||||||
|
#expect(await sawExit.value)
|
||||||
|
try? FileManager.default.removeItem(at: testRoot)
|
||||||
|
}
|
||||||
|
|
||||||
@Test("printcal captured run creates .cal")
|
@Test("printcal captured run creates .cal")
|
||||||
func printcalProducesCal() async throws {
|
func printcalProducesCal() async throws {
|
||||||
let testRoot = try makeTestDir()
|
let testRoot = try makeTestDir()
|
||||||
@@ -59,10 +88,28 @@ struct ArgyllRunnerCalibrationTests {
|
|||||||
try? FileManager.default.removeItem(at: testRoot)
|
try? FileManager.default.removeItem(at: testRoot)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("printcal failure throws printcalFailed")
|
@Test("printcal failure throws toolFailed")
|
||||||
func printcalFailureThrows() async throws {
|
func printcalFailureThrows() async throws {
|
||||||
let testRoot = try makeTestDir()
|
let testRoot = try makeTestDir()
|
||||||
let runner = makeRunner()
|
defer { try? FileManager.default.removeItem(at: testRoot) }
|
||||||
|
|
||||||
|
// Per-test mock printcal that always fails — no global
|
||||||
|
// environment mutation, no shared fixture changes.
|
||||||
|
let binDir = try makeTestDir()
|
||||||
|
defer { try? FileManager.default.removeItem(at: binDir) }
|
||||||
|
let mockURL = binDir.appendingPathComponent("printcal")
|
||||||
|
try """
|
||||||
|
#!/bin/sh
|
||||||
|
echo "printcal mock failure" >&2
|
||||||
|
exit 1
|
||||||
|
""".write(to: mockURL, atomically: true, encoding: .utf8)
|
||||||
|
try FileManager.default.setAttributes(
|
||||||
|
[.posixPermissions: 0o755], ofItemAtPath: mockURL.path)
|
||||||
|
|
||||||
|
let runner = ArgyllRunner(
|
||||||
|
processManager: ProcessManager(),
|
||||||
|
binaryResolver: BinaryResolver(bundledRoot: binDir, overrideDir: binDir)
|
||||||
|
)
|
||||||
let output = testRoot.appendingPathComponent("CAL_demo.cal")
|
let output = testRoot.appendingPathComponent("CAL_demo.cal")
|
||||||
let config = PrintcalConfig(
|
let config = PrintcalConfig(
|
||||||
ti3Basename: "CAL_demo",
|
ti3Basename: "CAL_demo",
|
||||||
@@ -70,12 +117,9 @@ struct ArgyllRunnerCalibrationTests {
|
|||||||
outputURL: output
|
outputURL: output
|
||||||
)
|
)
|
||||||
|
|
||||||
setenv("ICCERY_MOCK_PRINTCAL_EXIT", "1", 1)
|
await #expect(throws: ArgyllRunnerError.toolFailed(
|
||||||
defer { unsetenv("ICCERY_MOCK_PRINTCAL_EXIT") }
|
tool: "printcal", code: 1, logs: ["printcal mock failure\n"])) {
|
||||||
|
|
||||||
await #expect(throws: (any Error).self) {
|
|
||||||
_ = try await runner.runPrintcal(config: config)
|
_ = try await runner.runPrintcal(config: config)
|
||||||
}
|
}
|
||||||
try? FileManager.default.removeItem(at: testRoot)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ struct ArgyllRunnerColprofTests {
|
|||||||
try FileManager.default.createDirectory(at: testRoot, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: testRoot, withIntermediateDirectories: true)
|
||||||
|
|
||||||
let runner = ArgyllRunner(
|
let runner = ArgyllRunner(
|
||||||
processManager: .shared,
|
processManager: ProcessManager(),
|
||||||
binaryResolver: BinaryResolver(overrideDir: binDir)
|
binaryResolver: BinaryResolver(overrideDir: binDir)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -49,4 +49,32 @@ struct ArgyllRunnerColprofTests {
|
|||||||
|
|
||||||
try? FileManager.default.removeItem(at: testRoot)
|
try? FileManager.default.removeItem(at: testRoot)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("Failing colprof throws toolFailed with code and logs")
|
||||||
|
func colprofFailureThrowsToolFailed() async throws {
|
||||||
|
let dir = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("colprof-fail-\(UUID().uuidString)")
|
||||||
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
defer { try? FileManager.default.removeItem(at: dir) }
|
||||||
|
|
||||||
|
let mockURL = dir.appendingPathComponent("colprof")
|
||||||
|
try """
|
||||||
|
#!/bin/sh
|
||||||
|
echo "colprof broke" >&2
|
||||||
|
exit 4
|
||||||
|
""".write(to: mockURL, atomically: true, encoding: .utf8)
|
||||||
|
try FileManager.default.setAttributes(
|
||||||
|
[.posixPermissions: 0o755], ofItemAtPath: mockURL.path)
|
||||||
|
|
||||||
|
let runner = ArgyllRunner(
|
||||||
|
processManager: ProcessManager(),
|
||||||
|
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir)
|
||||||
|
)
|
||||||
|
let config = ColprofConfig(basename: "failrun", workingDirectory: dir)
|
||||||
|
|
||||||
|
await #expect(throws: ArgyllRunnerError.toolFailed(
|
||||||
|
tool: "colprof", code: 4, logs: ["colprof broke"])) {
|
||||||
|
try await runner.runColprof(config: config)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import ICCeryCore
|
||||||
|
|
||||||
|
/// Focused contracts for the shared `runStreamingTool` loop (#79).
|
||||||
|
///
|
||||||
|
/// Every test uses a per-test temporary directory, unique basenames,
|
||||||
|
/// and a fresh `ProcessManager` — no shared UI fixture scripts and no
|
||||||
|
/// process-environment mutation.
|
||||||
|
@Suite("ArgyllRunner streaming loop contracts")
|
||||||
|
struct ArgyllRunnerStreamingLoopTests {
|
||||||
|
|
||||||
|
private func makeTempDir() throws -> URL {
|
||||||
|
let dir = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("runner-loop-\(UUID().uuidString)")
|
||||||
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
private func writeMock(_ name: String, _ body: String, in dir: URL) throws {
|
||||||
|
let url = dir.appendingPathComponent(name)
|
||||||
|
try body.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
try FileManager.default.setAttributes(
|
||||||
|
[.posixPermissions: 0o755], ofItemAtPath: url.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeRunner(binDir: URL) -> ArgyllRunner {
|
||||||
|
ArgyllRunner(
|
||||||
|
processManager: ProcessManager(),
|
||||||
|
binaryResolver: BinaryResolver(bundledRoot: binDir, overrideDir: binDir))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Non-zero exit throws toolFailed retaining code and collected stdout/stderr lines")
|
||||||
|
func nonZeroExitThrowsToolFailed() async throws {
|
||||||
|
let dir = try makeTempDir()
|
||||||
|
defer { try? FileManager.default.removeItem(at: dir) }
|
||||||
|
try writeMock("targen", """
|
||||||
|
#!/bin/sh
|
||||||
|
echo "Generating patches..."
|
||||||
|
echo "targen: too few patches" >&2
|
||||||
|
exit 3
|
||||||
|
""", in: dir)
|
||||||
|
let runner = makeRunner(binDir: dir)
|
||||||
|
let config = TargenConfig(
|
||||||
|
colourSpace: .rgb, patchCount: 800, whitePatches: 4,
|
||||||
|
blackPatches: 4, basename: "fail", workingDirectory: dir)
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try await runner.runTargen(config: config)
|
||||||
|
Issue.record("Expected toolFailed")
|
||||||
|
} catch let error as ArgyllRunnerError {
|
||||||
|
guard case .toolFailed(let tool, let code, let logs) = error else {
|
||||||
|
Issue.record("Expected toolFailed, got \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#expect(tool == "targen")
|
||||||
|
#expect(code == 3)
|
||||||
|
#expect(logs.contains("Generating patches..."))
|
||||||
|
#expect(logs.contains("targen: too few patches"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Exit 0 without expected artefact throws missingArtefact with the artefact path")
|
||||||
|
func zeroExitMissingArtefact() async throws {
|
||||||
|
let dir = try makeTempDir()
|
||||||
|
defer { try? FileManager.default.removeItem(at: dir) }
|
||||||
|
try writeMock("targen", """
|
||||||
|
#!/bin/sh
|
||||||
|
echo "done but wrote nothing"
|
||||||
|
exit 0
|
||||||
|
""", in: dir)
|
||||||
|
let runner = makeRunner(binDir: dir)
|
||||||
|
let expectedPath = dir.appendingPathComponent("gone.ti1").path
|
||||||
|
let config = TargenConfig(
|
||||||
|
colourSpace: .rgb, patchCount: 800, whitePatches: 4,
|
||||||
|
blackPatches: 4, basename: "gone", workingDirectory: dir)
|
||||||
|
|
||||||
|
await #expect(throws: ArgyllRunnerError.missingArtefact(expectedPath)) {
|
||||||
|
try await runner.runTargen(config: config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Immediate exit after one stdout line still delivers the line and succeeds")
|
||||||
|
func immediateExitDeliversLine() async throws {
|
||||||
|
let dir = try makeTempDir()
|
||||||
|
defer { try? FileManager.default.removeItem(at: dir) }
|
||||||
|
try writeMock("targen", """
|
||||||
|
#!/bin/sh
|
||||||
|
last=""
|
||||||
|
for arg in "$@"; do last="$arg"; done
|
||||||
|
echo "only line"
|
||||||
|
touch "$last.ti1"
|
||||||
|
exit 0
|
||||||
|
""", in: dir)
|
||||||
|
let runner = makeRunner(binDir: dir)
|
||||||
|
let config = TargenConfig(
|
||||||
|
colourSpace: .rgb, patchCount: 800, whitePatches: 4,
|
||||||
|
blackPatches: 4, basename: "quick", workingDirectory: dir)
|
||||||
|
|
||||||
|
let holder = LogHolder()
|
||||||
|
let url = try await runner.runTargen(config: config) { batch in
|
||||||
|
holder.append(batch)
|
||||||
|
}
|
||||||
|
#expect(url.lastPathComponent == "quick.ti1")
|
||||||
|
#expect(FileManager.default.fileExists(atPath: url.path))
|
||||||
|
#expect(holder.lines.contains("only line"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("colprof unterminated progress fragment reaches onLogBatch before exit")
|
||||||
|
func colprofPartialLineFlush() async throws {
|
||||||
|
let dir = try makeTempDir()
|
||||||
|
defer { try? FileManager.default.removeItem(at: dir) }
|
||||||
|
// The fragment is printed without a newline, then the mock sleeps
|
||||||
|
// past the 500 ms partial-line flush interval before writing the
|
||||||
|
// artefact and exiting — so the tail is delivered mid-run.
|
||||||
|
try writeMock("colprof", """
|
||||||
|
#!/bin/sh
|
||||||
|
last=""
|
||||||
|
for arg in "$@"; do last="$arg"; done
|
||||||
|
printf 'Doing gamut mapping'
|
||||||
|
sleep 2
|
||||||
|
touch "$last.icc"
|
||||||
|
exit 0
|
||||||
|
""", in: dir)
|
||||||
|
let runner = makeRunner(binDir: dir)
|
||||||
|
let config = ColprofConfig(basename: "frag", workingDirectory: dir)
|
||||||
|
|
||||||
|
let holder = LogHolder()
|
||||||
|
let url = try await runner.runColprof(config: config) { batch in
|
||||||
|
holder.append(batch)
|
||||||
|
}
|
||||||
|
#expect(url.lastPathComponent == "frag.icc")
|
||||||
|
#expect(FileManager.default.fileExists(atPath: url.path))
|
||||||
|
#expect(holder.lines.contains("Doing gamut mapping"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("toolFailed maps each tool to its user-facing description",
|
||||||
|
arguments: [
|
||||||
|
(tool: "chartread", expected: "Chartread failed: boom"),
|
||||||
|
(tool: "average", expected: "Averaging failed: boom"),
|
||||||
|
(tool: "colprof", expected: "Profile creation failed: boom"),
|
||||||
|
(tool: "printcal", expected: "Calibration curve computation failed: boom"),
|
||||||
|
(tool: "applycal", expected: "Apply calibration failed: boom"),
|
||||||
|
(tool: "iccgamut", expected: "Gamut extraction failed: boom"),
|
||||||
|
(tool: "profcheck", expected: "Profile verification failed: boom"),
|
||||||
|
])
|
||||||
|
func toolDescriptions(tool: String, expected: String) {
|
||||||
|
let error = ArgyllRunnerError.toolFailed(tool: tool, code: 1, logs: ["boom"])
|
||||||
|
#expect(error.errorDescription == expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("toolFailed falls back to a generic description for unmapped tools and empty logs")
|
||||||
|
func genericFallbacks() {
|
||||||
|
let unknown = ArgyllRunnerError.toolFailed(tool: "targen", code: 7, logs: ["boom"])
|
||||||
|
#expect(unknown.errorDescription == "Process exited with code 7")
|
||||||
|
|
||||||
|
let emptyLogs = ArgyllRunnerError.toolFailed(tool: "colprof", code: 2, logs: [])
|
||||||
|
#expect(emptyLogs.errorDescription
|
||||||
|
== "Profile creation failed: exited with code 2")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -122,3 +122,105 @@ struct ArtefactFilesTests {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suite("ArtefactProbe profile resolve")
|
||||||
|
struct ArtefactProbeProfileTests {
|
||||||
|
private func makeDir() throws -> URL {
|
||||||
|
let dir = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("probe-\(UUID().uuidString)")
|
||||||
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Basename probe matrix (#69)
|
||||||
|
|
||||||
|
@Test("basename probe: only .icc exists")
|
||||||
|
func onlyIcc() throws {
|
||||||
|
let dir = try makeDir()
|
||||||
|
let icc = dir.appendingPathComponent("job.icc")
|
||||||
|
try Data("icc".utf8).write(to: icc)
|
||||||
|
#expect(ArtefactProbe.resolveProfile(basename: "job", cwd: dir)?.path == icc.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("basename probe: only .icm exists")
|
||||||
|
func onlyIcm() throws {
|
||||||
|
let dir = try makeDir()
|
||||||
|
let icm = dir.appendingPathComponent("job.icm")
|
||||||
|
try Data("icm".utf8).write(to: icm)
|
||||||
|
#expect(ArtefactProbe.resolveProfile(basename: "job", cwd: dir)?.path == icm.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("basename probe prefers .icm")
|
||||||
|
func icmWins() throws {
|
||||||
|
let dir = try makeDir()
|
||||||
|
try Data("icc".utf8).write(to: dir.appendingPathComponent("job.icc"))
|
||||||
|
let icm = dir.appendingPathComponent("job.icm")
|
||||||
|
try Data("icm".utf8).write(to: icm)
|
||||||
|
let url = ArtefactProbe.resolveProfile(basename: "job", cwd: dir)
|
||||||
|
#expect(url?.path == icm.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("basename probe: neither exists returns nil")
|
||||||
|
func neitherExists() throws {
|
||||||
|
let dir = try makeDir()
|
||||||
|
#expect(ArtefactProbe.resolveProfile(basename: "job", cwd: dir) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Explicit URL matrix (#69 / #83)
|
||||||
|
|
||||||
|
@Test("explicit existing .icc wins even when .icm exists")
|
||||||
|
func explicitIccWins() throws {
|
||||||
|
let dir = try makeDir()
|
||||||
|
let icc = dir.appendingPathComponent("job.icc")
|
||||||
|
try Data("icc".utf8).write(to: icc)
|
||||||
|
try Data("icm".utf8).write(to: dir.appendingPathComponent("job.icm"))
|
||||||
|
#expect(ArtefactProbe.resolveProfile(icc).path == icc.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("explicit existing .icm wins even when .icc exists")
|
||||||
|
func explicitIcmWins() throws {
|
||||||
|
let dir = try makeDir()
|
||||||
|
try Data("icc".utf8).write(to: dir.appendingPathComponent("job.icc"))
|
||||||
|
let icm = dir.appendingPathComponent("job.icm")
|
||||||
|
try Data("icm".utf8).write(to: icm)
|
||||||
|
#expect(ArtefactProbe.resolveProfile(icm).path == icm.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("explicit missing .icc flips to sibling .icm")
|
||||||
|
func flipExtension() throws {
|
||||||
|
let dir = try makeDir()
|
||||||
|
let icc = dir.appendingPathComponent("job.icc")
|
||||||
|
let icm = dir.appendingPathComponent("job.icm")
|
||||||
|
try Data("icm".utf8).write(to: icm)
|
||||||
|
let resolved = ArtefactProbe.resolveProfile(icc)
|
||||||
|
#expect(resolved.path == icm.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("explicit missing .icm flips to sibling .icc")
|
||||||
|
func flipToIcc() throws {
|
||||||
|
let dir = try makeDir()
|
||||||
|
let icc = dir.appendingPathComponent("job.icc")
|
||||||
|
let icm = dir.appendingPathComponent("job.icm")
|
||||||
|
try Data("icc".utf8).write(to: icc)
|
||||||
|
#expect(ArtefactProbe.resolveProfile(icm).path == icc.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("explicit missing both returns the original URL")
|
||||||
|
func missingBoth() throws {
|
||||||
|
let dir = try makeDir()
|
||||||
|
let icc = dir.appendingPathComponent("job.icc")
|
||||||
|
#expect(ArtefactProbe.resolveProfile(icc).path == icc.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("unrelated extension is never rewritten")
|
||||||
|
func unrelatedExtension() throws {
|
||||||
|
let dir = try makeDir()
|
||||||
|
let mpp = dir.appendingPathComponent("job.mpp")
|
||||||
|
let icc = dir.appendingPathComponent("job.icc")
|
||||||
|
try Data("icc".utf8).write(to: icc)
|
||||||
|
// Even though a sibling .icc exists, a missing .mpp stays .mpp.
|
||||||
|
#expect(ArtefactProbe.resolveProfile(mpp).path == mpp.path)
|
||||||
|
let txt = dir.appendingPathComponent("job.txt")
|
||||||
|
#expect(ArtefactProbe.resolveProfile(txt).path == txt.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import ICCeryCore
|
||||||
|
|
||||||
|
/// Issue #83 — canonical `CAL_` / original-stem pairing.
|
||||||
|
@Suite("CalibrationIdentity")
|
||||||
|
struct CalibrationIdentityTests {
|
||||||
|
@Test("live foo, no persisted")
|
||||||
|
func livePlain() {
|
||||||
|
let id = CalibrationIdentity.parse(liveBasename: "foo", persistedOriginal: "")
|
||||||
|
#expect(id.originalBasename == "foo")
|
||||||
|
#expect(id.calibrationBasename == "CAL_foo")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("live foo ignores stale persisted")
|
||||||
|
func livePlainIgnoresPersisted() {
|
||||||
|
let id = CalibrationIdentity.parse(liveBasename: "foo", persistedOriginal: "bar")
|
||||||
|
#expect(id.originalBasename == "foo")
|
||||||
|
#expect(id.calibrationBasename == "CAL_foo")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("live CAL_foo, persisted foo")
|
||||||
|
func liveCalPersisted() {
|
||||||
|
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "foo")
|
||||||
|
#expect(id.originalBasename == "foo")
|
||||||
|
#expect(id.calibrationBasename == "CAL_foo")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("live CAL_foo, empty persisted strips prefix")
|
||||||
|
func liveCalNoPersist() {
|
||||||
|
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "")
|
||||||
|
#expect(id.originalBasename == "foo")
|
||||||
|
#expect(id.calibrationBasename == "CAL_foo")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("persisted original wins over CAL_ live")
|
||||||
|
func persistedWins() {
|
||||||
|
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "bar")
|
||||||
|
#expect(id.originalBasename == "bar")
|
||||||
|
#expect(id.calibrationBasename == "CAL_bar")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("empty live yields empty identity even with persisted original")
|
||||||
|
func emptyLiveWithPersisted() {
|
||||||
|
let id = CalibrationIdentity.parse(liveBasename: "", persistedOriginal: "foo")
|
||||||
|
#expect(id.originalBasename.isEmpty)
|
||||||
|
#expect(id.calibrationBasename.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("empty live, empty persisted")
|
||||||
|
func emptyLive() {
|
||||||
|
let id = CalibrationIdentity.parse(liveBasename: "", persistedOriginal: "")
|
||||||
|
#expect(id.originalBasename.isEmpty)
|
||||||
|
#expect(id.calibrationBasename.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("prefix is idempotent on already-prefixed input")
|
||||||
|
func alreadyPrefixed() {
|
||||||
|
#expect(CalibrationIdentity.prefix("CAL_foo") == "CAL_foo")
|
||||||
|
#expect(CalibrationIdentity.prefix("foo") == "CAL_foo")
|
||||||
|
let id = CalibrationIdentity.parse(liveBasename: "CAL_CAL_foo", persistedOriginal: "")
|
||||||
|
#expect(id.originalBasename == "CAL_foo")
|
||||||
|
#expect(id.calibrationBasename == "CAL_foo")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("prefix never invents a name from empty input")
|
||||||
|
func prefixEmpty() {
|
||||||
|
#expect(CalibrationIdentity.prefix("").isEmpty)
|
||||||
|
#expect(CalibrationIdentity.strip("foo") == "foo")
|
||||||
|
#expect(CalibrationIdentity.strip("CAL_foo") == "foo")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("runner process id for a calibration targen is targen_CAL_*")
|
||||||
|
func processIdMatches() {
|
||||||
|
let cal = CalibrationIdentity.prefix("foo")
|
||||||
|
#expect(ProcessID.targen(cal) == "targen_CAL_foo")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import ICCeryCore
|
||||||
|
|
||||||
|
@Suite("JSONFileStore")
|
||||||
|
struct JSONFileStoreTests {
|
||||||
|
private func tempURL() -> URL {
|
||||||
|
FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("json-store-\(UUID().uuidString).json")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Missing file returns default")
|
||||||
|
func missingFileDefaults() throws {
|
||||||
|
let store = JSONFileStore<AppSettings>(
|
||||||
|
fileURL: tempURL(),
|
||||||
|
corrupt: .throwCorrupt,
|
||||||
|
defaultValue: { .default }
|
||||||
|
)
|
||||||
|
#expect(try store.load() == .default)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Corrupt file with replaceWithDefault returns default and leaves bytes")
|
||||||
|
func corruptDefaults() throws {
|
||||||
|
let url = tempURL()
|
||||||
|
try "{ not json".write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
let store = JSONFileStore<AppSettings>(
|
||||||
|
fileURL: url,
|
||||||
|
corrupt: .replaceWithDefault,
|
||||||
|
defaultValue: { .default }
|
||||||
|
)
|
||||||
|
#expect(try store.load() == .default)
|
||||||
|
let kept = try String(contentsOf: url, encoding: .utf8)
|
||||||
|
#expect(kept == "{ not json")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Corrupt file with throwCorrupt throws and leaves bytes")
|
||||||
|
func corruptThrows() throws {
|
||||||
|
let url = tempURL()
|
||||||
|
try "not json".write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
let store = JSONFileStore<[Int]>(
|
||||||
|
fileURL: url,
|
||||||
|
corrupt: .throwCorrupt,
|
||||||
|
defaultValue: { [] }
|
||||||
|
)
|
||||||
|
#expect(throws: DecodingError.self) {
|
||||||
|
_ = try store.load()
|
||||||
|
}
|
||||||
|
let kept = try String(contentsOf: url, encoding: .utf8)
|
||||||
|
#expect(kept == "not json")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Pretty sorted keys")
|
||||||
|
func prettySorted() throws {
|
||||||
|
let url = tempURL()
|
||||||
|
let store = JSONFileStore<AppSettings>(
|
||||||
|
fileURL: url,
|
||||||
|
corrupt: .replaceWithDefault,
|
||||||
|
defaultValue: { .default }
|
||||||
|
)
|
||||||
|
try store.save(.default)
|
||||||
|
let text = try String(contentsOf: url, encoding: .utf8)
|
||||||
|
#expect(text.contains("\n"))
|
||||||
|
#expect(text.contains("\"delta_e_good_max\""))
|
||||||
|
// Lexical key sorting: ascending order of top-level keys.
|
||||||
|
let keys = [
|
||||||
|
"ask_before_overwrite_profile",
|
||||||
|
"calibration_stale_days",
|
||||||
|
"custom_presets",
|
||||||
|
"default_install_location",
|
||||||
|
"delta_e_good_max",
|
||||||
|
"delta_e_warning_max",
|
||||||
|
"enable_i1pro2_leds",
|
||||||
|
"open_color_panel_after_install",
|
||||||
|
]
|
||||||
|
var lastIndex = text.startIndex
|
||||||
|
for key in keys {
|
||||||
|
guard let range = text.range(of: "\"\(key)\"", range: lastIndex..<text.endIndex) else {
|
||||||
|
Issue.record("missing or out-of-order key \(key)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastIndex = range.upperBound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -159,6 +159,51 @@ struct ChartreadRowTests {
|
|||||||
#expect(row.patchCount == 1)
|
#expect(row.patchCount == 1)
|
||||||
#expect(row.patches[0].measured.lab?.l == 51)
|
#expect(row.patches[0].measured.lab?.l == 51)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("Decodes a row carrying both XYZ and Lab arrays")
|
||||||
|
func decodeXYZAndLab() throws {
|
||||||
|
let json = """
|
||||||
|
{"event": "row_complete", "row_id": "B", "row_index": 1, "total_rows": 2,
|
||||||
|
"patch_count": 1, "patches": [
|
||||||
|
{"id": "7", "loc": "B7", "is_pad": false, "device": [10, 20, 30, 40],
|
||||||
|
"measured": {"XYZ": [30.5, 32.1, 25.9], "Lab": [63.4, 2.5, -8.2]}}
|
||||||
|
]}
|
||||||
|
"""
|
||||||
|
let row = try JSONDecoder().decode(ChartreadRow.self, from: Data(json.utf8))
|
||||||
|
let measured = row.patches[0].measured
|
||||||
|
#expect(measured.xyz == CIEXYZ(x: 30.5, y: 32.1, z: 25.9))
|
||||||
|
#expect(measured.lab == CIELab(l: 63.4, a: 2.5, b: -8.2))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("XYZColor/CIEXYZ encode as an unkeyed three-number array")
|
||||||
|
func xyzWireEncoding() throws {
|
||||||
|
for color in [XYZColor(x: 1.5, y: 2.5, z: 3.5), CIEXYZ(x: 1.5, y: 2.5, z: 3.5)] {
|
||||||
|
let value = try JSONSerialization.jsonObject(
|
||||||
|
with: JSONEncoder().encode(color))
|
||||||
|
#expect(value as? [Double] == [1.5, 2.5, 3.5])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("LabColor/CIELab encode as an unkeyed three-number array")
|
||||||
|
func labWireEncoding() throws {
|
||||||
|
for color in [LabColor(l: 50, a: -1, b: 2), CIELab(l: 50, a: -1, b: 2)] {
|
||||||
|
let value = try JSONSerialization.jsonObject(
|
||||||
|
with: JSONEncoder().encode(color))
|
||||||
|
#expect(value as? [Double] == [50, -1, 2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("PatchColor keeps the XYZ and Lab keys over unkeyed arrays")
|
||||||
|
func patchColorKeys() throws {
|
||||||
|
let color = PatchColor(
|
||||||
|
xyz: CIEXYZ(x: 10, y: 20, z: 30),
|
||||||
|
lab: CIELab(l: 55, a: 1, b: -2))
|
||||||
|
let object = try JSONSerialization.jsonObject(
|
||||||
|
with: JSONEncoder().encode(color)) as? [String: Any]
|
||||||
|
#expect(object?["XYZ"] as? [Double] == [10, 20, 30])
|
||||||
|
#expect(object?["Lab"] as? [Double] == [55, 1, -2])
|
||||||
|
#expect(object?["spectral"] == nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suite("ColourMath")
|
@Suite("ColourMath")
|
||||||
|
|||||||
@@ -230,3 +230,267 @@ struct PresetMigrationTests {
|
|||||||
#expect(back.dpi == 150)
|
#expect(back.dpi == 150)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suite("Preset mapping")
|
||||||
|
struct PresetMappingTests {
|
||||||
|
@Test("Draft 150 DPI maps into PrinttargConfig")
|
||||||
|
func draftDpi() {
|
||||||
|
let cfg = PrinttargConfig(
|
||||||
|
preset: PresetCatalog.draftRGB,
|
||||||
|
basename: "t",
|
||||||
|
workingDirectory: nil,
|
||||||
|
calibrationFile: nil
|
||||||
|
)
|
||||||
|
#expect(cfg.dpi == 150)
|
||||||
|
#expect(cfg.layoutOrder == .deterministic)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Nil optional targen fields stay nil")
|
||||||
|
func optionalNil() {
|
||||||
|
let preset = ProfilingPreset(id: "x", name: "n", patchCount: 800)
|
||||||
|
let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
|
||||||
|
#expect(cfg.greySteps == nil)
|
||||||
|
#expect(cfg.singleChannelSteps == nil)
|
||||||
|
#expect(cfg.neutralSteps == nil)
|
||||||
|
#expect(cfg.totalInkLimit == nil)
|
||||||
|
#expect(cfg.darkEmphasis == nil)
|
||||||
|
#expect(cfg.devicePower == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Custom page and FWA survive a config round-trip")
|
||||||
|
func roundTripConfigs() {
|
||||||
|
var preset = PresetCatalog.highQualityCMYK
|
||||||
|
preset.pageSize = "210x297"
|
||||||
|
preset.colprofFwa = "D50"
|
||||||
|
preset.greySteps = nil
|
||||||
|
let targen = TargenConfig(preset: preset, basename: "job", workingDirectory: nil)
|
||||||
|
let printtarg = PrinttargConfig(
|
||||||
|
preset: preset, basename: "job", workingDirectory: nil, calibrationFile: nil
|
||||||
|
)
|
||||||
|
let colprof = ColprofConfig(preset: preset, basename: "job", workingDirectory: nil)
|
||||||
|
#expect(printtarg.pageSize == .custom)
|
||||||
|
#expect(printtarg.customPageWidth == 210)
|
||||||
|
#expect(colprof.fwa == "D50")
|
||||||
|
let back = ProfilingPreset(
|
||||||
|
id: preset.id,
|
||||||
|
name: preset.name,
|
||||||
|
description: preset.description,
|
||||||
|
targen: targen,
|
||||||
|
printtarg: printtarg,
|
||||||
|
colprof: colprof,
|
||||||
|
calibrationFile: preset.calibrationFile,
|
||||||
|
applyCalibration: preset.applyCalibration
|
||||||
|
)
|
||||||
|
#expect(back.dpi == preset.dpi)
|
||||||
|
#expect(back.colourSpace == "cmyk")
|
||||||
|
#expect(back.pageSize == "210x297")
|
||||||
|
#expect(back.colprofFwa == "D50")
|
||||||
|
#expect(back.greySteps == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Full preset round-trips through all three configs with every field asserted")
|
||||||
|
func fullRoundTrip() {
|
||||||
|
let preset = ProfilingPreset(
|
||||||
|
id: "custom-full",
|
||||||
|
name: "Full",
|
||||||
|
description: "All fields",
|
||||||
|
colourSpace: "cmyk",
|
||||||
|
patchCount: 1500,
|
||||||
|
whitePatches: 6,
|
||||||
|
blackPatches: 8,
|
||||||
|
greySteps: 9,
|
||||||
|
singleChannelSteps: 7,
|
||||||
|
neutralSteps: 4,
|
||||||
|
neutralConcentration: 0.7,
|
||||||
|
preconditioningProfile: "/tmp/pre.icm",
|
||||||
|
ofpsHighQuality: true,
|
||||||
|
ofpsAdaptation: 0.2,
|
||||||
|
fullSpreadAlgorithm: "R",
|
||||||
|
totalInkLimit: 280,
|
||||||
|
darkEmphasis: 1.3,
|
||||||
|
devicePower: 1.2,
|
||||||
|
instrument: "p3",
|
||||||
|
pageSize: "250x300",
|
||||||
|
bitDepth: 16,
|
||||||
|
dpi: 360,
|
||||||
|
randomSeed: 42,
|
||||||
|
noRandomize: false,
|
||||||
|
calibrationFile: "/tmp/a.cal",
|
||||||
|
applyCalibration: true,
|
||||||
|
colprofAlgorithm: "x",
|
||||||
|
colprofQuality: "u",
|
||||||
|
colprofIntent: "p",
|
||||||
|
colprofFwa: "D65",
|
||||||
|
colprofIlluminant: "D65",
|
||||||
|
colprofObserver: "1931_2",
|
||||||
|
colprofInputViewingCond: "D50_2",
|
||||||
|
colprofOutputViewingCond: "D65_2"
|
||||||
|
)
|
||||||
|
|
||||||
|
let targen = TargenConfig(preset: preset, basename: "j", workingDirectory: nil)
|
||||||
|
#expect(targen.colourSpace == .cmyk)
|
||||||
|
#expect(targen.patchCount == 1500)
|
||||||
|
#expect(targen.whitePatches == 6)
|
||||||
|
#expect(targen.blackPatches == 8)
|
||||||
|
#expect(targen.greySteps == 9)
|
||||||
|
#expect(targen.singleChannelSteps == 7)
|
||||||
|
#expect(targen.neutralSteps == 4)
|
||||||
|
#expect(targen.neutralConcentration == 0.7)
|
||||||
|
#expect(targen.preconditioningProfile == "/tmp/pre.icm")
|
||||||
|
#expect(targen.ofpsHighQuality == true)
|
||||||
|
#expect(targen.ofpsAdaptation == 0.2)
|
||||||
|
#expect(targen.fullSpreadAlgorithm == .uniformRandom)
|
||||||
|
#expect(targen.totalInkLimit == 280)
|
||||||
|
#expect(targen.darkEmphasis == 1.3)
|
||||||
|
#expect(targen.devicePower == 1.2)
|
||||||
|
|
||||||
|
let printtarg = PrinttargConfig(
|
||||||
|
preset: preset,
|
||||||
|
basename: "j",
|
||||||
|
workingDirectory: nil,
|
||||||
|
calibrationFile: preset.calibrationFile
|
||||||
|
)
|
||||||
|
#expect(printtarg.instrument == .p3)
|
||||||
|
#expect(printtarg.pageSize == .custom)
|
||||||
|
#expect(printtarg.customPageWidth == 250)
|
||||||
|
#expect(printtarg.customPageHeight == 300)
|
||||||
|
#expect(printtarg.bitDepth == .sixteen)
|
||||||
|
#expect(printtarg.dpi == 360)
|
||||||
|
#expect(printtarg.layoutOrder == .customSeed)
|
||||||
|
#expect(printtarg.customSeed == 42)
|
||||||
|
#expect(printtarg.calibrationFile == "/tmp/a.cal")
|
||||||
|
|
||||||
|
let colprof = ColprofConfig(preset: preset, basename: "j", workingDirectory: nil)
|
||||||
|
#expect(colprof.algorithm == "x")
|
||||||
|
#expect(colprof.quality == "u")
|
||||||
|
#expect(colprof.intent == "p")
|
||||||
|
#expect(colprof.fwa == "D65")
|
||||||
|
#expect(colprof.illuminant == "D65")
|
||||||
|
#expect(colprof.observer == "1931_2")
|
||||||
|
#expect(colprof.inputViewingCond == "D50_2")
|
||||||
|
#expect(colprof.outputViewingCond == "D65_2")
|
||||||
|
|
||||||
|
let back = ProfilingPreset(
|
||||||
|
id: preset.id,
|
||||||
|
name: preset.name,
|
||||||
|
description: preset.description,
|
||||||
|
targen: targen,
|
||||||
|
printtarg: printtarg,
|
||||||
|
colprof: colprof,
|
||||||
|
calibrationFile: preset.calibrationFile,
|
||||||
|
applyCalibration: preset.applyCalibration
|
||||||
|
)
|
||||||
|
#expect(back == preset)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Every full-spread algorithm round-trips", arguments: [
|
||||||
|
("ofps", FullSpreadAlgorithm.ofps),
|
||||||
|
("t", .target),
|
||||||
|
("r", .random),
|
||||||
|
("R", .uniformRandom),
|
||||||
|
("q", .quasiRandom),
|
||||||
|
("Q", .uniformQuasiRandom),
|
||||||
|
("i", .invertedQuasiRandom),
|
||||||
|
("I", .invertedUniformQuasiRandom)
|
||||||
|
])
|
||||||
|
func fullSpreadAlgorithms(value: String, expected: FullSpreadAlgorithm) {
|
||||||
|
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
|
||||||
|
preset.fullSpreadAlgorithm = value
|
||||||
|
let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
|
||||||
|
if expected == .ofps {
|
||||||
|
// ofps is the default — no flag emitted, stored value is nil.
|
||||||
|
#expect(cfg.fullSpreadAlgorithm == nil)
|
||||||
|
} else {
|
||||||
|
#expect(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
|
||||||
|
)
|
||||||
|
#expect(back.fullSpreadAlgorithm == value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Explicit ofpsHighQuality=false is preserved, distinct from nil")
|
||||||
|
func ofpsHighQualityFalse() {
|
||||||
|
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
|
||||||
|
preset.ofpsHighQuality = false
|
||||||
|
let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
|
||||||
|
#expect(cfg.ofpsHighQuality == false)
|
||||||
|
|
||||||
|
preset.ofpsHighQuality = nil
|
||||||
|
let nilCfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
|
||||||
|
#expect(nilCfg.ofpsHighQuality == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("noRandomize/seed layout mapping rules", arguments: [
|
||||||
|
(true, nil, LayoutOrder.raster, 1),
|
||||||
|
(true, 7, .raster, 7),
|
||||||
|
(false, nil, .deterministic, 1),
|
||||||
|
(false, 1, .deterministic, 1),
|
||||||
|
(nil, 1, .deterministic, 1),
|
||||||
|
(false, 5, .customSeed, 5)
|
||||||
|
] as [(Bool?, Int?, LayoutOrder, Int)])
|
||||||
|
func layoutMapping(noRandomize: Bool?, seed: Int?, layout: LayoutOrder, expectedSeed: Int) {
|
||||||
|
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
|
||||||
|
preset.noRandomize = noRandomize
|
||||||
|
preset.randomSeed = seed
|
||||||
|
let cfg = PrinttargConfig(
|
||||||
|
preset: preset, basename: "t",
|
||||||
|
workingDirectory: nil, calibrationFile: nil
|
||||||
|
)
|
||||||
|
#expect(cfg.layoutOrder == layout)
|
||||||
|
#expect(cfg.customSeed == expectedSeed)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Custom page fallback matrix", arguments: [
|
||||||
|
("250x300", PageSize.custom, 250.0, 300.0),
|
||||||
|
("50x50", .custom, 50.0, 50.0),
|
||||||
|
("foo", .a4, 210.0, 297.0),
|
||||||
|
("30x40", .a4, 210.0, 297.0),
|
||||||
|
("210x", .a4, 210.0, 297.0)
|
||||||
|
] as [(String, PageSize, Double, Double)])
|
||||||
|
func customPageFallback(raw: String, page: PageSize, w: Double, h: Double) {
|
||||||
|
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
|
||||||
|
preset.pageSize = raw
|
||||||
|
let cfg = PrinttargConfig(
|
||||||
|
preset: preset, basename: "t",
|
||||||
|
workingDirectory: nil, calibrationFile: nil
|
||||||
|
)
|
||||||
|
#expect(cfg.pageSize == page)
|
||||||
|
#expect(cfg.customPageWidth == w)
|
||||||
|
#expect(cfg.customPageHeight == h)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("FWA preset value → selection matrix", arguments: [
|
||||||
|
(nil, ColprofFwaSelection.none),
|
||||||
|
("none", .none),
|
||||||
|
("NONE", .none),
|
||||||
|
("", .empty),
|
||||||
|
("D50", .D50),
|
||||||
|
("d50", .D50),
|
||||||
|
("D65", .D65),
|
||||||
|
("d65", .D65),
|
||||||
|
("/tmp/fwa.sp", .custom)
|
||||||
|
] as [(String?, ColprofFwaSelection)])
|
||||||
|
func fwaToSelection(raw: String?, expected: ColprofFwaSelection) {
|
||||||
|
#expect(ColprofFwaSelection(presetValue: raw) == expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("FWA selection → preset value matrix", arguments: [
|
||||||
|
(ColprofFwaSelection.none, nil),
|
||||||
|
(.empty, ""),
|
||||||
|
(.D50, "D50"),
|
||||||
|
(.D65, "D65"),
|
||||||
|
(.custom, "/tmp/fwa.sp")
|
||||||
|
] as [(ColprofFwaSelection, String?)])
|
||||||
|
func fwaToPresetValue(selection: ColprofFwaSelection, expected: String?) {
|
||||||
|
#expect(selection.presetValue(customPath: "/tmp/fwa.sp") == expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import ICCeryCore
|
||||||
|
@testable import ICCery
|
||||||
|
|
||||||
|
/// Issue #82 — preset application through the live view models, under an
|
||||||
|
/// isolated `TestAppEnvironment` (temp stores, fresh ProcessManager).
|
||||||
|
@Suite("PresetViewModelMapping")
|
||||||
|
@MainActor
|
||||||
|
struct PresetViewModelMappingTests {
|
||||||
|
|
||||||
|
private func makeWorkflow() throws -> (TestAppEnvironment, TargetWorkflowViewModel) {
|
||||||
|
let env = try TestAppEnvironment.make()
|
||||||
|
return (env, TargetWorkflowViewModel(environment: env.environment))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Applying a nil-FWA preset after a custom FWA clears the stale path")
|
||||||
|
func nilFwaClearsCustomPath() throws {
|
||||||
|
let (env, vm) = try makeWorkflow()
|
||||||
|
defer { env.cleanup() }
|
||||||
|
|
||||||
|
var customPreset = ProfilingPreset(
|
||||||
|
id: "c-fwa", name: "FWA", patchCount: 800,
|
||||||
|
colprofFwa: "/tmp/fwa.sp"
|
||||||
|
)
|
||||||
|
vm.applyPreset(customPreset)
|
||||||
|
#expect(vm.profile.fwaSelection == .custom)
|
||||||
|
#expect(vm.profile.fwaCustomPath == "/tmp/fwa.sp")
|
||||||
|
|
||||||
|
customPreset.colprofFwa = nil
|
||||||
|
vm.applyPreset(customPreset)
|
||||||
|
#expect(vm.profile.fwaSelection == .none)
|
||||||
|
#expect(vm.profile.fwaCustomPath == "")
|
||||||
|
#expect(vm.profile.fwaValue == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Custom FWA preset path survives the round-trip to colprof_fwa")
|
||||||
|
func customFwaRoundTrip() throws {
|
||||||
|
let (env, vm) = try makeWorkflow()
|
||||||
|
defer { env.cleanup() }
|
||||||
|
|
||||||
|
let preset = ProfilingPreset(
|
||||||
|
id: "c-fwa2", name: "FWA2", patchCount: 800,
|
||||||
|
colprofFwa: "/tmp/other.sp"
|
||||||
|
)
|
||||||
|
vm.applyPreset(preset)
|
||||||
|
#expect(vm.profile.fwaSelection == .custom)
|
||||||
|
#expect(vm.profile.fwaCustomPath == "/tmp/other.sp")
|
||||||
|
#expect(vm.profile.fwaValue == "/tmp/other.sp")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Preset calibration reaches Stage 2 instead of stale live state")
|
||||||
|
func presetCalibrationReachesStage2() throws {
|
||||||
|
let (env, vm) = try makeWorkflow()
|
||||||
|
defer { env.cleanup() }
|
||||||
|
|
||||||
|
// Stale live state must not leak into the preset-applied layout.
|
||||||
|
vm.profile.applyCalibration = true
|
||||||
|
vm.profile.calibrationFile = "/tmp/stale.cal"
|
||||||
|
|
||||||
|
let preset = ProfilingPreset(
|
||||||
|
id: "c-cal", name: "Cal", patchCount: 800,
|
||||||
|
calibrationFile: "/tmp/preset.cal",
|
||||||
|
applyCalibration: true
|
||||||
|
)
|
||||||
|
vm.applyPreset(preset)
|
||||||
|
|
||||||
|
#expect(vm.profile.applyCalibration)
|
||||||
|
#expect(vm.profile.calibrationFile == "/tmp/preset.cal")
|
||||||
|
#expect(vm.buildPrinttargConfig().calibrationFile == "/tmp/preset.cal")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Preset with calibration disabled clears Stage 2 calibration")
|
||||||
|
func disabledCalibrationClearsStage2() throws {
|
||||||
|
let (env, vm) = try makeWorkflow()
|
||||||
|
defer { env.cleanup() }
|
||||||
|
|
||||||
|
vm.profile.applyCalibration = true
|
||||||
|
vm.profile.calibrationFile = "/tmp/stale.cal"
|
||||||
|
|
||||||
|
let preset = ProfilingPreset(
|
||||||
|
id: "c-nocal", name: "NoCal", patchCount: 800,
|
||||||
|
calibrationFile: "/tmp/preset.cal",
|
||||||
|
applyCalibration: nil
|
||||||
|
)
|
||||||
|
vm.applyPreset(preset)
|
||||||
|
|
||||||
|
#expect(!vm.profile.applyCalibration)
|
||||||
|
#expect(vm.buildPrinttargConfig().calibrationFile == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Preset Stage 1/2 form fields apply to the live form")
|
||||||
|
func formFieldsApply() throws {
|
||||||
|
let (env, vm) = try makeWorkflow()
|
||||||
|
defer { env.cleanup() }
|
||||||
|
|
||||||
|
var preset = ProfilingPreset(
|
||||||
|
id: "c-form", name: "Form",
|
||||||
|
colourSpace: "cmyk", patchCount: 1500,
|
||||||
|
whitePatches: 6,
|
||||||
|
blackPatches: 8,
|
||||||
|
greySteps: 9,
|
||||||
|
fullSpreadAlgorithm: "r",
|
||||||
|
pageSize: "250x300",
|
||||||
|
dpi: 150
|
||||||
|
)
|
||||||
|
vm.applyPreset(preset)
|
||||||
|
|
||||||
|
#expect(vm.colourSpace == .cmyk)
|
||||||
|
#expect(vm.effectivePatchCount == 1500)
|
||||||
|
#expect(vm.whitePatches == 6)
|
||||||
|
#expect(vm.blackPatches == 8)
|
||||||
|
#expect(vm.greyStepsEnabled && vm.greySteps == 9)
|
||||||
|
#expect(vm.algorithm == .random)
|
||||||
|
#expect(vm.tiffDpi == 150)
|
||||||
|
#expect(vm.pageSize == .custom)
|
||||||
|
#expect(vm.customPageW == 250 && vm.customPageH == 300)
|
||||||
|
#expect(vm.selectedPresetID == "c-form")
|
||||||
|
|
||||||
|
// Disabled advanced controls stay nil in the snapshot, not
|
||||||
|
// numeric sentinels.
|
||||||
|
preset.greySteps = nil
|
||||||
|
vm.applyPreset(preset)
|
||||||
|
#expect(!vm.greyStepsEnabled)
|
||||||
|
#expect(vm.buildTargenConfig().greySteps == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,29 @@ struct PrintcalArgsTests {
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("Whitespace-only previous calibration path emits no -a")
|
||||||
|
func whitespacePreviousCal() throws {
|
||||||
|
let config = PrintcalConfig(
|
||||||
|
ti3Basename: "demo",
|
||||||
|
outputURL: tmp,
|
||||||
|
previousCalPath: " \n\t "
|
||||||
|
)
|
||||||
|
let args = try PrintcalArgs.build(config: config)
|
||||||
|
#expect(!args.contains("-a"))
|
||||||
|
#expect(args == ["-v", "-e", "-o", "/tmp/out.cal", "CAL_demo"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Previous calibration path is trimmed before emission")
|
||||||
|
func previousCalTrimmed() throws {
|
||||||
|
let config = PrintcalConfig(
|
||||||
|
ti3Basename: "demo",
|
||||||
|
outputURL: tmp,
|
||||||
|
previousCalPath: " /tmp/old.cal "
|
||||||
|
)
|
||||||
|
let args = try PrintcalArgs.build(config: config)
|
||||||
|
#expect(args[args.firstIndex(of: "-a")! + 1] == "/tmp/old.cal")
|
||||||
|
}
|
||||||
|
|
||||||
@Test("Rejects invalid per-channel limit")
|
@Test("Rejects invalid per-channel limit")
|
||||||
func rejectsBadChannelLimit() {
|
func rejectsBadChannelLimit() {
|
||||||
let config = PrintcalConfig(
|
let config = PrintcalConfig(
|
||||||
|
|||||||
@@ -131,6 +131,23 @@ struct PrinttargArgsTests {
|
|||||||
#expect(!args.contains("-I"))
|
#expect(!args.contains("-I"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("Whitespace-only label emits no -d; whitespace-only calibration emits no -K/-I")
|
||||||
|
func whitespaceOptions() throws {
|
||||||
|
let args = try PrinttargArgs.build(
|
||||||
|
config: config(label: " \n ", calFile: " \t "))
|
||||||
|
#expect(!args.contains("-d"))
|
||||||
|
#expect(!args.contains("-K"))
|
||||||
|
#expect(!args.contains("-I"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Label and calibration values are trimmed before emission")
|
||||||
|
func trimmedOptions() throws {
|
||||||
|
let args = try PrinttargArgs.build(
|
||||||
|
config: config(label: " My Label ", calFile: " /tmp/a.cal "))
|
||||||
|
#expect(args[args.firstIndex(of: "-d")! + 1] == "My Label")
|
||||||
|
#expect(args[args.firstIndex(of: "-K")! + 1] == "/tmp/a.cal")
|
||||||
|
}
|
||||||
|
|
||||||
@Test("Unsafe basename throws")
|
@Test("Unsafe basename throws")
|
||||||
func unsafeBasename() {
|
func unsafeBasename() {
|
||||||
#expect(throws: PathSecurity.Error.self) {
|
#expect(throws: PathSecurity.Error.self) {
|
||||||
@@ -349,7 +366,7 @@ struct ArgyllRunnerPrinttargTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Non-zero exit throws processFailed and stays on stage")
|
@Test("Non-zero exit throws toolFailed and stays on stage")
|
||||||
func failure() async throws {
|
func failure() async throws {
|
||||||
let dir = try makeFixture("""
|
let dir = try makeFixture("""
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
@@ -360,7 +377,8 @@ 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 #expect(throws: ArgyllRunnerError.toolFailed(
|
||||||
|
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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,59 @@ struct ProcessManagerTests {
|
|||||||
func finish() -> Bool { lock.lock(); defer { lock.unlock() }; if finished { return false }; finished = true; return true }
|
func finish() -> Bool { lock.lock(); defer { lock.unlock() }; if finished { return false }; finished = true; return true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Subscribes synchronously (registration happens inside `events()`)
|
||||||
|
/// then records every event for `id` until the task is cancelled.
|
||||||
|
/// Unlike `collect`, observation continues past `.exit` so tests can
|
||||||
|
/// prove exactly-once exit emission.
|
||||||
|
private func observe(
|
||||||
|
_ manager: ProcessManager,
|
||||||
|
id: String,
|
||||||
|
into box: Box
|
||||||
|
) -> Task<Void, Never> {
|
||||||
|
let stream = manager.events()
|
||||||
|
return Task {
|
||||||
|
for await event in stream {
|
||||||
|
guard event.id == id else { continue }
|
||||||
|
box.append(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func exitCount(in box: Box) -> Int {
|
||||||
|
box.events.filter { if case .exit = $0 { return true }; return false }.count
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitForExit(in box: Box, timeout: TimeInterval = 10) async -> Bool {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
if exitCount(in: box) > 0 { return true }
|
||||||
|
try? await Task.sleep(for: .milliseconds(10))
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitForFile(_ url: URL, timeout: TimeInterval = 5) async -> Bool {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
if FileManager.default.fileExists(atPath: url.path) { return true }
|
||||||
|
try? await Task.sleep(for: .milliseconds(10))
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitForRunning(
|
||||||
|
_ manager: ProcessManager,
|
||||||
|
id: String,
|
||||||
|
timeout: TimeInterval = 5
|
||||||
|
) async -> Bool {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
if await manager.isRunning(id) { return true }
|
||||||
|
try? await Task.sleep(for: .milliseconds(10))
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Tests
|
// MARK: - Tests
|
||||||
|
|
||||||
@Test func streamsStdoutAndEmitsExit() async throws {
|
@Test func streamsStdoutAndEmitsExit() async throws {
|
||||||
@@ -214,6 +267,145 @@ struct ProcessManagerTests {
|
|||||||
try await pm.sendStdin(id: "nope", text: "d\n")
|
try await pm.sendStdin(id: "nope", text: "d\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func explicitPartialFlushEmitsRowColorsJSON() async throws {
|
||||||
|
let pm = ProcessManager()
|
||||||
|
let marker = Self.fixtureDir
|
||||||
|
.appendingPathComponent("partial-row-ready-\(UUID().uuidString)")
|
||||||
|
let bin = try script(
|
||||||
|
"partial-row.sh",
|
||||||
|
"#!/bin/sh\nprintf 'ROW_COLORS_JSON: {\"row\":9}'\ntouch \"$1\"\nsleep 30\n"
|
||||||
|
)
|
||||||
|
let box = Box()
|
||||||
|
let observer = observe(pm, id: "t11", into: box)
|
||||||
|
try await pm.runStreaming(id: "t11", binary: bin, arguments: [marker.path])
|
||||||
|
#expect(await waitForFile(marker))
|
||||||
|
// Retry the flush so the pipe-ingest task can win the actor race
|
||||||
|
// on a loaded host; the first successful flush emits the row.
|
||||||
|
var flushed = false
|
||||||
|
for _ in 0..<50 {
|
||||||
|
await pm.flushPartialLine(id: "t11")
|
||||||
|
if box.events.contains(where: { if case .jsonRow = $0 { return true }; return false }) {
|
||||||
|
flushed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
try await Task.sleep(for: .milliseconds(20))
|
||||||
|
}
|
||||||
|
#expect(flushed)
|
||||||
|
await pm.kill(id: "t11")
|
||||||
|
#expect(await waitForExit(in: box))
|
||||||
|
observer.cancel()
|
||||||
|
let events = box.events
|
||||||
|
let rows = events.compactMap { e -> String? in
|
||||||
|
if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
#expect(rows == ["{\"row\":9}"])
|
||||||
|
// Prefixed tails must not leak into stdout, even via finalize.
|
||||||
|
#expect(!events.contains(.stdout(id: "t11", line: "ROW_COLORS_JSON: {\"row\":9}")))
|
||||||
|
#expect(exitCount(in: box) == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func unterminatedRowTailFinalizesAsJSONRow() async throws {
|
||||||
|
let pm = ProcessManager()
|
||||||
|
let bin = try script(
|
||||||
|
"row-tail.sh",
|
||||||
|
"#!/bin/sh\nprintf 'ROW_COLORS_JSON: {\"row\":42}'\n"
|
||||||
|
)
|
||||||
|
let box = Box()
|
||||||
|
let observer = observe(pm, id: "t12", into: box)
|
||||||
|
try await pm.runStreaming(id: "t12", binary: bin, arguments: [])
|
||||||
|
#expect(await waitForExit(in: box))
|
||||||
|
observer.cancel()
|
||||||
|
let events = box.events
|
||||||
|
let rows = events.compactMap { e -> String? in
|
||||||
|
if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
#expect(rows == ["{\"row\":42}"])
|
||||||
|
#expect(!events.contains(.stdout(id: "t12", line: "ROW_COLORS_JSON: {\"row\":42}")))
|
||||||
|
let rowIndex = events.firstIndex {
|
||||||
|
if case .jsonRow = $0 { return true }; return false
|
||||||
|
}
|
||||||
|
let exitIndexes = events.indices.filter {
|
||||||
|
if case .exit = events[$0] { return true }; return false
|
||||||
|
}
|
||||||
|
#expect(exitIndexes.count == 1)
|
||||||
|
if let rowIndex, let exitIndex = exitIndexes.first {
|
||||||
|
#expect(rowIndex < exitIndex)
|
||||||
|
} else {
|
||||||
|
Issue.record("expected a jsonRow before the exit event")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func fastStreamingExitEmitsExactlyOneExit() async throws {
|
||||||
|
let pm = ProcessManager()
|
||||||
|
let bin = try script("fast-stream.sh", "#!/bin/sh\nexit 0\n")
|
||||||
|
let box = Box()
|
||||||
|
let observer = observe(pm, id: "t13", into: box)
|
||||||
|
try await pm.runStreaming(id: "t13", binary: bin, arguments: [])
|
||||||
|
#expect(await waitForExit(in: box))
|
||||||
|
// The grace window must outlast the 2 s finalize watchdog so a
|
||||||
|
// duplicate emission from it would be observed.
|
||||||
|
try await Task.sleep(for: .milliseconds(2500))
|
||||||
|
observer.cancel()
|
||||||
|
#expect(box.events == [.exit(id: "t13", code: 0)])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func fastCapturedExitEmitsExactlyOneExit() async throws {
|
||||||
|
let pm = ProcessManager()
|
||||||
|
let bin = try script("fast-cap.sh", "#!/bin/sh\nexit 7\n")
|
||||||
|
let box = Box()
|
||||||
|
let observer = observe(pm, id: "t14", into: box)
|
||||||
|
let result = try await pm.runCaptured(id: "t14", binary: bin, arguments: [])
|
||||||
|
#expect(result.exitCode == 7)
|
||||||
|
// Both the termination handler and the waitUntilExit watchdog
|
||||||
|
// resume the same box; give the slower path time to fire.
|
||||||
|
try await Task.sleep(for: .milliseconds(500))
|
||||||
|
observer.cancel()
|
||||||
|
#expect(box.events == [.exit(id: "t14", code: 7)])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func capturedRunSetsArgyllNotInteractive() async throws {
|
||||||
|
let pm = ProcessManager()
|
||||||
|
let bin = try script(
|
||||||
|
"cap-env.sh",
|
||||||
|
"#!/bin/sh\necho \"ANI=$ARGYLL_NOT_INTERACTIVE\"\n"
|
||||||
|
)
|
||||||
|
let result = try await pm.runCaptured(id: "t15", binary: bin, arguments: [])
|
||||||
|
#expect(result.stdout == "ANI=1\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func killAllTerminatesStreamingAndCapturedChildren() async throws {
|
||||||
|
let pm = ProcessManager()
|
||||||
|
let marker = Self.fixtureDir
|
||||||
|
.appendingPathComponent("mixed-cap-ready-\(UUID().uuidString)")
|
||||||
|
let slowBin = try script("mixed-slow.sh", "#!/bin/sh\nsleep 30\n")
|
||||||
|
let capBin = try script("mixed-cap.sh", "#!/bin/sh\ntouch \"$1\"\nsleep 30\n")
|
||||||
|
let streamBox = Box()
|
||||||
|
let capBox = Box()
|
||||||
|
let streamObserver = observe(pm, id: "t16", into: streamBox)
|
||||||
|
let capObserver = observe(pm, id: "t17", into: capBox)
|
||||||
|
try await pm.runStreaming(id: "t16", binary: slowBin, arguments: [])
|
||||||
|
let capTask = Task {
|
||||||
|
try await pm.runCaptured(id: "t17", binary: capBin, arguments: [marker.path])
|
||||||
|
}
|
||||||
|
#expect(await waitForFile(marker))
|
||||||
|
#expect(await waitForRunning(pm, id: "t16"))
|
||||||
|
#expect(await waitForRunning(pm, id: "t17"))
|
||||||
|
#expect(await pm.killAll() == 2)
|
||||||
|
_ = try await capTask.value
|
||||||
|
#expect(await waitForExit(in: streamBox))
|
||||||
|
#expect(await waitForExit(in: capBox))
|
||||||
|
// Grace window outlasts the streaming finalize watchdog.
|
||||||
|
try await Task.sleep(for: .milliseconds(2500))
|
||||||
|
streamObserver.cancel()
|
||||||
|
capObserver.cancel()
|
||||||
|
#expect(!(await pm.isRunning("t16")))
|
||||||
|
#expect(!(await pm.isRunning("t17")))
|
||||||
|
#expect(exitCount(in: streamBox) == 1)
|
||||||
|
#expect(exitCount(in: capBox) == 1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suite("ProcessLineDecoder")
|
@Suite("ProcessLineDecoder")
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import ICCery
|
||||||
|
|
||||||
|
/// Direct contracts for the shared logged-run helper (issue #80).
|
||||||
|
///
|
||||||
|
/// `runLogged` owns the running-flag transition (`false → true → false`)
|
||||||
|
/// and the log-reset decision; these tests pin both sides of the
|
||||||
|
/// contract plus the coalesced `@MainActor` log hop.
|
||||||
|
@Suite("ProcessRunSupport runLogged")
|
||||||
|
@MainActor
|
||||||
|
struct ProcessRunSupportTests {
|
||||||
|
|
||||||
|
private struct SentinelError: Error {}
|
||||||
|
|
||||||
|
@Test("Success: running transitions [true, false], log resets once, batches reach the main actor, value preserved")
|
||||||
|
func successTransitions() async throws {
|
||||||
|
var running: [Bool] = []
|
||||||
|
var resets = 0
|
||||||
|
var received: [String] = []
|
||||||
|
|
||||||
|
let result = try await ProcessRunSupport.runLogged(
|
||||||
|
setRunning: { running.append($0) },
|
||||||
|
resetLog: { resets += 1 },
|
||||||
|
onLog: { batch in
|
||||||
|
MainActor.assertIsolated()
|
||||||
|
received.append(contentsOf: batch)
|
||||||
|
}
|
||||||
|
) { onLog in
|
||||||
|
onLog(["alpha", "beta"])
|
||||||
|
return 42
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(result == 42)
|
||||||
|
#expect(running == [true, false])
|
||||||
|
#expect(resets == 1)
|
||||||
|
|
||||||
|
// The sink hops back through a main-actor Task; yield until the
|
||||||
|
// coalesced batch lands.
|
||||||
|
for _ in 0..<200 where received.isEmpty {
|
||||||
|
try await Task.sleep(for: .milliseconds(10))
|
||||||
|
}
|
||||||
|
#expect(received == ["alpha", "beta"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Failure: running still transitions [true, false], log resets once, error is rethrown")
|
||||||
|
func failureTransitions() async throws {
|
||||||
|
var running: [Bool] = []
|
||||||
|
var resets = 0
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try await ProcessRunSupport.runLogged(
|
||||||
|
setRunning: { running.append($0) },
|
||||||
|
resetLog: { resets += 1 },
|
||||||
|
onLog: { _ in }
|
||||||
|
) { _ -> Int in
|
||||||
|
throw SentinelError()
|
||||||
|
}
|
||||||
|
Issue.record("Expected runLogged to rethrow")
|
||||||
|
} catch is SentinelError {
|
||||||
|
// Expected path.
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(running == [true, false])
|
||||||
|
#expect(resets == 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -93,6 +93,28 @@ struct SettingsStoreTests {
|
|||||||
#expect(!FileManager.default.fileExists(atPath: url.path))
|
#expect(!FileManager.default.fileExists(atPath: url.path))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func invalidSaveOverValidFilePreservesBytesAndPostsNothing() throws {
|
||||||
|
let url = tempStoreURL()
|
||||||
|
let store = SettingsStore(fileURL: url)
|
||||||
|
var valid = AppSettings.default
|
||||||
|
valid.deltaEGoodMax = 1.5
|
||||||
|
try store.save(valid)
|
||||||
|
let originalBytes = try Data(contentsOf: url)
|
||||||
|
|
||||||
|
var fired = false
|
||||||
|
let token = NotificationCenter.default.addObserver(
|
||||||
|
forName: SettingsStore.settingsDidChange, object: nil, queue: nil
|
||||||
|
) { _ in fired = true }
|
||||||
|
defer { NotificationCenter.default.removeObserver(token) }
|
||||||
|
|
||||||
|
var invalid = AppSettings.default
|
||||||
|
invalid.deltaEGoodMax = 9.0
|
||||||
|
#expect(throws: SettingsStore.SettingsError.self) { try store.save(invalid) }
|
||||||
|
#expect(try Data(contentsOf: url) == originalBytes)
|
||||||
|
#expect(!fired)
|
||||||
|
#expect(store.load() == valid)
|
||||||
|
}
|
||||||
|
|
||||||
@Test func savePostsNotification() async throws {
|
@Test func savePostsNotification() async throws {
|
||||||
let url = tempStoreURL()
|
let url = tempStoreURL()
|
||||||
let store = SettingsStore(fileURL: url)
|
let store = SettingsStore(fileURL: url)
|
||||||
|
|||||||
@@ -162,6 +162,34 @@ struct TargenArgsTests {
|
|||||||
#expect(!args.contains("-p"))
|
#expect(!args.contains("-p"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("Whitespace-only preconditioning profile emits no -c")
|
||||||
|
func whitespacePreconditioner() throws {
|
||||||
|
let config = TargenConfig(
|
||||||
|
colourSpace: .rgb,
|
||||||
|
patchCount: 800,
|
||||||
|
whitePatches: 4,
|
||||||
|
blackPatches: 4,
|
||||||
|
preconditioningProfile: " \n\t ",
|
||||||
|
basename: "ws_pre"
|
||||||
|
)
|
||||||
|
let args = try TargenArgs.build(config: config)
|
||||||
|
#expect(!args.contains("-c"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Preconditioning profile is trimmed before emission")
|
||||||
|
func preconditionerTrimmed() throws {
|
||||||
|
let config = TargenConfig(
|
||||||
|
colourSpace: .rgb,
|
||||||
|
patchCount: 800,
|
||||||
|
whitePatches: 4,
|
||||||
|
blackPatches: 4,
|
||||||
|
preconditioningProfile: " /path/to/profile.icc ",
|
||||||
|
basename: "trim_pre"
|
||||||
|
)
|
||||||
|
let args = try TargenArgs.build(config: config)
|
||||||
|
#expect(args[args.firstIndex(of: "-c")! + 1] == "/path/to/profile.icc")
|
||||||
|
}
|
||||||
|
|
||||||
@Test("Invalid basename throws")
|
@Test("Invalid basename throws")
|
||||||
func invalidBasenameThrows() {
|
func invalidBasenameThrows() {
|
||||||
let config = TargenConfig(
|
let config = TargenConfig(
|
||||||
@@ -262,7 +290,7 @@ struct ArgyllRunnerTargenTests {
|
|||||||
#expect(ti1URL.lastPathComponent == "mock_test.ti1")
|
#expect(ti1URL.lastPathComponent == "mock_test.ti1")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Failed targen execution throws processFailed")
|
@Test("Failed targen execution throws toolFailed")
|
||||||
func failedTargenExecution() 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)
|
||||||
@@ -290,7 +318,8 @@ struct ArgyllRunnerTargenTests {
|
|||||||
workingDirectory: tempDir
|
workingDirectory: tempDir
|
||||||
)
|
)
|
||||||
|
|
||||||
await #expect(throws: ArgyllRunnerError.self) {
|
await #expect(throws: ArgyllRunnerError.toolFailed(
|
||||||
|
tool: "targen", code: 1, logs: ["Error: something went wrong"])) {
|
||||||
try await runner.runTargen(config: config)
|
try await runner.runTargen(config: config)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -323,7 +352,8 @@ struct ArgyllRunnerTargenTests {
|
|||||||
workingDirectory: tempDir
|
workingDirectory: tempDir
|
||||||
)
|
)
|
||||||
|
|
||||||
await #expect(throws: ArgyllRunnerError.self) {
|
await #expect(throws: ArgyllRunnerError.missingArtefact(
|
||||||
|
tempDir.appendingPathComponent("no_file.ti1").path)) {
|
||||||
try await runner.runTargen(config: config)
|
try await runner.runTargen(config: config)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import ICCeryCore
|
||||||
|
@testable import ICCery
|
||||||
|
|
||||||
|
/// Dataset-import error contracts through the
|
||||||
|
/// `importMeasurementDataset(from:)` seam (issue #80): parser and I/O
|
||||||
|
/// failures must surface identically as a single `.error` Notice.
|
||||||
|
@Suite("TargetWorkflowViewModel dataset import")
|
||||||
|
@MainActor
|
||||||
|
struct TargetWorkflowViewModelTests {
|
||||||
|
|
||||||
|
@Test("Malformed content (CGATSParseError) produces one .error notice prefixed 'Import failed:'")
|
||||||
|
func malformedDatasetNotice() throws {
|
||||||
|
let env = try TestAppEnvironment.make()
|
||||||
|
defer { env.cleanup() }
|
||||||
|
let vm = TargetWorkflowViewModel(environment: env.environment)
|
||||||
|
|
||||||
|
let bad = env.root.appendingPathComponent("broken.ti3")
|
||||||
|
try Data("this is not CGATS data".utf8).write(to: bad)
|
||||||
|
|
||||||
|
vm.importMeasurementDataset(from: bad)
|
||||||
|
|
||||||
|
let notice = try #require(vm.wizard.notice)
|
||||||
|
#expect(notice.kind == .error)
|
||||||
|
#expect(notice.text.hasPrefix("Import failed:"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Missing file (CocoaError) produces one .error notice prefixed 'Import failed:'")
|
||||||
|
func missingDatasetNotice() throws {
|
||||||
|
let env = try TestAppEnvironment.make()
|
||||||
|
defer { env.cleanup() }
|
||||||
|
let vm = TargetWorkflowViewModel(environment: env.environment)
|
||||||
|
|
||||||
|
let missing = env.root.appendingPathComponent("does-not-exist.ti3")
|
||||||
|
vm.importMeasurementDataset(from: missing)
|
||||||
|
|
||||||
|
let notice = try #require(vm.wizard.notice)
|
||||||
|
#expect(notice.kind == .error)
|
||||||
|
#expect(notice.text.hasPrefix("Import failed:"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import ICCeryCore
|
||||||
|
@testable import ICCery
|
||||||
|
|
||||||
|
/// Shared app-test dependency factory (issue #82).
|
||||||
|
///
|
||||||
|
/// Every store is pointed at a unique temporary directory so tests never
|
||||||
|
/// read or write the user's real Application Support tree, and a fresh
|
||||||
|
/// `ProcessManager` keeps child-process state isolated per test. The
|
||||||
|
/// global process environment is never mutated.
|
||||||
|
struct TestAppEnvironment {
|
||||||
|
|
||||||
|
/// Root temp directory holding all per-test state files.
|
||||||
|
let root: URL
|
||||||
|
let environment: AppEnvironment
|
||||||
|
|
||||||
|
var settingsURL: URL { root.appendingPathComponent("settings.json") }
|
||||||
|
var stateURL: URL { root.appendingPathComponent("wizard_state.json") }
|
||||||
|
var historyURL: URL {
|
||||||
|
root.appendingPathComponent("verification_history.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates an isolated environment under `NSTemporaryDirectory()`.
|
||||||
|
/// Call `cleanup()` when finished.
|
||||||
|
static func make() throws -> TestAppEnvironment {
|
||||||
|
let root = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("iccery-test-env-\(UUID().uuidString)")
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: root, withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
|
||||||
|
let processManager = ProcessManager()
|
||||||
|
let settingsStore = SettingsStore(
|
||||||
|
fileURL: root.appendingPathComponent("settings.json")
|
||||||
|
)
|
||||||
|
let environment = AppEnvironment(
|
||||||
|
stateStore: WizardStateStore(
|
||||||
|
fileURL: root.appendingPathComponent("wizard_state.json")
|
||||||
|
),
|
||||||
|
settingsStore: settingsStore,
|
||||||
|
presetStore: PresetStore(settingsStore: settingsStore),
|
||||||
|
runner: ArgyllRunner(
|
||||||
|
processManager: processManager,
|
||||||
|
binaryResolver: BinaryResolver(overrideDir: nil)
|
||||||
|
),
|
||||||
|
cupsService: CupsService(
|
||||||
|
processManager: processManager,
|
||||||
|
binaryDir: root.appendingPathComponent("cups-bin")
|
||||||
|
),
|
||||||
|
historyStore: VerificationHistoryStore(
|
||||||
|
url: root.appendingPathComponent("verification_history.json")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return TestAppEnvironment(root: root, environment: environment)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes the temporary root directory.
|
||||||
|
func cleanup() {
|
||||||
|
try? FileManager.default.removeItem(at: root)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -131,6 +131,57 @@ struct VerificationHistoryStoreTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("Clear does not overwrite an unparseable file")
|
||||||
|
func clearPreservesUnparseableFile() async {
|
||||||
|
let fm = FileManager.default
|
||||||
|
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
|
try? fm.createDirectory(at: tmp, withIntermediateDirectories: true)
|
||||||
|
let url = tmp.appendingPathComponent("verification_history.json")
|
||||||
|
|
||||||
|
let badJSON = "not json"
|
||||||
|
try? badJSON.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
|
||||||
|
let store = VerificationHistoryStore(url: url)
|
||||||
|
do {
|
||||||
|
try await store.clear()
|
||||||
|
Issue.record("clear() should propagate the load error")
|
||||||
|
} catch {
|
||||||
|
let contents = try? String(contentsOf: url, encoding: .utf8)
|
||||||
|
#expect(contents == badJSON)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("ISO-8601 timestamps round-trip through a fresh store")
|
||||||
|
func iso8601RoundTrip() async throws {
|
||||||
|
let fm = FileManager.default
|
||||||
|
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
|
try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
|
||||||
|
let url = tmp.appendingPathComponent("verification_history.json")
|
||||||
|
|
||||||
|
let timestamp = Date(timeIntervalSince1970: 1_700_000_000)
|
||||||
|
let record = VerificationRecord(
|
||||||
|
id: "vr-iso",
|
||||||
|
profileName: "p",
|
||||||
|
printerName: "",
|
||||||
|
avgDE: 1.0,
|
||||||
|
maxDE: 2.0,
|
||||||
|
rmsDE: 1.5,
|
||||||
|
patchCount: 1,
|
||||||
|
status: .good,
|
||||||
|
timestamp: timestamp
|
||||||
|
)
|
||||||
|
let store1 = VerificationHistoryStore(url: url)
|
||||||
|
_ = try await store1.append(record)
|
||||||
|
|
||||||
|
let text = try String(contentsOf: url, encoding: .utf8)
|
||||||
|
#expect(text.contains(ISO8601DateFormatter().string(from: timestamp)))
|
||||||
|
|
||||||
|
let store2 = VerificationHistoryStore(url: url)
|
||||||
|
let loaded = try await store2.load()
|
||||||
|
#expect(loaded.count == 1)
|
||||||
|
#expect(loaded.first?.timestamp == timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
@Test("CSV export quoting")
|
@Test("CSV export quoting")
|
||||||
func csvQuoting() async throws {
|
func csvQuoting() async throws {
|
||||||
let fm = FileManager.default
|
let fm = FileManager.default
|
||||||
|
|||||||
@@ -117,6 +117,17 @@ struct WizardStateStoreTests {
|
|||||||
#expect(WizardStateStore(fileURL: url).load().stage == .generate)
|
#expect(WizardStateStore(fileURL: url).load().stage == .generate)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func corruptJsonReturnsDefaultAndKeepsBytes() throws {
|
||||||
|
let url = tempURL()
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
try "not json".write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
#expect(WizardStateStore(fileURL: url).load() == .default)
|
||||||
|
let kept = try String(contentsOf: url, encoding: .utf8)
|
||||||
|
#expect(kept == "not json")
|
||||||
|
}
|
||||||
|
|
||||||
@Test func sessionModeCalibrationRoundTrips() throws {
|
@Test func sessionModeCalibrationRoundTrips() throws {
|
||||||
var s = WizardState(sessionMode: .calibration)
|
var s = WizardState(sessionMode: .calibration)
|
||||||
let data = try JSONEncoder().encode(s)
|
let data = try JSONEncoder().encode(s)
|
||||||
|
|||||||
@@ -133,6 +133,18 @@ final class Milestone2UITests: XCTestCase {
|
|||||||
XCTAssertTrue(element("targenInkLimitGroup").waitForExistence(timeout: 5))
|
XCTAssertTrue(element("targenInkLimitGroup").waitForExistence(timeout: 5))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stage 1/2 process-log containers resolve under the shared
|
||||||
|
/// `ProcessLogView` identifiers (issue #80).
|
||||||
|
func testProcessLogContainersResolve() throws {
|
||||||
|
launchApp()
|
||||||
|
XCTAssertTrue(waitFor("targenLogContainer").exists)
|
||||||
|
|
||||||
|
app.buttons["btnBrowse"].click()
|
||||||
|
app.buttons["btnGenerate"].click()
|
||||||
|
XCTAssertTrue(waitFor("btnCreateLayout", timeout: 20).exists)
|
||||||
|
XCTAssertTrue(element("printtargLogContainer").exists)
|
||||||
|
}
|
||||||
|
|
||||||
/// Fixture-backed targen run creates .ti1 and unlocks Stage 2.
|
/// Fixture-backed targen run creates .ti1 and unlocks Stage 2.
|
||||||
func testTargenFixtureUnlocksStage2() throws {
|
func testTargenFixtureUnlocksStage2() throws {
|
||||||
launchApp()
|
launchApp()
|
||||||
|
|||||||
@@ -146,6 +146,8 @@ final class Milestone3UITests: XCTestCase {
|
|||||||
XCTAssertTrue(notice.waitForExistence(timeout: 10))
|
XCTAssertTrue(notice.waitForExistence(timeout: 10))
|
||||||
XCTAssertTrue((notice.value as? String ?? "")
|
XCTAssertTrue((notice.value as? String ?? "")
|
||||||
.contains("cancelled"))
|
.contains("cancelled"))
|
||||||
|
// Cancellation is informational, never an error (#80).
|
||||||
|
XCTAssertEqual(element("printNotificationIcon").value as? String, "info")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Preferences OK → captured options are replayed verbatim in the
|
/// Preferences OK → captured options are replayed verbatim in the
|
||||||
@@ -214,6 +216,8 @@ final class Milestone3UITests: XCTestCase {
|
|||||||
let notice = app.staticTexts.containing(predicate).firstMatch
|
let notice = app.staticTexts.containing(predicate).firstMatch
|
||||||
XCTAssertTrue(notice.waitForExistence(timeout: 10))
|
XCTAssertTrue(notice.waitForExistence(timeout: 10))
|
||||||
XCTAssertTrue(notice.label.contains("Print failed"))
|
XCTAssertTrue(notice.label.contains("Print failed"))
|
||||||
|
// Spool failure exposes the .error kind on the icon (#80).
|
||||||
|
XCTAssertEqual(element("printNotificationIcon").value as? String, "error")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// wizardState.printerName records the queue used for spooling (#95).
|
/// wizardState.printerName records the queue used for spooling (#95).
|
||||||
|
|||||||
@@ -140,4 +140,57 @@ final class Milestone4UITests: XCTestCase {
|
|||||||
}
|
}
|
||||||
XCTAssertTrue(FileManager.default.fileExists(atPath: ti3.path))
|
XCTAssertTrue(FileManager.default.fileExists(atPath: ti3.path))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Two passes + a failing `average` run promote pass 1 to the
|
||||||
|
/// canonical .ti3 and show the sticky finish error notice via
|
||||||
|
/// `chartreadFinishNotice` (issue #80).
|
||||||
|
func testTwoPassAverageFailurePromotesFirstPass() throws {
|
||||||
|
app.launchEnvironment["MOCK_AVERAGE_FAIL"] = "1"
|
||||||
|
reachStage3()
|
||||||
|
|
||||||
|
app.buttons["btnDetectInstruments"].click()
|
||||||
|
_ = waitFor("chartreadInstrumentSelect", timeout: 20)
|
||||||
|
|
||||||
|
driveOnePass(startButton: "btnStartRead")
|
||||||
|
_ = waitFor("chartreadAveragingPanel", timeout: 20)
|
||||||
|
|
||||||
|
driveOnePass(startButton: "btnMeasureAnotherSheet")
|
||||||
|
|
||||||
|
XCTAssertTrue(waitFor("btnFinishAndAverage", timeout: 20).exists)
|
||||||
|
app.buttons["btnFinishAndAverage"].click()
|
||||||
|
|
||||||
|
// Averaging failed → pass 1 is promoted to the canonical .ti3
|
||||||
|
// and the sticky error notice stays on Stage 3.
|
||||||
|
let ti3 = workDir.appendingPathComponent("mytarget.ti3")
|
||||||
|
let deadline = Date().addingTimeInterval(20)
|
||||||
|
while Date() < deadline, !FileManager.default.fileExists(atPath: ti3.path) {
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
|
||||||
|
}
|
||||||
|
XCTAssertTrue(FileManager.default.fileExists(atPath: ti3.path))
|
||||||
|
|
||||||
|
let notice = element("chartreadFinishNotice")
|
||||||
|
XCTAssertTrue(notice.waitForExistence(timeout: 10))
|
||||||
|
XCTAssertEqual(notice.value as? String, "error")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs the mock handheld chartread session to completion
|
||||||
|
/// (start → calibrate → strip A → strip B → Done & Save).
|
||||||
|
private func driveOnePass(startButton: String) {
|
||||||
|
let start = app.buttons[startButton]
|
||||||
|
XCTAssertTrue(start.waitForExistence(timeout: 10))
|
||||||
|
let deadline = Date().addingTimeInterval(10)
|
||||||
|
while Date() < deadline, !start.isEnabled {
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
|
}
|
||||||
|
XCTAssertTrue(start.isEnabled)
|
||||||
|
start.click()
|
||||||
|
_ = waitFor("btnCalibrate", timeout: 25)
|
||||||
|
app.buttons["btnCalibrate"].click()
|
||||||
|
_ = waitFor("btnTrigger", timeout: 20)
|
||||||
|
app.buttons["btnTrigger"].click()
|
||||||
|
_ = waitFor("btnTrigger", timeout: 20)
|
||||||
|
app.buttons["btnTrigger"].click()
|
||||||
|
_ = waitFor("btnDoneRead", timeout: 20)
|
||||||
|
app.buttons["btnDoneRead"].firstMatch.click()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,4 +111,21 @@ final class Milestone5UITests: XCTestCase {
|
|||||||
"Expected verification status, got '\(statusValue)'"
|
"Expected verification status, got '\(statusValue)'"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A failing colprof run surfaces through the session-wide wizard
|
||||||
|
/// notice only — no duplicate stage-local error view (issue #80).
|
||||||
|
func testProfileFailureShowsWizardNotice() throws {
|
||||||
|
app.launchEnvironment["ICCERY_MOCK_COLPROF_EXIT"] = "2"
|
||||||
|
launchApp()
|
||||||
|
|
||||||
|
let create = waitFor("btnCreateProfile")
|
||||||
|
XCTAssertTrue(create.isEnabled)
|
||||||
|
create.click()
|
||||||
|
|
||||||
|
let notice = element("noticeText")
|
||||||
|
XCTAssertTrue(notice.waitForExistence(timeout: 20))
|
||||||
|
XCTAssertTrue((notice.value as? String ?? "")
|
||||||
|
.contains("Profile creation failed"))
|
||||||
|
XCTAssertFalse(element("colprofLastError").exists)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ final class Milestone6CalibrationUITests: XCTestCase {
|
|||||||
"ICCERY_TEST_WORKDIR": testWorkDir.path
|
"ICCERY_TEST_WORKDIR": testWorkDir.path
|
||||||
]
|
]
|
||||||
app.launch()
|
app.launch()
|
||||||
|
app.activate()
|
||||||
}
|
}
|
||||||
|
|
||||||
override func tearDown() async throws {
|
override func tearDown() async throws {
|
||||||
@@ -77,4 +78,60 @@ final class Milestone6CalibrationUITests: XCTestCase {
|
|||||||
let layout = app.buttons["btnCreateLayout"]
|
let layout = app.buttons["btnCreateLayout"]
|
||||||
XCTAssertTrue(layout.waitForExistence(timeout: 25))
|
XCTAssertTrue(layout.waitForExistence(timeout: 25))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A failing calibration targen surfaces the error through the
|
||||||
|
/// wizard notice and restores the original basename (issue #80).
|
||||||
|
func testCalibrationTargenFailureRestoresBasename() throws {
|
||||||
|
let testRoot = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("cal-fail-\(UUID().uuidString)")
|
||||||
|
let appData = testRoot.appendingPathComponent("AppData")
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: appData, withIntermediateDirectories: true)
|
||||||
|
defer { try? FileManager.default.removeItem(at: testRoot) }
|
||||||
|
|
||||||
|
// Pre-stage wizard state so the failing mock targen is only
|
||||||
|
// exercised by the calibration run, not target generation.
|
||||||
|
let state: [String: Any] = [
|
||||||
|
"currentStage": 1,
|
||||||
|
"basename": "DemoTarget",
|
||||||
|
"cwd": testWorkDir.path,
|
||||||
|
"sessionMode": "profile",
|
||||||
|
"calibrationOriginalBasename": ""
|
||||||
|
]
|
||||||
|
let stateURL = appData.appendingPathComponent("wizard_state.json")
|
||||||
|
try JSONSerialization.data(withJSONObject: state).write(to: stateURL)
|
||||||
|
|
||||||
|
app.terminate()
|
||||||
|
app.launchEnvironment["ICCERY_TEST_ROOT"] = testRoot.path
|
||||||
|
app.launchEnvironment["ICCERY_MOCK_TARGEN_EXIT"] = "2"
|
||||||
|
app.launch()
|
||||||
|
app.activate()
|
||||||
|
|
||||||
|
let calButton = app.buttons["btnCalibratePrinter"]
|
||||||
|
XCTAssertTrue(calButton.waitForExistence(timeout: 10))
|
||||||
|
calButton.tap()
|
||||||
|
|
||||||
|
let calGenerate = app.buttons["btnCalGenerate"]
|
||||||
|
XCTAssertTrue(calGenerate.waitForExistence(timeout: 10))
|
||||||
|
calGenerate.tap()
|
||||||
|
|
||||||
|
let notice = app.descendants(matching: .any)["noticeText"]
|
||||||
|
XCTAssertTrue(notice.waitForExistence(timeout: 20))
|
||||||
|
XCTAssertTrue((notice.value as? String ?? "")
|
||||||
|
.contains("Calibration target failed"))
|
||||||
|
|
||||||
|
// The pre-CAL_ basename is restored and persisted.
|
||||||
|
let deadline = Date().addingTimeInterval(10)
|
||||||
|
var restoredBasename: String?
|
||||||
|
while Date() < deadline {
|
||||||
|
if let data = try? Data(contentsOf: stateURL),
|
||||||
|
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
let basename = object["basename"] as? String {
|
||||||
|
restoredBasename = basename
|
||||||
|
if basename == "DemoTarget" { break }
|
||||||
|
}
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
|
}
|
||||||
|
XCTAssertEqual(restoredBasename, "DemoTarget")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+103
@@ -0,0 +1,103 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# scripts/attach-release-asset.sh
|
||||||
|
#
|
||||||
|
# Attach ICCery-*.dmg to the Gitea release for the current tag.
|
||||||
|
# actions/upload-artifact only stores a workflow artifact; it does not
|
||||||
|
# publish a release asset (run 29714 left v2.0.0-pre2-grok with no files).
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT="$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
TOKEN="${GITEA_TOKEN:-${GITHUB_TOKEN:-}}"
|
||||||
|
if [ -z "$TOKEN" ]; then
|
||||||
|
echo "error: GITEA_TOKEN or GITHUB_TOKEN is required to attach release assets" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SERVER="${GITEA_SERVER_URL:-${GITHUB_SERVER_URL:-https://git.i3omb.com}}"
|
||||||
|
SERVER="${SERVER%/}"
|
||||||
|
API="$SERVER/api/v1"
|
||||||
|
|
||||||
|
REPO="${GITHUB_REPOSITORY:-gronod/iccery-v2-mac}"
|
||||||
|
TAG="${RELEASE_TAG:-${GITHUB_REF_NAME:-}}"
|
||||||
|
if [ -z "$TAG" ] && [ -n "${GITHUB_REF:-}" ]; then
|
||||||
|
TAG="${GITHUB_REF#refs/tags/}"
|
||||||
|
fi
|
||||||
|
if [ -z "$TAG" ] || [ "$TAG" = "${GITHUB_REF:-}" ]; then
|
||||||
|
echo "error: no release tag (set RELEASE_TAG or GITHUB_REF_NAME)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
DMG="${1:-}"
|
||||||
|
if [ -z "$DMG" ]; then
|
||||||
|
DMG="$(ls -1 ICCery-*.dmg 2>/dev/null | head -n 1 || true)"
|
||||||
|
fi
|
||||||
|
if [ -z "$DMG" ] || [ ! -f "$DMG" ]; then
|
||||||
|
echo "error: no ICCery-*.dmg to attach" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
NAME="$(basename "$DMG")"
|
||||||
|
|
||||||
|
echo "==> Resolving release $TAG"
|
||||||
|
HTTP="$(mktemp)"
|
||||||
|
BODY="$(mktemp)"
|
||||||
|
STATUS="$(curl -sS -o "$BODY" -w '%{http_code}' \
|
||||||
|
-H "Authorization: token $TOKEN" \
|
||||||
|
-H "Accept: application/json" \
|
||||||
|
"$API/repos/$REPO/releases/tags/$TAG" || true)"
|
||||||
|
|
||||||
|
if [ "$STATUS" = "404" ]; then
|
||||||
|
echo "==> Creating release $TAG"
|
||||||
|
STATUS="$(curl -sS -o "$BODY" -w '%{http_code}' \
|
||||||
|
-H "Authorization: token $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-X POST "$API/repos/$REPO/releases" \
|
||||||
|
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"prerelease\":true,\"target_commitish\":\"${GITHUB_SHA:-}\"}")"
|
||||||
|
fi
|
||||||
|
if [ "$STATUS" != "200" ] && [ "$STATUS" != "201" ]; then
|
||||||
|
echo "error: could not load/create release $TAG (HTTP $STATUS)" >&2
|
||||||
|
cat "$BODY" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
RELEASE_ID="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("id",""))' "$BODY")"
|
||||||
|
if [ -z "$RELEASE_ID" ]; then
|
||||||
|
echo "error: release JSON missing id" >&2
|
||||||
|
cat "$BODY" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Replace a same-named asset so retags stay idempotent.
|
||||||
|
python3 - "$BODY" "$NAME" > "$HTTP" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
rel = json.load(open(sys.argv[1]))
|
||||||
|
want = sys.argv[2]
|
||||||
|
for a in rel.get("assets") or []:
|
||||||
|
if a.get("name") == want:
|
||||||
|
print(a.get("id", ""))
|
||||||
|
break
|
||||||
|
PY
|
||||||
|
EXISTING="$(cat "$HTTP")"
|
||||||
|
if [ -n "$EXISTING" ]; then
|
||||||
|
echo "==> Replacing existing asset $NAME ($EXISTING)"
|
||||||
|
curl -sS -o /dev/null -w '%{http_code}\n' \
|
||||||
|
-H "Authorization: token $TOKEN" \
|
||||||
|
-X DELETE "$API/repos/$REPO/releases/$RELEASE_ID/assets/$EXISTING" >/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Uploading $NAME to release $RELEASE_ID"
|
||||||
|
STATUS="$(curl -sS -o "$BODY" -w '%{http_code}' \
|
||||||
|
-H "Authorization: token $TOKEN" \
|
||||||
|
-H "Accept: application/json" \
|
||||||
|
-F "attachment=@$DMG;filename=$NAME" \
|
||||||
|
"$API/repos/$REPO/releases/$RELEASE_ID/assets?name=$NAME")"
|
||||||
|
|
||||||
|
if [ "$STATUS" != "201" ]; then
|
||||||
|
echo "error: asset upload failed (HTTP $STATUS)" >&2
|
||||||
|
cat "$BODY" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Attached $NAME to $SERVER/$REPO/releases/tag/$TAG"
|
||||||
|
rm -f "$HTTP" "$BODY"
|
||||||
Reference in New Issue
Block a user