Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
407136c5a2 | ||
|
|
83b5f6bf75 | ||
|
|
16df18127c | ||
|
|
78baa49e61 | ||
|
|
04a563c54a | ||
|
|
2cbd43470d | ||
|
|
14f9e6f78d | ||
|
|
f16825be72 | ||
|
|
9fcd21dc2d | ||
|
|
e967502ea7 |
@@ -30,19 +30,25 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
echo "$line"
|
echo "$line"
|
||||||
|
|
||||||
|
# Tag pushes whose name contains "prerelease" skip the test build and both
|
||||||
|
# test legs: they exist to package a build already validated elsewhere.
|
||||||
|
# The job still succeeds quickly so `package`'s `needs:` stays satisfied.
|
||||||
# Homebrew's xcodegen formula requires Xcode 15.3, which cannot be
|
# Homebrew's xcodegen formula requires Xcode 15.3, which cannot be
|
||||||
# installed on macOS 12 (#109). The script installs a pinned
|
# installed on macOS 12 (#109). The script installs a pinned
|
||||||
# prebuilt release instead.
|
# prebuilt release instead.
|
||||||
- name: Ensure host tools
|
- name: Ensure host tools
|
||||||
|
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||||
run: scripts/ensure-host-tools.sh
|
run: scripts/ensure-host-tools.sh
|
||||||
|
|
||||||
- name: Generate Xcode project
|
- name: Generate Xcode project
|
||||||
|
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||||
run: xcodegen generate --spec project.yml
|
run: xcodegen generate --spec project.yml
|
||||||
|
|
||||||
# Tests only ever run on the runner's own architecture; build
|
# Tests only ever run on the runner's own architecture; build
|
||||||
# just that slice. Packaging (scripts/package-release.sh) still
|
# just that slice. Packaging (scripts/package-release.sh) still
|
||||||
# produces the universal Release binary.
|
# produces the universal Release binary.
|
||||||
- name: Build for testing (host arch)
|
- name: Build for testing (host arch)
|
||||||
|
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||||
run: |
|
run: |
|
||||||
xcodebuild build-for-testing \
|
xcodebuild build-for-testing \
|
||||||
-scheme ICCery \
|
-scheme ICCery \
|
||||||
@@ -60,6 +66,7 @@ jobs:
|
|||||||
# launch (run 31992) — so sign every embedded copy once the build is
|
# launch (run 31992) — so sign every embedded copy once the build is
|
||||||
# done (embed steps run after any build script phase) (#119).
|
# done (embed steps run after any build script phase) (#119).
|
||||||
- name: Sign package product frameworks
|
- name: Sign package product frameworks
|
||||||
|
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||||
run: |
|
run: |
|
||||||
find "$DERIVED/Build/Products/Debug" -depth -name '*_PackageProduct.framework' -print0 \
|
find "$DERIVED/Build/Products/Debug" -depth -name '*_PackageProduct.framework' -print0 \
|
||||||
| while IFS= read -r -d '' fw; do
|
| while IFS= read -r -d '' fw; do
|
||||||
@@ -68,6 +75,7 @@ jobs:
|
|||||||
done
|
done
|
||||||
|
|
||||||
- name: Test unit (ICCeryCoreTests)
|
- name: Test unit (ICCeryCoreTests)
|
||||||
|
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||||
run: |
|
run: |
|
||||||
XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)"
|
XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)"
|
||||||
if [ -z "$XCTESTRUN" ] || [ ! -f "$XCTESTRUN" ]; then
|
if [ -z "$XCTESTRUN" ] || [ ! -f "$XCTESTRUN" ]; then
|
||||||
@@ -90,6 +98,7 @@ jobs:
|
|||||||
# tag packaging can proceed. Real XCTest assertion failures
|
# tag packaging can proceed. Real XCTest assertion failures
|
||||||
# still fail the job.
|
# still fail the job.
|
||||||
- name: Test UI (ICCeryUITests)
|
- name: Test UI (ICCeryUITests)
|
||||||
|
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||||
run: |
|
run: |
|
||||||
set -o pipefail
|
set -o pipefail
|
||||||
XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)"
|
XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)"
|
||||||
@@ -154,7 +163,7 @@ jobs:
|
|||||||
# xcresult on failure — upload it so UI failures can be triaged
|
# xcresult on failure — upload it so UI failures can be triaged
|
||||||
# without access to the runner (#126).
|
# without access to the runner (#126).
|
||||||
- name: Prepare Node CA bundle (failure path)
|
- name: Prepare Node CA bundle (failure path)
|
||||||
if: failure()
|
if: "failure() && !(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||||
run: |
|
run: |
|
||||||
NODE_CA_FILE="/tmp/macos-ca-bundle.pem"
|
NODE_CA_FILE="/tmp/macos-ca-bundle.pem"
|
||||||
security find-certificate -a -p \
|
security find-certificate -a -p \
|
||||||
@@ -166,7 +175,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Upload UI test xcresult
|
- name: Upload UI test xcresult
|
||||||
if: failure()
|
if: "failure() && !(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
env:
|
env:
|
||||||
NODE_EXTRA_CA_CERTS: /tmp/macos-ca-bundle.pem
|
NODE_EXTRA_CA_CERTS: /tmp/macos-ca-bundle.pem
|
||||||
|
|||||||
@@ -97,6 +97,12 @@ enum UITestHooks {
|
|||||||
static var projectSaveURL: URL? { url("ICCERY_TEST_PROJECT_SAVE") }
|
static var projectSaveURL: URL? { url("ICCERY_TEST_PROJECT_SAVE") }
|
||||||
/// Relocate-folder result when a project's `cwd` is missing (#149).
|
/// Relocate-folder result when a project's `cwd` is missing (#149).
|
||||||
static var projectRelocateURL: URL? { url("ICCERY_TEST_PROJECT_RELOCATE") }
|
static var projectRelocateURL: URL? { url("ICCERY_TEST_PROJECT_RELOCATE") }
|
||||||
|
/// Forces the gamut sheet into its no-Metal fallback even on a GPU
|
||||||
|
/// host (#147). Set per-test only — never in a default launch env,
|
||||||
|
/// or CI's future GPU run would skip SceneKit too.
|
||||||
|
static var skipSceneKit: Bool {
|
||||||
|
isEnabled && env["ICCERY_TEST_SKIP_SCENEKIT"] == "1"
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Print panel / CUPS stubs (issue 13/17)
|
// MARK: - Print panel / CUPS stubs (issue 13/17)
|
||||||
|
|
||||||
|
|||||||
@@ -7,102 +7,135 @@ struct CalibrationView: View {
|
|||||||
@ObservedObject var wizard: WizardViewModel
|
@ObservedObject var wizard: WizardViewModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Text("Calibrate Printer")
|
ScrollView {
|
||||||
.font(.title2.bold())
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
.padding(.horizontal, 16)
|
Text("Calibrate Printer")
|
||||||
.padding(.top, 16)
|
.font(.title2.bold())
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
Form {
|
wedgeSection
|
||||||
Section("Wedge Settings") {
|
workflowSection
|
||||||
Picker("Colour Space", selection: $model.colourSpace) {
|
if !model.calibrationLog.isEmpty { logSection }
|
||||||
Text("RGB").tag(ColourSpace.rgb)
|
|
||||||
Text("CMYK").tag(ColourSpace.cmyk)
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack {
|
|
||||||
Text("Steps per channel")
|
|
||||||
Spacer()
|
|
||||||
TextField("", value: $model.steps, format: .number)
|
|
||||||
.frame(width: 60)
|
|
||||||
.accessibilityIdentifier("calSteps")
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack {
|
|
||||||
Text("White patches")
|
|
||||||
Spacer()
|
|
||||||
TextField("", value: $model.whitePatches, format: .number)
|
|
||||||
.frame(width: 60)
|
|
||||||
}
|
|
||||||
|
|
||||||
if model.colourSpace == .cmyk {
|
|
||||||
HStack {
|
|
||||||
Text("Ink-limit exploration")
|
|
||||||
Spacer()
|
|
||||||
TextField("", text: $model.inkLimit)
|
|
||||||
.frame(width: 60)
|
|
||||||
.accessibilityIdentifier("calInkExplore")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Toggle("Neutral emphasis", isOn: $model.includeNeutralEmphasis)
|
|
||||||
}
|
|
||||||
|
|
||||||
Section("Workflow") {
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
Button("Generate Target") { model.generateTarget() }
|
|
||||||
.accessibilityIdentifier("btnCalGenerate")
|
|
||||||
.disabled(wizard.basename.isEmpty
|
|
||||||
|| wizard.effectiveWorkingDirectory == nil
|
|
||||||
|| model.isGenerating)
|
|
||||||
|
|
||||||
Button("Create Layout & Print") { model.createLayout() }
|
|
||||||
.accessibilityIdentifier("btnCalLayout")
|
|
||||||
.disabled(wizard.basename.isEmpty
|
|
||||||
|| wizard.effectiveWorkingDirectory == nil
|
|
||||||
|| model.isGenerating)
|
|
||||||
|
|
||||||
Button("Measure") { model.measureChart() }
|
|
||||||
.accessibilityIdentifier("btnCalMeasure")
|
|
||||||
.disabled(model.calibrationTi3URL == nil)
|
|
||||||
|
|
||||||
Button("Compute Curves") { model.computeCurves() }
|
|
||||||
.accessibilityIdentifier("btnCalCompute")
|
|
||||||
.disabled(!model.canCompute)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let url = model.computedCalURL {
|
|
||||||
Toggle("Apply calibration to next profile", isOn: $model.applyToProfile)
|
|
||||||
.onChange(of: model.applyToProfile) { _ in model.updateApplyToProfile() }
|
|
||||||
.accessibilityIdentifier("calApplyToggle")
|
|
||||||
|
|
||||||
Text("Loaded: \(url.lastPathComponent)")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !model.calibrationLog.isEmpty {
|
|
||||||
Section("Log") {
|
|
||||||
ScrollView {
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
ForEach(model.calibrationLog, id: \.self) { line in
|
|
||||||
Text(line)
|
|
||||||
.font(.system(.caption, design: .monospaced))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(minHeight: 80, maxHeight: 120)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.padding(20)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
.background(Theme.background)
|
||||||
|
|
||||||
|
Divider().overlay(Theme.border)
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
Button("Return to Profiling") { model.returnToProfiling() }
|
Button("Return to Profiling", role: .cancel) { model.returnToProfiling() }
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
.accessibilityIdentifier("btnCalReturn")
|
.accessibilityIdentifier("btnCalReturn")
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(16)
|
||||||
}
|
}
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityIdentifier("stage-cal")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Wedge settings
|
||||||
|
|
||||||
|
private var wedgeSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Wedge Settings").font(.headline).foregroundStyle(Theme.text)
|
||||||
|
Picker("Colour Space", selection: $model.colourSpace) {
|
||||||
|
Text("RGB").tag(ColourSpace.rgb)
|
||||||
|
Text("CMYK").tag(ColourSpace.cmyk)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.frame(maxWidth: 220)
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Text("Steps per channel")
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.frame(width: 140, alignment: .leading)
|
||||||
|
TextField("", value: $model.steps, format: .number)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: 70)
|
||||||
|
.accessibilityIdentifier("calSteps")
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Text("White patches")
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.frame(width: 140, alignment: .leading)
|
||||||
|
TextField("", value: $model.whitePatches, format: .number)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: 70)
|
||||||
|
}
|
||||||
|
|
||||||
|
if model.colourSpace == .cmyk {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Text("Ink-limit exploration")
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.frame(width: 140, alignment: .leading)
|
||||||
|
TextField("", text: $model.inkLimit)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: 70)
|
||||||
|
.accessibilityIdentifier("calInkExplore")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Toggle("Neutral emphasis", isOn: $model.includeNeutralEmphasis)
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.accessibilityIdentifier("calNeutralEmphasis")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Workflow
|
||||||
|
|
||||||
|
private var workflowSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Workflow").font(.headline).foregroundStyle(Theme.text)
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button("Generate Target") { model.generateTarget() }
|
||||||
|
.accessibilityIdentifier("btnCalGenerate")
|
||||||
|
.disabled(wizard.basename.isEmpty
|
||||||
|
|| wizard.effectiveWorkingDirectory == nil
|
||||||
|
|| model.isGenerating)
|
||||||
|
|
||||||
|
Button("Create Layout & Print") { model.createLayout() }
|
||||||
|
.accessibilityIdentifier("btnCalLayout")
|
||||||
|
.disabled(wizard.basename.isEmpty
|
||||||
|
|| wizard.effectiveWorkingDirectory == nil
|
||||||
|
|| model.isGenerating)
|
||||||
|
|
||||||
|
Button("Measure") { model.measureChart() }
|
||||||
|
.accessibilityIdentifier("btnCalMeasure")
|
||||||
|
.disabled(model.calibrationTi3URL == nil)
|
||||||
|
|
||||||
|
Button("Compute Curves") { model.computeCurves() }
|
||||||
|
.accessibilityIdentifier("btnCalCompute")
|
||||||
|
.disabled(!model.canCompute)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let url = model.computedCalURL {
|
||||||
|
Toggle("Apply calibration to next profile", isOn: $model.applyToProfile)
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.onChange(of: model.applyToProfile) { _ in model.updateApplyToProfile() }
|
||||||
|
.accessibilityIdentifier("calApplyToggle")
|
||||||
|
|
||||||
|
Text("Loaded: \(url.lastPathComponent)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Log
|
||||||
|
|
||||||
|
private var logSection: some View {
|
||||||
|
ProcessLogView(
|
||||||
|
lines: model.calibrationLog,
|
||||||
|
minHeight: 80,
|
||||||
|
maxHeight: 120,
|
||||||
|
containerId: "calLogContainer",
|
||||||
|
logId: "calLog"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ struct GamutView: View {
|
|||||||
@StateObject private var viewModel: GamutViewModel
|
@StateObject private var viewModel: GamutViewModel
|
||||||
@State private var pause: () -> Void = {}
|
@State private var pause: () -> Void = {}
|
||||||
@FocusState private var isFocused: Bool
|
@FocusState private var isFocused: Bool
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
@Binding var showingAllHelp: Bool
|
@Binding var showingAllHelp: Bool
|
||||||
|
|
||||||
init(
|
init(
|
||||||
@@ -94,6 +95,8 @@ struct GamutView: View {
|
|||||||
Divider().overlay(Theme.border)
|
Divider().overlay(Theme.border)
|
||||||
statusLine
|
statusLine
|
||||||
inspectPanel
|
inspectPanel
|
||||||
|
Divider().overlay(Theme.border)
|
||||||
|
footer
|
||||||
}
|
}
|
||||||
.frame(minWidth: 720, minHeight: 520)
|
.frame(minWidth: 720, minHeight: 520)
|
||||||
.background(Theme.background)
|
.background(Theme.background)
|
||||||
@@ -142,7 +145,7 @@ struct GamutView: View {
|
|||||||
Text(layer?.displayName ?? fallback)
|
Text(layer?.displayName ?? fallback)
|
||||||
}
|
}
|
||||||
.toggleStyle(.checkbox)
|
.toggleStyle(.checkbox)
|
||||||
.disabled(layer == nil || viewModel.viewerUnavailable)
|
.disabled(layer == nil)
|
||||||
.help(layer.map { $0.sourceURL.lastPathComponent } ?? "No profile .gam loaded")
|
.help(layer.map { $0.sourceURL.lastPathComponent } ?? "No profile .gam loaded")
|
||||||
// macOS 12 puts the identifier on the Toggle's container, an
|
// macOS 12 puts the identifier on the Toggle's container, an
|
||||||
// element that never reports isEnabled — combine so the a11y
|
// element that never reports isEnabled — combine so the a11y
|
||||||
@@ -322,6 +325,21 @@ struct GamutView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Footer
|
||||||
|
|
||||||
|
/// Always-visible Close (#147) — the fallback banner keeps it
|
||||||
|
/// reachable and Escape works via `.cancelAction` without SceneKit.
|
||||||
|
private var footer: some View {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button("Close") { dismiss() }
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
.accessibilityIdentifier("btnCloseGamut")
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - TIFF sample sheet
|
// MARK: - TIFF sample sheet
|
||||||
|
|
||||||
private var tiffPreviewSheet: some View {
|
private var tiffPreviewSheet: some View {
|
||||||
@@ -381,7 +399,9 @@ private struct GamutSceneView: NSViewRepresentable {
|
|||||||
context.coordinator.installKeyMonitor()
|
context.coordinator.installKeyMonitor()
|
||||||
context.coordinator.installClickGesture()
|
context.coordinator.installClickGesture()
|
||||||
|
|
||||||
// No GPU → the docs/18 fallback; never respawn the view in a loop.
|
// Safety net only — the primary no-Metal check is
|
||||||
|
// `GamutSceneAvailability.isAvailable`, evaluated before this
|
||||||
|
// view is mounted. Never respawn the view in a loop.
|
||||||
if MTLCreateSystemDefaultDevice() == nil {
|
if MTLCreateSystemDefaultDevice() == nil {
|
||||||
DispatchQueue.main.async { onUnavailable() }
|
DispatchQueue.main.async { onUnavailable() }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,21 @@
|
|||||||
import Combine
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import ICCeryCore
|
import ICCeryCore
|
||||||
|
import Metal
|
||||||
import simd
|
import simd
|
||||||
|
|
||||||
|
/// Whether the SceneKit gamut scene can render on this host (#147).
|
||||||
|
///
|
||||||
|
/// Checked **before** `GamutSceneView` is mounted — constructing an
|
||||||
|
/// `SCNView` on a Metal-less machine can wedge the main thread, which
|
||||||
|
/// also stalls app quit behind the open sheet.
|
||||||
|
enum GamutSceneAvailability {
|
||||||
|
static var isAvailable: Bool {
|
||||||
|
if UITestHooks.skipSceneKit { return false }
|
||||||
|
return MTLCreateSystemDefaultDevice() != nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// View model for the native SceneKit gamut viewer (issues #28, #147).
|
/// View model for the native SceneKit gamut viewer (issues #28, #147).
|
||||||
///
|
///
|
||||||
/// Loads the bundled `sRGB.gam` reference immediately, the workflow's own
|
/// Loads the bundled `sRGB.gam` reference immediately, the workflow's own
|
||||||
@@ -73,6 +86,8 @@ final class GamutViewModel: ObservableObject {
|
|||||||
init(environment: AppEnvironment, profileGamURL: URL? = nil) {
|
init(environment: AppEnvironment, profileGamURL: URL? = nil) {
|
||||||
self.environment = environment
|
self.environment = environment
|
||||||
self.profileGamURL = profileGamURL
|
self.profileGamURL = profileGamURL
|
||||||
|
// Never let the view mount an SCNView without Metal (#147).
|
||||||
|
viewerUnavailable = !GamutSceneAvailability.isAvailable
|
||||||
loadTask = Task { await load() }
|
loadTask = Task { await load() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ struct RootView: View {
|
|||||||
}
|
}
|
||||||
WizardStageContent(model: model, workflow: workflow)
|
WizardStageContent(model: model, workflow: workflow)
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
.frame(minWidth: 1100, minHeight: 700)
|
.frame(minWidth: 1100, minHeight: 700)
|
||||||
.background(Theme.background)
|
.background(Theme.background)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import Metal
|
||||||
import XCTest
|
import XCTest
|
||||||
@testable import ICCeryCore
|
@testable import ICCeryCore
|
||||||
@testable import ICCery
|
@testable import ICCery
|
||||||
@@ -36,6 +37,15 @@ final class GamutViewModelTests: XCTestCase {
|
|||||||
XCTAssertTrue(vm.status.contains("faces"), "status: \(vm.status)")
|
XCTAssertTrue(vm.status.contains("faces"), "status: \(vm.status)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #147 — `viewerUnavailable` is decided before any `SCNView` is
|
||||||
|
/// mounted: it must exactly mirror Metal presence on this host.
|
||||||
|
func testViewerUnavailableMirrorsMetalAvailability() async throws {
|
||||||
|
let vm = try makeViewModel()
|
||||||
|
XCTAssertEqual(
|
||||||
|
vm.viewerUnavailable,
|
||||||
|
MTLCreateSystemDefaultDevice() == nil)
|
||||||
|
}
|
||||||
|
|
||||||
func testMissingCompareGamLeavesSRGBAndSetsNotice() async throws {
|
func testMissingCompareGamLeavesSRGBAndSetsNotice() async throws {
|
||||||
let vm = try makeViewModel()
|
let vm = try makeViewModel()
|
||||||
await vm.awaitInitialLoad()
|
await vm.awaitInitialLoad()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import Metal
|
||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
/// Milestone 10 — Issue #147 gamut compare chrome tests.
|
/// Milestone 10 — Issue #147 gamut compare chrome tests.
|
||||||
@@ -9,6 +10,11 @@ import XCTest
|
|||||||
@MainActor
|
@MainActor
|
||||||
final class Milestone10GamutCompareUITests: XCTestCase {
|
final class Milestone10GamutCompareUITests: XCTestCase {
|
||||||
|
|
||||||
|
/// Metal on the test host — the app under test runs on the same
|
||||||
|
/// machine, so this predicts whether the sheet mounts SceneKit.
|
||||||
|
/// GPU-less runners still get the banner/Close assertions (#147).
|
||||||
|
private var hasGPU: Bool { MTLCreateSystemDefaultDevice() != nil }
|
||||||
|
|
||||||
private var app: XCUIApplication!
|
private var app: XCUIApplication!
|
||||||
private var testRoot: URL!
|
private var testRoot: URL!
|
||||||
private var binDir: URL!
|
private var binDir: URL!
|
||||||
@@ -46,6 +52,10 @@ final class Milestone10GamutCompareUITests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override func tearDown() async throws {
|
override func tearDown() async throws {
|
||||||
|
// Never leave the gamut sheet up for `terminate()` (#147).
|
||||||
|
if app != nil, element("btnCloseGamut").exists {
|
||||||
|
element("btnCloseGamut").click()
|
||||||
|
}
|
||||||
app?.terminate()
|
app?.terminate()
|
||||||
app = nil
|
app = nil
|
||||||
if let testRoot {
|
if let testRoot {
|
||||||
@@ -104,6 +114,25 @@ final class Milestone10GamutCompareUITests: XCTestCase {
|
|||||||
_ = waitFor("gamutView")
|
_ = waitFor("gamutView")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Inverse of `waitFor` — polls until the element leaves the tree.
|
||||||
|
private func waitForGone(_ id: String, timeout: TimeInterval = 10) {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
if !element(id).exists { return }
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
|
}
|
||||||
|
XCTAssertFalse(element(id).exists, "Expected element \(id) to disappear")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `btnCloseGamut` dismisses the sheet so `tearDown`'s `terminate()`
|
||||||
|
/// is not stuck behind a key sheet (#147). No-op when already closed.
|
||||||
|
private func closeGamutSheet() {
|
||||||
|
let close = element("btnCloseGamut")
|
||||||
|
guard close.waitForExistence(timeout: 5) else { return }
|
||||||
|
close.click()
|
||||||
|
waitForGone("gamutView")
|
||||||
|
}
|
||||||
|
|
||||||
func testLayerTogglesExistWithSRGB() throws {
|
func testLayerTogglesExistWithSRGB() throws {
|
||||||
openGamutSheet()
|
openGamutSheet()
|
||||||
|
|
||||||
@@ -115,6 +144,11 @@ final class Milestone10GamutCompareUITests: XCTestCase {
|
|||||||
let compare = waitFor("gamutLayer-compare")
|
let compare = waitFor("gamutLayer-compare")
|
||||||
XCTAssertTrue(compare.exists)
|
XCTAssertTrue(compare.exists)
|
||||||
XCTAssertFalse(compare.isEnabled, "Compare toggle must be disabled before a load")
|
XCTAssertFalse(compare.isEnabled, "Compare toggle must be disabled before a load")
|
||||||
|
|
||||||
|
let status = waitFor("gamutStatusText")
|
||||||
|
let statusValue = status.value as? String ?? ""
|
||||||
|
XCTAssertTrue(statusValue.contains("sRGB"), "Status should list the sRGB layer, got: \(statusValue)")
|
||||||
|
closeGamutSheet()
|
||||||
}
|
}
|
||||||
|
|
||||||
func testAddCompareButtonExists() throws {
|
func testAddCompareButtonExists() throws {
|
||||||
@@ -128,6 +162,7 @@ final class Milestone10GamutCompareUITests: XCTestCase {
|
|||||||
let status = waitFor("gamutStatusText")
|
let status = waitFor("gamutStatusText")
|
||||||
let value = status.value as? String ?? ""
|
let value = status.value as? String ?? ""
|
||||||
XCTAssertTrue(value.contains("sRGB"), "Status should keep the sRGB clause, got: \(value)")
|
XCTAssertTrue(value.contains("sRGB"), "Status should keep the sRGB clause, got: \(value)")
|
||||||
|
closeGamutSheet()
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCompareGamLoadEnablesToggle() throws {
|
func testCompareGamLoadEnablesToggle() throws {
|
||||||
@@ -142,6 +177,7 @@ final class Milestone10GamutCompareUITests: XCTestCase {
|
|||||||
// The pre-load placeholder also exists — wait for enabled.
|
// The pre-load placeholder also exists — wait for enabled.
|
||||||
let compare = waitUntilEnabled("gamutLayer-compare")
|
let compare = waitUntilEnabled("gamutLayer-compare")
|
||||||
XCTAssertTrue(compare.isEnabled, "Compare toggle should enable after load")
|
XCTAssertTrue(compare.isEnabled, "Compare toggle should enable after load")
|
||||||
|
XCTAssertEqual(compare.value as? Int, 1, "Compare layer should be on after load")
|
||||||
|
|
||||||
let status = waitFor("gamutStatusText")
|
let status = waitFor("gamutStatusText")
|
||||||
let value = status.value as? String ?? ""
|
let value = status.value as? String ?? ""
|
||||||
@@ -149,6 +185,7 @@ final class Milestone10GamutCompareUITests: XCTestCase {
|
|||||||
|
|
||||||
let remove = waitFor("btnGamutRemoveCompare")
|
let remove = waitFor("btnGamutRemoveCompare")
|
||||||
XCTAssertTrue(remove.isEnabled)
|
XCTAssertTrue(remove.isEnabled)
|
||||||
|
closeGamutSheet()
|
||||||
}
|
}
|
||||||
|
|
||||||
func testOpenProfileRunsIccgamutForCompare() throws {
|
func testOpenProfileRunsIccgamutForCompare() throws {
|
||||||
@@ -164,6 +201,13 @@ final class Milestone10GamutCompareUITests: XCTestCase {
|
|||||||
|
|
||||||
let compare = waitUntilEnabled("gamutLayer-compare")
|
let compare = waitUntilEnabled("gamutLayer-compare")
|
||||||
XCTAssertTrue(compare.isEnabled, "Compare toggle should enable after iccgamut")
|
XCTAssertTrue(compare.isEnabled, "Compare toggle should enable after iccgamut")
|
||||||
|
XCTAssertEqual(compare.value as? Int, 1, "Compare layer should be on after iccgamut")
|
||||||
|
|
||||||
|
// The compare slot's display name is the .gam stem ("myprinter").
|
||||||
|
let status = waitFor("gamutStatusText")
|
||||||
|
let statusValue = status.value as? String ?? ""
|
||||||
|
XCTAssertTrue(statusValue.contains("myprinter"), "Status should list the compare layer, got: \(statusValue)")
|
||||||
|
closeGamutSheet()
|
||||||
}
|
}
|
||||||
|
|
||||||
func testInspectPanelIdleStableHeight() throws {
|
func testInspectPanelIdleStableHeight() throws {
|
||||||
@@ -173,6 +217,7 @@ final class Milestone10GamutCompareUITests: XCTestCase {
|
|||||||
XCTAssertTrue(panel.exists)
|
XCTAssertTrue(panel.exists)
|
||||||
XCTAssertTrue(element("gamutInspectIdle").exists)
|
XCTAssertTrue(element("gamutInspectIdle").exists)
|
||||||
XCTAssertTrue(element("gamutStatusText").exists)
|
XCTAssertTrue(element("gamutStatusText").exists)
|
||||||
|
closeGamutSheet()
|
||||||
}
|
}
|
||||||
|
|
||||||
func testManualLabInspectShowsContainment() throws {
|
func testManualLabInspectShowsContainment() throws {
|
||||||
@@ -192,11 +237,55 @@ final class Milestone10GamutCompareUITests: XCTestCase {
|
|||||||
XCTAssertTrue(value.contains("in"), "Lab(50,0,0) should be inside sRGB, got: \(value)")
|
XCTAssertTrue(value.contains("in"), "Lab(50,0,0) should be inside sRGB, got: \(value)")
|
||||||
XCTAssertTrue(element("gamutInspectL").exists)
|
XCTAssertTrue(element("gamutInspectL").exists)
|
||||||
XCTAssertTrue(element("gamutInspectSwatch").exists)
|
XCTAssertTrue(element("gamutInspectSwatch").exists)
|
||||||
|
closeGamutSheet()
|
||||||
}
|
}
|
||||||
|
|
||||||
func testResetIdentifierUnchanged() throws {
|
func testResetIdentifierUnchanged() throws {
|
||||||
openGamutSheet()
|
openGamutSheet()
|
||||||
let reset = waitFor("btnResetGamutCamera")
|
let reset = waitFor("btnResetGamutCamera")
|
||||||
XCTAssertTrue(reset.isEnabled)
|
XCTAssertTrue(reset.isEnabled)
|
||||||
|
closeGamutSheet()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `btnCloseGamut` is always enabled — including on the fallback
|
||||||
|
/// banner — and dismisses the sheet (#147).
|
||||||
|
func testCloseButtonDismissesSheet() throws {
|
||||||
|
openGamutSheet()
|
||||||
|
|
||||||
|
let close = waitFor("btnCloseGamut")
|
||||||
|
XCTAssertTrue(close.isEnabled)
|
||||||
|
close.click()
|
||||||
|
waitForGone("gamutView")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fallback banner exists exactly when the host lacks Metal —
|
||||||
|
/// no `SCNView` is mounted on a GPU-less runner, and none may be
|
||||||
|
/// reported unavailable on a GPU host.
|
||||||
|
func testFallbackBannerMatchesGPUAvailability() throws {
|
||||||
|
openGamutSheet()
|
||||||
|
|
||||||
|
if hasGPU {
|
||||||
|
XCTAssertFalse(
|
||||||
|
element("gamutViewerUnavailable").exists,
|
||||||
|
"GPU host must mount the SceneKit view, not the fallback")
|
||||||
|
} else {
|
||||||
|
_ = waitFor("gamutViewerUnavailable")
|
||||||
|
}
|
||||||
|
closeGamutSheet()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `ICCERY_TEST_SKIP_SCENEKIT=1` forces the fallback even on a GPU
|
||||||
|
/// host — banner plus a working Close, no `SCNView` mounted (#147).
|
||||||
|
/// The env is set for this test only; the default launch env must
|
||||||
|
/// not carry it, or CI's future GPU run would skip SceneKit too.
|
||||||
|
func testForcedSceneKitSkipShowsBannerAndClose() throws {
|
||||||
|
app.launchEnvironment["ICCERY_TEST_SKIP_SCENEKIT"] = "1"
|
||||||
|
openGamutSheet()
|
||||||
|
|
||||||
|
_ = waitFor("gamutViewerUnavailable")
|
||||||
|
let close = waitFor("btnCloseGamut")
|
||||||
|
XCTAssertTrue(close.isEnabled)
|
||||||
|
close.click()
|
||||||
|
waitForGone("gamutView")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,6 +113,59 @@ final class Milestone3UITests: XCTestCase {
|
|||||||
return recordedLpArgv()
|
return recordedLpArgv()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drags `#galleryPage-0`'s TIFF upward so `identifier`'s button
|
||||||
|
/// moves up, clear of the Dock collision zone at the window's
|
||||||
|
/// bottom edge (#132).
|
||||||
|
///
|
||||||
|
/// macOS overlay scrollbars are not in the AX tree — never use
|
||||||
|
/// `app.scrollBars` — and a synthesized scroll wheel is inert on
|
||||||
|
/// this LazyVGrid, so the scroll is a real drag on the gallery
|
||||||
|
/// cell's content. A stale/off-screen AX frame resolves to a screen
|
||||||
|
/// point that can be a Dock icon — a coordinate click there once
|
||||||
|
/// opened Calendar instead of Print. Callers must click only when
|
||||||
|
/// the returned element `isHittable`; never coordinate-click a
|
||||||
|
/// stale frame.
|
||||||
|
@discardableResult
|
||||||
|
private func scrollStage2UntilHittable(
|
||||||
|
_ identifier: String,
|
||||||
|
timeout: TimeInterval = 20
|
||||||
|
) -> XCUIElement {
|
||||||
|
var button = app.buttons[identifier]
|
||||||
|
let cell = app.descendants(matching: .any)["galleryPage-0"].firstMatch
|
||||||
|
XCTAssertTrue(cell.waitForExistence(timeout: 10), "galleryPage-0")
|
||||||
|
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
let windowBottom = app.windows.firstMatch.frame.maxY
|
||||||
|
if button.exists, button.isHittable,
|
||||||
|
button.frame.maxY < windowBottom - 80 {
|
||||||
|
return button
|
||||||
|
}
|
||||||
|
// Grab the upper half of the cell (the TIFF, not the Print
|
||||||
|
// button / Dock) and drag toward the top of the window.
|
||||||
|
// Mouse moves UP ⇒ gallery content moves UP ⇒ Print leaves
|
||||||
|
// the Dock zone.
|
||||||
|
if cell.isHittable {
|
||||||
|
let start = cell.coordinate(withNormalizedOffset:
|
||||||
|
CGVector(dx: 0.5, dy: 0.25))
|
||||||
|
let end = start.withOffset(CGVector(dx: 0, dy: -280))
|
||||||
|
start.press(forDuration: 0.15, thenDragTo: end)
|
||||||
|
} else {
|
||||||
|
// Cell not hit-testable: drag the stage-2 content
|
||||||
|
// directly — still content, still never scrollBars.
|
||||||
|
let scrollView = app.scrollViews["stage-2"]
|
||||||
|
scrollView.coordinate(withNormalizedOffset:
|
||||||
|
CGVector(dx: 0.5, dy: 0.55))
|
||||||
|
.press(forDuration: 0.15, thenDragTo:
|
||||||
|
scrollView.coordinate(withNormalizedOffset:
|
||||||
|
CGVector(dx: 0.5, dy: 0.15)))
|
||||||
|
}
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.4))
|
||||||
|
button = app.buttons[identifier]
|
||||||
|
}
|
||||||
|
return button
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Tests
|
// MARK: - Tests
|
||||||
|
|
||||||
/// Panel appears after the manifest; refresh populates the printer
|
/// Panel appears after the manifest; refresh populates the printer
|
||||||
@@ -199,36 +252,16 @@ final class Milestone3UITests: XCTestCase {
|
|||||||
XCTAssertTrue(app.buttons["btnPrintAll"].isEnabled)
|
XCTAssertTrue(app.buttons["btnPrintAll"].isEnabled)
|
||||||
|
|
||||||
// The gallery cell's Print button sits at the window's bottom
|
// The gallery cell's Print button sits at the window's bottom
|
||||||
// edge where synthesized scroll-wheel events are inert on the
|
// edge; scroll until it is genuinely hittable (#132). Never
|
||||||
// LazyVGrid (#132). Drag the NSScrollView's vertical AXScrollBar
|
// coordinate-click a stale frame — that point can be the Dock.
|
||||||
// thumb instead — a real scroll that re-renders the cell onscreen.
|
let printPage = scrollStage2UntilHittable("btnPrintPage-0")
|
||||||
var printPage = app.buttons["btnPrintPage-0"]
|
guard printPage.isHittable else {
|
||||||
let scrollDeadline = Date().addingTimeInterval(15)
|
|
||||||
while !printPage.isHittable, Date() < scrollDeadline {
|
|
||||||
let scroller = app.scrollBars.allElementsBoundByIndex
|
|
||||||
.first { $0.frame.height > $0.frame.width }
|
|
||||||
if let scroller {
|
|
||||||
scroller.coordinate(withNormalizedOffset:
|
|
||||||
CGVector(dx: 0.5, dy: 0.1))
|
|
||||||
.press(forDuration: 0.1, thenDragTo:
|
|
||||||
scroller.coordinate(withNormalizedOffset:
|
|
||||||
CGVector(dx: 0.5, dy: 0.6)))
|
|
||||||
} else {
|
|
||||||
app.scrollViews["stage-2"].scroll(byDeltaX: 0, deltaY: -1)
|
|
||||||
}
|
|
||||||
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
|
|
||||||
printPage = app.buttons["btnPrintPage-0"]
|
|
||||||
}
|
|
||||||
if printPage.isHittable {
|
|
||||||
printPage.click()
|
|
||||||
} else {
|
|
||||||
// LazyVGrid cells can report a stale a11y frame — click the
|
|
||||||
// point directly; the lp argv assert below still verifies.
|
|
||||||
print("AXTREE-BEGIN frame=\(printPage.frame)\n" +
|
print("AXTREE-BEGIN frame=\(printPage.frame)\n" +
|
||||||
"\(app.debugDescription)\nAXTREE-END")
|
"\(app.debugDescription)\nAXTREE-END")
|
||||||
printPage.coordinate(withNormalizedOffset:
|
XCTFail("btnPrintPage-0 never became hittable; frame=\(printPage.frame)")
|
||||||
CGVector(dx: 0.5, dy: 0.5)).click()
|
return
|
||||||
}
|
}
|
||||||
|
printPage.click()
|
||||||
let argv = waitForLpLine()
|
let argv = waitForLpLine()
|
||||||
XCTAssertTrue(argv.contains("AP_ColorMatchingMode"), argv)
|
XCTAssertTrue(argv.contains("AP_ColorMatchingMode"), argv)
|
||||||
XCTAssertTrue(argv.contains("page1.tif"), argv)
|
XCTAssertTrue(argv.contains("page1.tif"), argv)
|
||||||
|
|||||||
@@ -86,6 +86,60 @@ final class Milestone6CalibrationUITests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stage 0 must not push the sidebar off-screen: the macOS `Form`
|
||||||
|
/// rows with expanding spacers once gave the stage an unbounded ideal
|
||||||
|
/// width, and window centering shifted the 270 pt sidebar into
|
||||||
|
/// negative X (issue #163). AX-tree existence checks cannot see that,
|
||||||
|
/// so assert real frame geometry.
|
||||||
|
func testCalibrationViewDoesNotOverflowWindow() throws {
|
||||||
|
let calButton = app.buttons["btnCalibratePrinter"]
|
||||||
|
XCTAssertTrue(calButton.waitForExistence(timeout: 10))
|
||||||
|
calButton.tap()
|
||||||
|
|
||||||
|
XCTAssertTrue(app.staticTexts["Calibrate Printer"].waitForExistence(timeout: 5))
|
||||||
|
|
||||||
|
let window = app.windows.firstMatch
|
||||||
|
XCTAssertTrue(window.exists)
|
||||||
|
XCTAssertGreaterThanOrEqual(calButton.frame.minX, 0)
|
||||||
|
XCTAssertLessThanOrEqual(calButton.frame.maxX, window.frame.maxX)
|
||||||
|
let ret = app.buttons["btnCalReturn"]
|
||||||
|
XCTAssertTrue(ret.waitForExistence(timeout: 5))
|
||||||
|
XCTAssertTrue(ret.isHittable)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Return to Profiling" is the single Stage 0 exit and carries the
|
||||||
|
/// cancel-action shortcut, so Escape must dismiss the dashboard too
|
||||||
|
/// (issue #163). `typeKey` delivery is unreliable on the macOS 12 CI
|
||||||
|
/// runner (m10 phase-08), so the Escape check falls back to the
|
||||||
|
/// deterministic button tap.
|
||||||
|
func testCalibrationReturnButtonAndEscapeDismiss() throws {
|
||||||
|
let calButton = app.buttons["btnCalibratePrinter"]
|
||||||
|
XCTAssertTrue(calButton.waitForExistence(timeout: 10))
|
||||||
|
calButton.tap()
|
||||||
|
XCTAssertTrue(app.staticTexts["Calibrate Printer"].waitForExistence(timeout: 5))
|
||||||
|
|
||||||
|
let returnButton = app.buttons["btnCalReturn"]
|
||||||
|
XCTAssertTrue(returnButton.waitForExistence(timeout: 5))
|
||||||
|
XCTAssertTrue(returnButton.isHittable)
|
||||||
|
returnButton.tap()
|
||||||
|
|
||||||
|
let stage1 = app.descendants(matching: .any)["stage-1"]
|
||||||
|
XCTAssertTrue(stage1.waitForExistence(timeout: 5))
|
||||||
|
|
||||||
|
// Re-enter and try Escape; fall back to the button where the
|
||||||
|
// runtime does not deliver typeKey.
|
||||||
|
XCTAssertTrue(calButton.waitForExistence(timeout: 5))
|
||||||
|
calButton.tap()
|
||||||
|
XCTAssertTrue(app.staticTexts["Calibrate Printer"].waitForExistence(timeout: 5))
|
||||||
|
|
||||||
|
app.typeKey(XCUIKeyboardKey.escape, modifierFlags: [])
|
||||||
|
if !stage1.waitForExistence(timeout: 4) {
|
||||||
|
XCTAssertTrue(returnButton.waitForExistence(timeout: 5))
|
||||||
|
returnButton.tap()
|
||||||
|
XCTAssertTrue(stage1.waitForExistence(timeout: 5))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A failing calibration targen surfaces the error through the
|
/// A failing calibration targen surfaces the error through the
|
||||||
/// wizard notice and restores the original basename (issue #80).
|
/// wizard notice and restores the original basename (issue #80).
|
||||||
func testCalibrationTargenFailureRestoresBasename() throws {
|
func testCalibrationTargenFailureRestoresBasename() throws {
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import Metal
|
||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
/// Milestone 6 — Issue #28 native SceneKit gamut viewer acceptance tests.
|
/// Milestone 6 — Issue #28 native SceneKit gamut viewer acceptance tests.
|
||||||
@MainActor
|
@MainActor
|
||||||
final class Milestone6GamutUITests: XCTestCase {
|
final class Milestone6GamutUITests: XCTestCase {
|
||||||
|
|
||||||
|
/// Metal on the test host — the app under test runs on the same
|
||||||
|
/// machine, so this predicts whether the sheet mounts SceneKit.
|
||||||
|
private var hasGPU: Bool { MTLCreateSystemDefaultDevice() != nil }
|
||||||
|
|
||||||
private var app: XCUIApplication!
|
private var app: XCUIApplication!
|
||||||
private var testRoot: URL!
|
private var testRoot: URL!
|
||||||
private var binDir: URL!
|
private var binDir: URL!
|
||||||
@@ -64,6 +69,10 @@ final class Milestone6GamutUITests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override func tearDown() async throws {
|
override func tearDown() async throws {
|
||||||
|
// Never leave the gamut sheet up for `terminate()` (#147).
|
||||||
|
if app != nil, element("btnCloseGamut").exists {
|
||||||
|
element("btnCloseGamut").click()
|
||||||
|
}
|
||||||
app?.terminate()
|
app?.terminate()
|
||||||
app = nil
|
app = nil
|
||||||
if let testRoot {
|
if let testRoot {
|
||||||
@@ -90,10 +99,29 @@ final class Milestone6GamutUITests: XCTestCase {
|
|||||||
return el
|
return el
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Inverse of `waitFor` — polls until the element leaves the tree.
|
||||||
|
private func waitForGone(_ id: String, timeout: TimeInterval = 10) {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
if !element(id).exists { return }
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
|
}
|
||||||
|
XCTAssertFalse(element(id).exists, "Expected element \(id) to disappear")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `btnCloseGamut` dismisses the sheet so `tearDown`'s `terminate()`
|
||||||
|
/// is not stuck behind a key sheet (#147). No-op when already closed.
|
||||||
|
private func closeGamutSheet() {
|
||||||
|
let close = element("btnCloseGamut")
|
||||||
|
guard close.waitForExistence(timeout: 5) else { return }
|
||||||
|
close.click()
|
||||||
|
waitForGone("gamutView")
|
||||||
|
}
|
||||||
|
|
||||||
/// Build and verify the mock profile, then open the native gamut viewer.
|
/// Build and verify the mock profile, then open the native gamut viewer.
|
||||||
/// The viewer should load both the reference sRGB mesh and the profile
|
/// The viewer should load both the reference sRGB mesh and the profile
|
||||||
/// gamut copied from that reference.
|
/// gamut copied from that reference.
|
||||||
func testViewGamutOpensSceneKitSheet() throws {
|
private func openGamutSheet() {
|
||||||
app.launch()
|
app.launch()
|
||||||
if !app.wait(for: .runningForeground, timeout: 10) {
|
if !app.wait(for: .runningForeground, timeout: 10) {
|
||||||
app.activate()
|
app.activate()
|
||||||
@@ -105,6 +133,12 @@ final class Milestone6GamutUITests: XCTestCase {
|
|||||||
|
|
||||||
waitFor("btnViewGamut").click()
|
waitFor("btnViewGamut").click()
|
||||||
|
|
||||||
|
_ = waitFor("gamutView")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testViewGamutOpensSceneKitSheet() throws {
|
||||||
|
openGamutSheet()
|
||||||
|
|
||||||
let gamutView = waitFor("gamutView")
|
let gamutView = waitFor("gamutView")
|
||||||
XCTAssertTrue(gamutView.exists)
|
XCTAssertTrue(gamutView.exists)
|
||||||
|
|
||||||
@@ -112,9 +146,33 @@ final class Milestone6GamutUITests: XCTestCase {
|
|||||||
let value = status.value as? String ?? ""
|
let value = status.value as? String ?? ""
|
||||||
XCTAssertTrue(value.contains("faces"), "Gamut status should report mesh faces, got: \(value)")
|
XCTAssertTrue(value.contains("faces"), "Gamut status should report mesh faces, got: \(value)")
|
||||||
|
|
||||||
|
// The fallback banner appears exactly when the host lacks Metal
|
||||||
|
// — no SCNView is constructed without a GPU (#147).
|
||||||
|
if hasGPU {
|
||||||
|
XCTAssertFalse(
|
||||||
|
element("gamutViewerUnavailable").exists,
|
||||||
|
"GPU host must mount the SceneKit view, not the fallback")
|
||||||
|
} else {
|
||||||
|
_ = waitFor("gamutViewerUnavailable")
|
||||||
|
}
|
||||||
|
|
||||||
// The reset button demonstrates that the viewer is interactive.
|
// The reset button demonstrates that the viewer is interactive.
|
||||||
let reset = waitFor("btnResetGamutCamera")
|
let reset = waitFor("btnResetGamutCamera")
|
||||||
XCTAssertTrue(reset.isEnabled)
|
XCTAssertTrue(reset.isEnabled)
|
||||||
|
|
||||||
|
closeGamutSheet()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clicking Reset drives the live `SCNView` — runs only on Metal
|
||||||
|
/// hosts, skipped on GPU-less runners so the same suite exercises
|
||||||
|
/// 3D once CI has a GPU (#147).
|
||||||
|
func testResetCameraInteractsWithScene() throws {
|
||||||
|
guard hasGPU else { throw XCTSkip("No Metal") }
|
||||||
|
openGamutSheet()
|
||||||
|
|
||||||
|
let reset = waitFor("btnResetGamutCamera")
|
||||||
reset.click()
|
reset.click()
|
||||||
|
|
||||||
|
closeGamutSheet()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -30,6 +30,11 @@ if [ -z "$TAG" ] || [ "$TAG" = "${GITHUB_REF:-}" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
case "$TAG" in
|
||||||
|
*prerelease*) PRERELEASE=true ;;
|
||||||
|
*) PRERELEASE=false ;;
|
||||||
|
esac
|
||||||
|
|
||||||
DMG="${1:-}"
|
DMG="${1:-}"
|
||||||
if [ -z "$DMG" ]; then
|
if [ -z "$DMG" ]; then
|
||||||
DMG="$(ls -1 ICCery-*.dmg 2>/dev/null | head -n 1 || true)"
|
DMG="$(ls -1 ICCery-*.dmg 2>/dev/null | head -n 1 || true)"
|
||||||
@@ -49,12 +54,12 @@ STATUS="$(curl -sS -o "$BODY" -w '%{http_code}' \
|
|||||||
"$API/repos/$REPO/releases/tags/$TAG" || true)"
|
"$API/repos/$REPO/releases/tags/$TAG" || true)"
|
||||||
|
|
||||||
if [ "$STATUS" = "404" ]; then
|
if [ "$STATUS" = "404" ]; then
|
||||||
echo "==> Creating release $TAG"
|
echo "==> Creating release $TAG (prerelease=$PRERELEASE)"
|
||||||
STATUS="$(curl -sS -o "$BODY" -w '%{http_code}' \
|
STATUS="$(curl -sS -o "$BODY" -w '%{http_code}' \
|
||||||
-H "Authorization: token $TOKEN" \
|
-H "Authorization: token $TOKEN" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-X POST "$API/repos/$REPO/releases" \
|
-X POST "$API/repos/$REPO/releases" \
|
||||||
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"prerelease\":true,\"target_commitish\":\"${GITHUB_SHA:-}\"}")"
|
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"prerelease\":$PRERELEASE,\"target_commitish\":\"${GITHUB_SHA:-}\"}")"
|
||||||
fi
|
fi
|
||||||
if [ "$STATUS" != "200" ] && [ "$STATUS" != "201" ]; then
|
if [ "$STATUS" != "200" ] && [ "$STATUS" != "201" ]; then
|
||||||
echo "error: could not load/create release $TAG (HTTP $STATUS)" >&2
|
echo "error: could not load/create release $TAG (HTTP $STATUS)" >&2
|
||||||
|
|||||||
Reference in New Issue
Block a user