Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa9a2042bf |
@@ -14,21 +14,13 @@ jobs:
|
|||||||
build-and-test:
|
build-and-test:
|
||||||
# Prefer a self-hosted Mac runner if your Gitea has one. If not,
|
# Prefer a self-hosted Mac runner if your Gitea has one. If not,
|
||||||
# macos-14 works for this pipeline.
|
# macos-14 works for this pipeline.
|
||||||
runs-on: macos-12
|
runs-on: macos-14
|
||||||
env:
|
env:
|
||||||
DERIVED: build/DerivedData-test
|
DERIVED: build/DerivedData-test
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Assert Xcode 14 toolchain
|
|
||||||
run: xcodebuild -version | grep -E "Xcode 14." || (echo "Unexpected Xcode version" && exit 1)
|
|
||||||
|
|
||||||
- name: Ensure host tools
|
|
||||||
run: |
|
|
||||||
command -v xcodegen || brew install xcodegen
|
|
||||||
python3 -c "import dmgbuild" 2>/dev/null || pip3 install dmgbuild
|
|
||||||
|
|
||||||
- name: Generate Xcode project
|
- name: Generate Xcode project
|
||||||
run: xcodegen generate --spec project.yml
|
run: xcodegen generate --spec project.yml
|
||||||
|
|
||||||
@@ -128,7 +120,7 @@ jobs:
|
|||||||
|
|
||||||
package:
|
package:
|
||||||
needs: build-and-test
|
needs: build-and-test
|
||||||
runs-on: macos-12
|
runs-on: macos-14
|
||||||
if: github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/tags/v')
|
if: github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/tags/v')
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
|
|||||||
@@ -27,5 +27,4 @@ ICCery.xcodeproj/
|
|||||||
Release/
|
Release/
|
||||||
notarization/
|
notarization/
|
||||||
build/
|
build/
|
||||||
docs/megaplans/*
|
|
||||||
docs/megaplans
|
docs/megaplans
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
// swift-tools-version: 5.7
|
// swift-tools-version: 6.0
|
||||||
import PackageDescription
|
import PackageDescription
|
||||||
|
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "ICCeryCore",
|
name: "ICCeryCore",
|
||||||
platforms: [.macOS(.v12)],
|
platforms: [.macOS(.v14)],
|
||||||
products: [
|
products: [
|
||||||
.library(name: "ICCeryCore", targets: ["ICCeryCore"]),
|
.library(name: "ICCeryCore", targets: ["ICCeryCore"]),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ public struct ArgyllRunner: Sendable {
|
|||||||
await processManager.kill(id: id)
|
await processManager.kill(id: id)
|
||||||
var attempts = 0
|
var attempts = 0
|
||||||
while await processManager.isRunning(id), attempts < 30 {
|
while await processManager.isRunning(id), attempts < 30 {
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
try? await Task.sleep(for: .milliseconds(100))
|
||||||
attempts += 1
|
attempts += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -243,7 +243,7 @@ public struct ArgyllRunner: Sendable {
|
|||||||
if flushPartialLines {
|
if flushPartialLines {
|
||||||
dotFlushTask = Task { [processManager] in
|
dotFlushTask = Task { [processManager] in
|
||||||
while !Task.isCancelled {
|
while !Task.isCancelled {
|
||||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
try? await Task.sleep(for: .milliseconds(500))
|
||||||
if Task.isCancelled { break }
|
if Task.isCancelled { break }
|
||||||
await processManager.flushPartialLine(id: processId)
|
await processManager.flushPartialLine(id: processId)
|
||||||
}
|
}
|
||||||
@@ -592,7 +592,7 @@ public struct ArgyllRunner: Sendable {
|
|||||||
await processManager.setPreKillHook(id: processId) { [processManager] in
|
await processManager.setPreKillHook(id: processId) { [processManager] in
|
||||||
if isXY {
|
if isXY {
|
||||||
try? await processManager.sendStdin(id: processId, bytes: ChartreadInput.quit.bytes)
|
try? await processManager.sendStdin(id: processId, bytes: ChartreadInput.quit.bytes)
|
||||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
try? await Task.sleep(for: .milliseconds(500))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,19 +56,23 @@ public enum PrinttargArgs {
|
|||||||
}
|
}
|
||||||
args.append(contentsOf: ["-R", "\(config.customSeed)"])
|
args.append(contentsOf: ["-R", "\(config.customSeed)"])
|
||||||
case .raster:
|
case .raster:
|
||||||
args.append(contentsOf: ArgsBuilder.flag("-r", when: true))
|
args.append("-r")
|
||||||
}
|
}
|
||||||
|
|
||||||
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-d", config.label))
|
if let label = config.label?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!label.isEmpty {
|
||||||
|
args.append(contentsOf: ["-d", label])
|
||||||
|
}
|
||||||
|
|
||||||
guard (72...600).contains(config.dpi) else {
|
guard (72...600).contains(config.dpi) else {
|
||||||
throw PrinttargArgError.invalidDPI(config.dpi)
|
throw PrinttargArgError.invalidDPI(config.dpi)
|
||||||
}
|
}
|
||||||
args.append(contentsOf: [config.bitDepth.flag, "\(config.dpi)"])
|
args.append(contentsOf: [config.bitDepth.flag, "\(config.dpi)"])
|
||||||
|
|
||||||
if !CalibrationIdentity.isCalibration(cleanBasename) {
|
if !CalibrationIdentity.isCalibration(cleanBasename),
|
||||||
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty(
|
let cal = config.calibrationFile?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
config.calibrationEmbedOnly ? "-I" : "-K", config.calibrationFile))
|
!cal.isEmpty {
|
||||||
|
args.append(contentsOf: [config.calibrationEmbedOnly ? "-I" : "-K", cal])
|
||||||
}
|
}
|
||||||
|
|
||||||
args.append(cleanBasename)
|
args.append(cleanBasename)
|
||||||
|
|||||||
@@ -70,12 +70,18 @@ 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)"])
|
||||||
}
|
}
|
||||||
args.append(contentsOf: ArgsBuilder.optionUnlessApprox("-N", config.neutralConcentration, skip: 0.50))
|
if let nConc = config.neutralConcentration, abs(nConc - 0.50) >= 0.001 {
|
||||||
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-c", config.preconditioningProfile))
|
args.append(contentsOf: ["-N", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), nConc)])
|
||||||
args.append(contentsOf: ArgsBuilder.flag("-G", when: config.ofpsHighQuality == true))
|
}
|
||||||
args.append(contentsOf: ArgsBuilder.option("-A", config.ofpsAdaptation.map {
|
if let c = config.preconditioningProfile?.trimmingCharacters(in: .whitespacesAndNewlines), !c.isEmpty {
|
||||||
String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), $0)
|
args.append(contentsOf: ["-c", c])
|
||||||
}))
|
}
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
@@ -85,9 +91,11 @@ public enum TargenArgs {
|
|||||||
}
|
}
|
||||||
args.append(contentsOf: ["-l", "\(inkLimit)"])
|
args.append(contentsOf: ["-l", "\(inkLimit)"])
|
||||||
}
|
}
|
||||||
args.append(contentsOf: ArgsBuilder.optionUnlessApprox("-V", config.darkEmphasis, skip: 1.0))
|
if let v = config.darkEmphasis, abs(v - 1.0) >= 0.001 {
|
||||||
if let p = config.devicePower, p > 0 {
|
args.append(contentsOf: ["-V", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), v)])
|
||||||
args.append(contentsOf: ArgsBuilder.optionUnlessApprox("-p", p, skip: 1.0))
|
}
|
||||||
|
if let p = config.devicePower, p > 0, abs(p - 1.0) >= 0.001 {
|
||||||
|
args.append(contentsOf: ["-p", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), p)])
|
||||||
}
|
}
|
||||||
|
|
||||||
args.append(cleanBasename)
|
args.append(cleanBasename)
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ public enum CGATSParser {
|
|||||||
public static func parse(
|
public static func parse(
|
||||||
_ contents: String,
|
_ contents: String,
|
||||||
sourceURL: URL? = nil
|
sourceURL: URL? = nil
|
||||||
) throws -> CGATSDataset {
|
) throws(CGATSParseError) -> CGATSDataset {
|
||||||
guard !contents.isEmpty else { throw .emptyFile }
|
guard !contents.isEmpty else { throw .emptyFile }
|
||||||
|
|
||||||
let ext = sourceURL?.pathExtension.lowercased() ?? ""
|
let ext = sourceURL?.pathExtension.lowercased() ?? ""
|
||||||
@@ -213,7 +213,7 @@ public enum CGATSParser {
|
|||||||
private static func preprocess(
|
private static func preprocess(
|
||||||
_ contents: String,
|
_ contents: String,
|
||||||
isCSV: Bool
|
isCSV: Bool
|
||||||
) throws -> (CGATSFormat, [String]) {
|
) throws(CGATSParseError) -> (CGATSFormat, [String]) {
|
||||||
let allLines = contents.components(separatedBy: .newlines)
|
let allLines = contents.components(separatedBy: .newlines)
|
||||||
var lines = [String]()
|
var lines = [String]()
|
||||||
|
|
||||||
|
|||||||
@@ -559,7 +559,7 @@ public actor ProcessManager {
|
|||||||
// Start a watchdog in case the `readabilityHandler` EOFs never
|
// Start a watchdog in case the `readabilityHandler` EOFs never
|
||||||
// arrive after the process exits (e.g. a hung pipe).
|
// arrive after the process exits (e.g. a hung pipe).
|
||||||
child.finalizeTask = Task { [weak self] in
|
child.finalizeTask = Task { [weak self] in
|
||||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
try? await Task.sleep(for: .seconds(2))
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
await self.forceKill(id: id)
|
await self.forceKill(id: id)
|
||||||
await self.forceFinalize(id: id)
|
await self.forceFinalize(id: id)
|
||||||
|
|||||||
@@ -79,9 +79,16 @@ public enum PrintcalArgs {
|
|||||||
|
|
||||||
var args: [String] = ["-v", "-e"]
|
var args: [String] = ["-v", "-e"]
|
||||||
|
|
||||||
args.append(contentsOf: ArgsBuilder.flag("-I", when: config.noInkLimit))
|
if config.noInkLimit {
|
||||||
args.append(contentsOf: ArgsBuilder.flag("-z", when: config.verify))
|
args.append("-I")
|
||||||
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 {
|
||||||
|
|||||||
@@ -95,6 +95,13 @@ 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,6 +25,7 @@ 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
|
||||||
@@ -76,6 +77,10 @@ final class CalibrationViewModel {
|
|||||||
wizard.basename = identity.calibrationBasename
|
wizard.basename = identity.calibrationBasename
|
||||||
wizard.sessionMode = .calibration
|
wizard.sessionMode = .calibration
|
||||||
|
|
||||||
|
isGenerating = true
|
||||||
|
calibrationLog = []
|
||||||
|
lastError = nil
|
||||||
|
|
||||||
let config = CalibrationTargenConfig(
|
let config = CalibrationTargenConfig(
|
||||||
colourSpace: colourSpace,
|
colourSpace: colourSpace,
|
||||||
steps: steps,
|
steps: steps,
|
||||||
@@ -87,19 +92,17 @@ final class CalibrationViewModel {
|
|||||||
)
|
)
|
||||||
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
|
defer { self.isGenerating = false }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
_ = try await ProcessRunSupport.runLogged(
|
_ = try await self.environment.runner.runCalibrationTargen(config: config, onLogBatch: ProcessRunSupport.logSink { [weak self] batch 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
|
||||||
@@ -134,13 +137,15 @@ 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) {
|
||||||
wizard.showNotice(
|
lastError = "\(outputURL.lastPathComponent) already exists. Rename or overwrite it first."
|
||||||
"\(outputURL.lastPathComponent) already exists. Rename or overwrite it first.",
|
wizard.showNotice(lastError!, kind: .error)
|
||||||
kind: .error
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isComputing = true
|
||||||
|
calibrationLog = []
|
||||||
|
lastError = nil
|
||||||
|
|
||||||
let config = PrintcalConfig(
|
let config = PrintcalConfig(
|
||||||
ti3Basename: calBasename,
|
ti3Basename: calBasename,
|
||||||
workingDirectory: cwd,
|
workingDirectory: cwd,
|
||||||
@@ -153,21 +158,19 @@ final class CalibrationViewModel {
|
|||||||
)
|
)
|
||||||
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
|
defer { self.isComputing = false }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let url = try await ProcessRunSupport.runLogged(
|
let url = try await self.environment.runner.runPrintcal(config: config, onLogBatch: ProcessRunSupport.logSink { [weak self] batch 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
|
||||||
self.profile.applyCalibration = self.applyToProfile
|
self.profile.applyCalibration = self.applyToProfile
|
||||||
self.wizard.showNotice("Calibration curves computed.")
|
self.wizard.showNotice("Calibration curves computed.")
|
||||||
self.wizard.restoreCalibration()
|
self.wizard.restoreCalibration()
|
||||||
} catch {
|
} catch {
|
||||||
|
self.lastError = error.localizedDescription
|
||||||
self.wizard.showNotice(
|
self.wizard.showNotice(
|
||||||
"Calibration curve computation failed: \(error.localizedDescription)",
|
"Calibration curve computation failed: \(error.localizedDescription)",
|
||||||
kind: .error
|
kind: .error
|
||||||
|
|||||||
@@ -63,8 +63,7 @@ final class MeasurementWorkflowViewModel {
|
|||||||
var rows: [ChartreadRow] = []
|
var rows: [ChartreadRow] = []
|
||||||
var swatchRows: [SwatchRow] = []
|
var swatchRows: [SwatchRow] = []
|
||||||
var showRemoveSheetNotice = false
|
var showRemoveSheetNotice = false
|
||||||
/// Stage-local chartread error notice (`#chartreadLastError`, #80).
|
var lastError: String?
|
||||||
var chartreadNotice: Notice?
|
|
||||||
private var chartreadTask: Task<Void, Never>?
|
private var chartreadTask: Task<Void, Never>?
|
||||||
|
|
||||||
// MARK: - Averaging
|
// MARK: - Averaging
|
||||||
@@ -186,7 +185,7 @@ final class MeasurementWorkflowViewModel {
|
|||||||
isChartreadRunning = true
|
isChartreadRunning = true
|
||||||
chartreadState = .idle
|
chartreadState = .idle
|
||||||
currentPrompt = nil
|
currentPrompt = nil
|
||||||
chartreadNotice = nil
|
lastError = nil
|
||||||
chartreadLog.removeAll()
|
chartreadLog.removeAll()
|
||||||
|
|
||||||
// Optional: reset rows when starting a fresh first pass.
|
// Optional: reset rows when starting a fresh first pass.
|
||||||
@@ -228,10 +227,7 @@ final class MeasurementWorkflowViewModel {
|
|||||||
|
|
||||||
case .exit(let code):
|
case .exit(let code):
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
chartreadNotice = Notice(
|
lastError = "chartread exited with code \(code)"
|
||||||
kind: .error,
|
|
||||||
text: "chartread exited with code \(code)"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case .completed(let canonicalURL):
|
case .completed(let canonicalURL):
|
||||||
@@ -239,7 +235,7 @@ final class MeasurementWorkflowViewModel {
|
|||||||
completePass(canonicalURL: canonicalURL)
|
completePass(canonicalURL: canonicalURL)
|
||||||
|
|
||||||
case .failed(let error):
|
case .failed(let error):
|
||||||
chartreadNotice = Notice(kind: .error, text: error.localizedDescription)
|
lastError = error.localizedDescription
|
||||||
chartreadState = .error
|
chartreadState = .error
|
||||||
isChartreadRunning = false
|
isChartreadRunning = false
|
||||||
}
|
}
|
||||||
@@ -385,10 +381,7 @@ final class MeasurementWorkflowViewModel {
|
|||||||
discoverPassSnapshots()
|
discoverPassSnapshots()
|
||||||
wizard.refreshGating()
|
wizard.refreshGating()
|
||||||
} catch {
|
} catch {
|
||||||
chartreadNotice = Notice(
|
lastError = "Could not snapshot pass: \(error.localizedDescription)"
|
||||||
kind: .error,
|
|
||||||
text: "Could not snapshot pass: \(error.localizedDescription)"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,32 +397,30 @@ final class MeasurementWorkflowViewModel {
|
|||||||
|
|
||||||
func finishAndAverage() {
|
func finishAndAverage() {
|
||||||
guard !isFinishing, let cwd = workingDirectory, !passSnapshots.isEmpty else { return }
|
guard !isFinishing, let cwd = workingDirectory, !passSnapshots.isEmpty else { return }
|
||||||
|
isFinishing = true
|
||||||
finishNotice = nil
|
finishNotice = nil
|
||||||
|
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
do {
|
do {
|
||||||
// No log reset: prior chartread output must be preserved.
|
let canonical: URL
|
||||||
let canonical = try await ProcessRunSupport.runLogged(
|
if self.passSnapshots.count == 1, let pass = self.passSnapshots.first {
|
||||||
setRunning: { self.isFinishing = $0 },
|
canonical = try MeasurementArtefacts.promotePass(
|
||||||
resetLog: {},
|
pass: pass,
|
||||||
onLog: { self.chartreadLog.append(contentsOf: $0) }
|
basename: self.basename,
|
||||||
) { onLog in
|
cwd: cwd
|
||||||
if self.passSnapshots.count == 1, let pass = self.passSnapshots.first {
|
)
|
||||||
return try MeasurementArtefacts.promotePass(
|
} else {
|
||||||
pass: pass,
|
|
||||||
basename: self.basename,
|
|
||||||
cwd: cwd
|
|
||||||
)
|
|
||||||
}
|
|
||||||
let config = AverageConfig(
|
let config = AverageConfig(
|
||||||
workingDirectory: cwd,
|
workingDirectory: cwd,
|
||||||
basename: self.basename,
|
basename: self.basename,
|
||||||
passFiles: self.passSnapshots
|
passFiles: self.passSnapshots
|
||||||
)
|
)
|
||||||
return try await self.environment.runner.runAverage(
|
canonical = try await self.environment.runner.runAverage(
|
||||||
config: config,
|
config: config,
|
||||||
onLogBatch: onLog
|
onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
|
||||||
|
self?.chartreadLog.append(contentsOf: batch)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
self.discoverPassSnapshots()
|
self.discoverPassSnapshots()
|
||||||
@@ -474,6 +465,7 @@ final class MeasurementWorkflowViewModel {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
self.isFinishing = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,14 +21,6 @@ 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()
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ 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?
|
||||||
@@ -164,59 +165,60 @@ 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 outcome = try await ProcessRunSupport.runLogged(
|
let url = try await runner.runColprof(config: config, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
|
||||||
setRunning: { self.isColprofRunning = $0 },
|
guard let self else { return }
|
||||||
resetLog: { self.colprofLog = [] },
|
self.colprofLog.append(contentsOf: batch)
|
||||||
onLog: { batch in
|
if let last = batch.last {
|
||||||
self.colprofLog.append(contentsOf: batch)
|
self.updateProgress(ColprofProgressClassifier.classify(line: last))
|
||||||
if let last = batch.last {
|
|
||||||
self.updateProgress(ColprofProgressClassifier.classify(line: last))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
) { onLog in
|
})
|
||||||
let url = try await runner.runColprof(config: config, onLogBatch: onLog)
|
|
||||||
|
|
||||||
var finalProfileURL = url
|
var finalProfileURL = url
|
||||||
|
|
||||||
if self.applyCalibration, !self.calibrationFile.isEmpty {
|
if self.applyCalibration, !self.calibrationFile.isEmpty {
|
||||||
let applyConfig = ApplycalConfig(
|
let applyConfig = ApplycalConfig(
|
||||||
calibrationPath: self.calibrationFile,
|
calibrationPath: self.calibrationFile,
|
||||||
inputProfileURL: url
|
inputProfileURL: url
|
||||||
)
|
)
|
||||||
assert(!applyConfig.unapply, "applycal unapply is not supported in v2.0")
|
assert(!applyConfig.unapply, "applycal unapply is not supported in v2.0")
|
||||||
finalProfileURL = try await runner.runApplycal(config: applyConfig)
|
finalProfileURL = try await runner.runApplycal(config: applyConfig)
|
||||||
self.colprofLog.append("Calibration embedded: \(self.calibrationFile)")
|
self.colprofLog.append("Calibration embedded: \(self.calibrationFile)")
|
||||||
}
|
|
||||||
|
|
||||||
// Gamut extraction is best-effort for Stage 5 / M6 viewer.
|
|
||||||
var gamutURL: URL?
|
|
||||||
do {
|
|
||||||
let gamConfig = IccgamutConfig(profileURL: finalProfileURL)
|
|
||||||
let url = try await runner.runIccgamut(config: gamConfig, onLogBatch: onLog)
|
|
||||||
gamutURL = url
|
|
||||||
self.colprofLog.append("Gamut mesh extracted: \(url.lastPathComponent)")
|
|
||||||
} catch {
|
|
||||||
self.wizard.showNotice(
|
|
||||||
"Gamut extraction skipped: \(error.localizedDescription)",
|
|
||||||
kind: .info
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (profileURL: finalProfileURL, gamutURL: gamutURL)
|
|
||||||
}
|
}
|
||||||
self.createdProfileURL = outcome.profileURL
|
|
||||||
self.createdGamutURL = outcome.gamutURL
|
// Gamut extraction is best-effort for Stage 5 / M6 viewer.
|
||||||
|
do {
|
||||||
|
let gamConfig = IccgamutConfig(profileURL: finalProfileURL)
|
||||||
|
let gamURL = try await runner.runIccgamut(config: gamConfig, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
|
||||||
|
self?.colprofLog.append(contentsOf: batch)
|
||||||
|
})
|
||||||
|
self.createdGamutURL = gamURL
|
||||||
|
self.colprofLog.append("Gamut mesh extracted: \(gamURL.lastPathComponent)")
|
||||||
|
} catch {
|
||||||
|
self.wizard.showNotice(
|
||||||
|
"Gamut extraction skipped: \(error.localizedDescription)",
|
||||||
|
kind: .info
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.createdProfileURL = finalProfileURL
|
||||||
self.wizard.refreshGating()
|
self.wizard.refreshGating()
|
||||||
self.wizard.showNotice("Profile created: \(outcome.profileURL.lastPathComponent)")
|
self.wizard.showNotice("Profile created: \(finalProfileURL.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
|
||||||
@@ -283,28 +285,23 @@ 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 outcome = try await ProcessRunSupport.runLogged(
|
let report = try await runner.runProfcheck(config: config, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
|
||||||
setRunning: { self.isProfcheckRunning = $0 },
|
self?.colprofLog.append(contentsOf: batch)
|
||||||
resetLog: {},
|
})
|
||||||
onLog: { self.colprofLog.append(contentsOf: $0) }
|
self.profcheckReport = report
|
||||||
) { onLog in
|
if let record = self.makeVerificationRecord(from: report) {
|
||||||
let report = try await runner.runProfcheck(config: config, onLogBatch: onLog)
|
let updated = try await self.environment.historyStore.append(record)
|
||||||
var history: [VerificationRecord]?
|
self.verificationHistory = updated
|
||||||
if let record = self.makeVerificationRecord(from: report) {
|
|
||||||
history = try await self.environment.historyStore.append(record)
|
|
||||||
}
|
|
||||||
return (report: report, history: history)
|
|
||||||
}
|
|
||||||
self.profcheckReport = outcome.report
|
|
||||||
if let history = outcome.history {
|
|
||||||
self.verificationHistory = history
|
|
||||||
self.driftAlert = DriftAlert.compute(from: self.filteredHistory)
|
self.driftAlert = DriftAlert.compute(from: self.filteredHistory)
|
||||||
}
|
}
|
||||||
} catch let error as ArgyllRunnerError where error == .profcheckUnparseable {
|
} catch let error as ArgyllRunnerError where error == .profcheckUnparseable {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ final class SettingsViewModel {
|
|||||||
sink.applySettings(settings)
|
sink.applySettings(settings)
|
||||||
savedFlash = true
|
savedFlash = true
|
||||||
Task {
|
Task {
|
||||||
try? await Task.sleep(nanoseconds: 1_500_000_000)
|
try? await Task.sleep(for: .seconds(1.5))
|
||||||
savedFlash = false
|
savedFlash = false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -218,7 +218,6 @@ struct Stage2View: View {
|
|||||||
.foregroundStyle(notice.kind == .error
|
.foregroundStyle(notice.kind == .error
|
||||||
? .red : .blue)
|
? .red : .blue)
|
||||||
.accessibilityIdentifier("printNotificationIcon")
|
.accessibilityIdentifier("printNotificationIcon")
|
||||||
.accessibilityValue(notice.kind.accessibilityValue)
|
|
||||||
Text(notice.text)
|
Text(notice.text)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(notice.kind == .error
|
.foregroundStyle(notice.kind == .error
|
||||||
|
|||||||
@@ -164,22 +164,28 @@ struct Stage3View: View {
|
|||||||
.foregroundStyle(Theme.accent)
|
.foregroundStyle(Theme.accent)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let notice = model.chartreadNotice {
|
if let lastError = model.lastError {
|
||||||
Text(notice.text)
|
Text(lastError)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(notice.kind.tint)
|
.foregroundStyle(.red)
|
||||||
.accessibilityIdentifier("chartreadLastError")
|
.accessibilityIdentifier("chartreadLastError")
|
||||||
.accessibilityValue(notice.text)
|
.accessibilityValue(lastError)
|
||||||
}
|
}
|
||||||
|
|
||||||
controlButtons
|
controlButtons
|
||||||
|
|
||||||
if !model.chartreadLog.isEmpty {
|
if !model.chartreadLog.isEmpty {
|
||||||
ProcessLogView(
|
DisclosureGroup("Log") {
|
||||||
lines: model.chartreadLog,
|
VStack(alignment: .leading) {
|
||||||
containerId: "chartreadLogContainer",
|
ForEach(model.chartreadLog, id: \.self) { line in
|
||||||
logId: "chartreadLog"
|
Text(line)
|
||||||
)
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.accessibilityIdentifier("chartreadLogContainer")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(16)
|
||||||
@@ -368,8 +374,6 @@ struct Stage3View: View {
|
|||||||
Text(notice.text)
|
Text(notice.text)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(notice.kind == .error ? .red : .green)
|
.foregroundStyle(notice.kind == .error ? .red : .green)
|
||||||
.accessibilityIdentifier("chartreadFinishNotice")
|
|
||||||
.accessibilityValue(notice.kind.accessibilityValue)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(16)
|
||||||
|
|||||||
@@ -166,14 +166,27 @@ 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 {
|
||||||
ProcessLogView(
|
DisclosureGroup("Log") {
|
||||||
lines: model.colprofLog,
|
VStack(alignment: .leading) {
|
||||||
containerId: "colprofLogContainer",
|
ForEach(model.colprofLog, id: \.self) { line in
|
||||||
logId: "colprofLog"
|
Text(line)
|
||||||
)
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.accessibilityIdentifier("colprofLogContainer")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(16)
|
||||||
|
|||||||
@@ -206,6 +206,8 @@ 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
|
||||||
@@ -226,6 +228,7 @@ final class TargetWorkflowViewModel {
|
|||||||
} catch {
|
} catch {
|
||||||
wizard.showNotice(
|
wizard.showNotice(
|
||||||
"targen failed: \(error.localizedDescription)", kind: .error)
|
"targen failed: \(error.localizedDescription)", kind: .error)
|
||||||
|
targenRunning = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -239,12 +242,7 @@ 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 {
|
||||||
@@ -341,6 +339,8 @@ 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
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ final class WizardViewModel {
|
|||||||
self.notice = notice
|
self.notice = notice
|
||||||
if let delay = notice.autoHideAfter {
|
if let delay = notice.autoHideAfter {
|
||||||
noticeDismissTask = Task { [weak self] in
|
noticeDismissTask = Task { [weak self] in
|
||||||
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
try? await Task.sleep(for: .seconds(delay))
|
||||||
guard !Task.isCancelled else { return }
|
guard !Task.isCancelled else { return }
|
||||||
if self?.notice?.id == notice.id {
|
if self?.notice?.id == notice.id {
|
||||||
self?.notice = nil
|
self?.notice = nil
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
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(processManager: ProcessManager = ProcessManager()) -> ArgyllRunner {
|
private func makeRunner() -> 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: processManager,
|
processManager: .shared,
|
||||||
binaryResolver: BinaryResolver(overrideDir: binDir)
|
binaryResolver: BinaryResolver(overrideDir: binDir)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -44,9 +44,8 @@ struct ArgyllRunnerCalibrationTests {
|
|||||||
@Test("Calibration targen from foo runs as process id targen_CAL_foo")
|
@Test("Calibration targen from foo runs as process id targen_CAL_foo")
|
||||||
func calibrationTargenProcessId() async throws {
|
func calibrationTargenProcessId() async throws {
|
||||||
let testRoot = try makeTestDir()
|
let testRoot = try makeTestDir()
|
||||||
let pm = ProcessManager()
|
let runner = makeRunner()
|
||||||
let runner = makeRunner(processManager: pm)
|
let events = ProcessManager.shared.events()
|
||||||
let events = pm.events()
|
|
||||||
// Subscribed before spawn; the exit event is emitted before
|
// Subscribed before spawn; the exit event is emitted before
|
||||||
// runCalibrationTargen returns, so this always terminates.
|
// runCalibrationTargen returns, so this always terminates.
|
||||||
let sawExit = Task {
|
let sawExit = Task {
|
||||||
@@ -88,28 +87,10 @@ struct ArgyllRunnerCalibrationTests {
|
|||||||
try? FileManager.default.removeItem(at: testRoot)
|
try? FileManager.default.removeItem(at: testRoot)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("printcal failure throws toolFailed")
|
@Test("printcal failure throws printcalFailed")
|
||||||
func printcalFailureThrows() async throws {
|
func printcalFailureThrows() async throws {
|
||||||
let testRoot = try makeTestDir()
|
let testRoot = try makeTestDir()
|
||||||
defer { try? FileManager.default.removeItem(at: testRoot) }
|
let runner = makeRunner()
|
||||||
|
|
||||||
// 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",
|
||||||
@@ -117,9 +98,12 @@ struct ArgyllRunnerCalibrationTests {
|
|||||||
outputURL: output
|
outputURL: output
|
||||||
)
|
)
|
||||||
|
|
||||||
await #expect(throws: ArgyllRunnerError.toolFailed(
|
setenv("ICCERY_MOCK_PRINTCAL_EXIT", "1", 1)
|
||||||
tool: "printcal", code: 1, logs: ["printcal mock failure\n"])) {
|
defer { unsetenv("ICCERY_MOCK_PRINTCAL_EXIT") }
|
||||||
|
|
||||||
|
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: ProcessManager(),
|
processManager: .shared,
|
||||||
binaryResolver: BinaryResolver(overrideDir: binDir)
|
binaryResolver: BinaryResolver(overrideDir: binDir)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -49,32 +49,4 @@ 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,161 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -159,51 +159,6 @@ 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")
|
||||||
|
|||||||
@@ -44,29 +44,6 @@ 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,23 +131,6 @@ 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) {
|
||||||
@@ -366,7 +349,7 @@ struct ArgyllRunnerPrinttargTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Non-zero exit throws toolFailed and stays on stage")
|
@Test("Non-zero exit throws processFailed and stays on stage")
|
||||||
func failure() async throws {
|
func failure() async throws {
|
||||||
let dir = try makeFixture("""
|
let dir = try makeFixture("""
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
@@ -377,8 +360,7 @@ struct ArgyllRunnerPrinttargTests {
|
|||||||
let runner = ArgyllRunner(
|
let runner = ArgyllRunner(
|
||||||
processManager: ProcessManager(),
|
processManager: ProcessManager(),
|
||||||
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir))
|
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir))
|
||||||
await #expect(throws: ArgyllRunnerError.toolFailed(
|
await #expect(throws: ArgyllRunnerError.self) {
|
||||||
tool: "printtarg", code: 3, logs: ["oops"])) {
|
|
||||||
try await runner.runPrinttarg(
|
try await runner.runPrinttarg(
|
||||||
config: PrinttargConfig(basename: "x", workingDirectory: dir))
|
config: PrinttargConfig(basename: "x", workingDirectory: dir))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -162,34 +162,6 @@ 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(
|
||||||
@@ -290,7 +262,7 @@ struct ArgyllRunnerTargenTests {
|
|||||||
#expect(ti1URL.lastPathComponent == "mock_test.ti1")
|
#expect(ti1URL.lastPathComponent == "mock_test.ti1")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Failed targen execution throws toolFailed")
|
@Test("Failed targen execution throws processFailed")
|
||||||
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)
|
||||||
@@ -318,8 +290,7 @@ struct ArgyllRunnerTargenTests {
|
|||||||
workingDirectory: tempDir
|
workingDirectory: tempDir
|
||||||
)
|
)
|
||||||
|
|
||||||
await #expect(throws: ArgyllRunnerError.toolFailed(
|
await #expect(throws: ArgyllRunnerError.self) {
|
||||||
tool: "targen", code: 1, logs: ["Error: something went wrong"])) {
|
|
||||||
try await runner.runTargen(config: config)
|
try await runner.runTargen(config: config)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -352,8 +323,7 @@ struct ArgyllRunnerTargenTests {
|
|||||||
workingDirectory: tempDir
|
workingDirectory: tempDir
|
||||||
)
|
)
|
||||||
|
|
||||||
await #expect(throws: ArgyllRunnerError.missingArtefact(
|
await #expect(throws: ArgyllRunnerError.self) {
|
||||||
tempDir.appendingPathComponent("no_file.ti1").path)) {
|
|
||||||
try await runner.runTargen(config: config)
|
try await runner.runTargen(config: config)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
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:"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -133,18 +133,6 @@ 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,8 +146,6 @@ 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
|
||||||
@@ -216,8 +214,6 @@ 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,57 +140,4 @@ 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,21 +111,4 @@ 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,7 +32,6 @@ 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 {
|
||||||
@@ -78,60 +77,4 @@ 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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-12
@@ -2,7 +2,7 @@ name: ICCery
|
|||||||
options:
|
options:
|
||||||
bundleIdPrefix: com.gronod
|
bundleIdPrefix: com.gronod
|
||||||
deploymentTarget:
|
deploymentTarget:
|
||||||
macOS: "12.0"
|
macOS: "14.0"
|
||||||
groupSortPosition: top
|
groupSortPosition: top
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
@@ -13,7 +13,7 @@ targets:
|
|||||||
ICCery:
|
ICCery:
|
||||||
type: application
|
type: application
|
||||||
platform: macOS
|
platform: macOS
|
||||||
deploymentTarget: "12.0"
|
deploymentTarget: "14.0"
|
||||||
sources:
|
sources:
|
||||||
- path: Sources/ICCery
|
- path: Sources/ICCery
|
||||||
- path: Resources
|
- path: Resources
|
||||||
@@ -46,7 +46,7 @@ targets:
|
|||||||
PRODUCT_BUNDLE_PACKAGE_TYPE: APPL
|
PRODUCT_BUNDLE_PACKAGE_TYPE: APPL
|
||||||
GENERATE_INFOPLIST_FILE: YES
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
INFOPLIST_KEY_CFBundleDisplayName: ICCery
|
INFOPLIST_KEY_CFBundleDisplayName: ICCery
|
||||||
INFOPLIST_KEY_LSMinimumSystemVersion: "12.0"
|
INFOPLIST_KEY_LSMinimumSystemVersion: "14.0"
|
||||||
INFOPLIST_KEY_NSPrincipalClass: NSApplication
|
INFOPLIST_KEY_NSPrincipalClass: NSApplication
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 Gronod. AGPLv3."
|
INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 Gronod. AGPLv3."
|
||||||
MARKETING_VERSION: "2.0.0"
|
MARKETING_VERSION: "2.0.0"
|
||||||
@@ -58,15 +58,15 @@ targets:
|
|||||||
ENABLE_APP_SANDBOX: NO
|
ENABLE_APP_SANDBOX: NO
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
|
||||||
SWIFT_VERSION: "5.0"
|
SWIFT_VERSION: "6.0"
|
||||||
OTHER_SWIFT_FLAGS: ["$(inherited)", "-strict-concurrency=minimal"]
|
SWIFT_STRICT_CONCURRENCY: complete
|
||||||
MACOSX_DEPLOYMENT_TARGET: "12.0"
|
MACOSX_DEPLOYMENT_TARGET: "14.0"
|
||||||
ARCHS: "$(ARCHS_STANDARD)"
|
ARCHS: "$(ARCHS_STANDARD)"
|
||||||
|
|
||||||
ICCeryCoreTests:
|
ICCeryCoreTests:
|
||||||
type: bundle.unit-test
|
type: bundle.unit-test
|
||||||
platform: macOS
|
platform: macOS
|
||||||
deploymentTarget: "12.0"
|
deploymentTarget: "14.0"
|
||||||
sources:
|
sources:
|
||||||
- path: Tests/ICCeryCoreTests
|
- path: Tests/ICCeryCoreTests
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -79,13 +79,13 @@ targets:
|
|||||||
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/ICCery.app/Contents/MacOS/ICCery"
|
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/ICCery.app/Contents/MacOS/ICCery"
|
||||||
GENERATE_INFOPLIST_FILE: YES
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
CODE_SIGN_IDENTITY: "-"
|
CODE_SIGN_IDENTITY: "-"
|
||||||
SWIFT_VERSION: "5.0"
|
SWIFT_VERSION: "6.0"
|
||||||
MACOSX_DEPLOYMENT_TARGET: "12.0"
|
MACOSX_DEPLOYMENT_TARGET: "14.0"
|
||||||
|
|
||||||
ICCeryUITests:
|
ICCeryUITests:
|
||||||
type: bundle.ui-testing
|
type: bundle.ui-testing
|
||||||
platform: macOS
|
platform: macOS
|
||||||
deploymentTarget: "12.0"
|
deploymentTarget: "14.0"
|
||||||
sources:
|
sources:
|
||||||
- path: Tests/ICCeryUITests
|
- path: Tests/ICCeryUITests
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -95,8 +95,8 @@ targets:
|
|||||||
TEST_TARGET_NAME: ICCery
|
TEST_TARGET_NAME: ICCery
|
||||||
GENERATE_INFOPLIST_FILE: YES
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
CODE_SIGN_IDENTITY: "-"
|
CODE_SIGN_IDENTITY: "-"
|
||||||
SWIFT_VERSION: "5.0"
|
SWIFT_VERSION: "6.0"
|
||||||
MACOSX_DEPLOYMENT_TARGET: "12.0"
|
MACOSX_DEPLOYMENT_TARGET: "14.0"
|
||||||
|
|
||||||
schemes:
|
schemes:
|
||||||
ICCery:
|
ICCery:
|
||||||
|
|||||||
Reference in New Issue
Block a user