Compare commits

..
Author SHA1 Message Date
gronod 407136c5a2 fix(ui): calibrate dashboard no longer overflows window; Escape exits (#163)
macOS CI / build-and-test (pull_request) Successful in 6m4s
macOS CI / package (pull_request) Skipped
macOS CI / build-and-test (push) Canceled after 1m44s
macOS CI / package (push) Skipped
2026-09-13 22:16:01 +01:00
gronod 83b5f6bf75 Merge pull request 'ci(macos): skip tests on tags containing "prerelease"' (#162) from chore/ci-prerelease-skip into develop
macOS CI / package (push) Canceled after 0s
macOS CI / build-and-test (push) Canceled after 16s
Reviewed-on: #162
2026-09-13 21:04:38 +01:00
gronod 16df18127c ci(macos): skip tests on tags containing "prerelease"
macOS CI / build-and-test (pull_request) Successful in 37m58s
macOS CI / package (pull_request) Skipped
2026-09-13 20:41:32 +01:00
gronod 78baa49e61 Merge pull request 'fix(gamut): Close button; skip SCNView without Metal (#147)' (#161) from fix/147-gamut-close-no-gpu into milestone/m10-studio
macOS CI / build-and-test (pull_request) Successful in 32m11s
macOS CI / package (pull_request) Skipped
macOS CI / package (push) Canceled after 0s
macOS CI / build-and-test (push) Canceled after 13m23s
2026-09-13 18:49:35 +01:00
gronod 04a563c54a test(print): scroll gallery until btnPrintPage is hittable (#132) 2026-09-13 18:24:43 +01:00
gronod 2cbd43470d fix(gamut): Close button; skip SCNView without Metal (#147)
Refs #147 #146
2026-09-13 18:24:43 +01:00
gronod 14f9e6f78d Merge pull request 'fix(gamut): don't disable layer toggles when SceneKit is unavailable (#147)' (#160) from fix/147-gamut-toggle-disabled into milestone/m10-studio
macOS CI / package (pull_request) Canceled after 0s
macOS CI / build-and-test (pull_request) Canceled after 5m30s
2026-09-13 17:05:05 +01:00
gronod f16825be72 fix(gamut): don't disable layer toggles when SceneKit is unavailable (#147)
Refs #147 #146
2026-09-13 17:03:56 +01:00
gronod 9fcd21dc2d Merge pull request 'test(m10): combine gamut layer toggle AX; harden gallery wait on macOS 12' (#159) from fix/m10-ci-ui-macos12 into milestone/m10-studio
macOS CI / package (pull_request) Canceled after 0s
macOS CI / build-and-test (pull_request) Canceled after 30m21s
2026-09-13 15:53:48 +01:00
gronod e967502ea7 Merge pull request 'test(m10): wait for gamut layer enable; drive New via File menu on macOS 12' (#158) from fix/m10-ci-ui-macos12 into milestone/m10-studio
macOS CI / build-and-test (pull_request) Failing after 32m15s
macOS CI / package (pull_request) Skipped
2026-09-13 14:59:45 +01:00
13 changed files with 458 additions and 125 deletions
+11 -2
View File
@@ -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
+6
View File
@@ -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)
+121 -88
View File
@@ -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"
)
} }
} }
+22 -2
View File
@@ -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() }
} }
+15
View File
@@ -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() }
} }
+1
View File
@@ -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")
} }
} }
+60 -27
View File
@@ -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
+7 -2
View File
@@ -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