diff --git a/Sources/ICCery/AppEnvironment.swift b/Sources/ICCery/AppEnvironment.swift index 5fdb687..88d0bd5 100644 --- a/Sources/ICCery/AppEnvironment.swift +++ b/Sources/ICCery/AppEnvironment.swift @@ -97,6 +97,12 @@ enum UITestHooks { static var projectSaveURL: URL? { url("ICCERY_TEST_PROJECT_SAVE") } /// Relocate-folder result when a project's `cwd` is missing (#149). 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) diff --git a/Sources/ICCery/GamutView.swift b/Sources/ICCery/GamutView.swift index a683049..63bfdcf 100644 --- a/Sources/ICCery/GamutView.swift +++ b/Sources/ICCery/GamutView.swift @@ -74,6 +74,7 @@ struct GamutView: View { @StateObject private var viewModel: GamutViewModel @State private var pause: () -> Void = {} @FocusState private var isFocused: Bool + @Environment(\.dismiss) private var dismiss @Binding var showingAllHelp: Bool init( @@ -94,6 +95,8 @@ struct GamutView: View { Divider().overlay(Theme.border) statusLine inspectPanel + Divider().overlay(Theme.border) + footer } .frame(minWidth: 720, minHeight: 520) .background(Theme.background) @@ -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 private var tiffPreviewSheet: some View { @@ -381,7 +399,9 @@ private struct GamutSceneView: NSViewRepresentable { context.coordinator.installKeyMonitor() 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 { DispatchQueue.main.async { onUnavailable() } } diff --git a/Sources/ICCery/GamutViewModel.swift b/Sources/ICCery/GamutViewModel.swift index 6225f70..f7f7e75 100644 --- a/Sources/ICCery/GamutViewModel.swift +++ b/Sources/ICCery/GamutViewModel.swift @@ -1,8 +1,21 @@ import Combine import Foundation import ICCeryCore +import Metal 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). /// /// 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) { self.environment = environment self.profileGamURL = profileGamURL + // Never let the view mount an SCNView without Metal (#147). + viewerUnavailable = !GamutSceneAvailability.isAvailable loadTask = Task { await load() } } diff --git a/Tests/ICCeryCoreTests/GamutViewModelTests.swift b/Tests/ICCeryCoreTests/GamutViewModelTests.swift index b284a7e..f93108b 100644 --- a/Tests/ICCeryCoreTests/GamutViewModelTests.swift +++ b/Tests/ICCeryCoreTests/GamutViewModelTests.swift @@ -1,4 +1,5 @@ import Foundation +import Metal import XCTest @testable import ICCeryCore @testable import ICCery @@ -36,6 +37,15 @@ final class GamutViewModelTests: XCTestCase { 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 { let vm = try makeViewModel() await vm.awaitInitialLoad() diff --git a/Tests/ICCeryUITests/Milestone10GamutCompareUITests.swift b/Tests/ICCeryUITests/Milestone10GamutCompareUITests.swift index 4ec0519..5ec28d4 100644 --- a/Tests/ICCeryUITests/Milestone10GamutCompareUITests.swift +++ b/Tests/ICCeryUITests/Milestone10GamutCompareUITests.swift @@ -1,4 +1,5 @@ import Foundation +import Metal import XCTest /// Milestone 10 — Issue #147 gamut compare chrome tests. @@ -9,6 +10,11 @@ import XCTest @MainActor 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 testRoot: URL! private var binDir: URL! @@ -46,6 +52,10 @@ final class Milestone10GamutCompareUITests: XCTestCase { } 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 = nil if let testRoot { @@ -104,6 +114,25 @@ final class Milestone10GamutCompareUITests: XCTestCase { _ = 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 { openGamutSheet() @@ -119,6 +148,7 @@ final class Milestone10GamutCompareUITests: XCTestCase { 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 { @@ -132,6 +162,7 @@ final class Milestone10GamutCompareUITests: XCTestCase { let status = waitFor("gamutStatusText") let value = status.value as? String ?? "" XCTAssertTrue(value.contains("sRGB"), "Status should keep the sRGB clause, got: \(value)") + closeGamutSheet() } func testCompareGamLoadEnablesToggle() throws { @@ -154,6 +185,7 @@ final class Milestone10GamutCompareUITests: XCTestCase { let remove = waitFor("btnGamutRemoveCompare") XCTAssertTrue(remove.isEnabled) + closeGamutSheet() } func testOpenProfileRunsIccgamutForCompare() throws { @@ -175,6 +207,7 @@ final class Milestone10GamutCompareUITests: XCTestCase { 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 { @@ -184,6 +217,7 @@ final class Milestone10GamutCompareUITests: XCTestCase { XCTAssertTrue(panel.exists) XCTAssertTrue(element("gamutInspectIdle").exists) XCTAssertTrue(element("gamutStatusText").exists) + closeGamutSheet() } func testManualLabInspectShowsContainment() throws { @@ -203,11 +237,55 @@ final class Milestone10GamutCompareUITests: XCTestCase { XCTAssertTrue(value.contains("in"), "Lab(50,0,0) should be inside sRGB, got: \(value)") XCTAssertTrue(element("gamutInspectL").exists) XCTAssertTrue(element("gamutInspectSwatch").exists) + closeGamutSheet() } func testResetIdentifierUnchanged() throws { openGamutSheet() let reset = waitFor("btnResetGamutCamera") 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") } } diff --git a/Tests/ICCeryUITests/Milestone3UITests.swift b/Tests/ICCeryUITests/Milestone3UITests.swift index 8ea1f86..bb20e1d 100644 --- a/Tests/ICCeryUITests/Milestone3UITests.swift +++ b/Tests/ICCeryUITests/Milestone3UITests.swift @@ -113,6 +113,59 @@ final class Milestone3UITests: XCTestCase { 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 /// Panel appears after the manifest; refresh populates the printer @@ -199,36 +252,16 @@ final class Milestone3UITests: XCTestCase { XCTAssertTrue(app.buttons["btnPrintAll"].isEnabled) // The gallery cell's Print button sits at the window's bottom - // edge where synthesized scroll-wheel events are inert on the - // LazyVGrid (#132). Drag the NSScrollView's vertical AXScrollBar - // thumb instead — a real scroll that re-renders the cell onscreen. - var printPage = app.buttons["btnPrintPage-0"] - let scrollDeadline = Date().addingTimeInterval(15) - while !printPage.isHittable, Date() < scrollDeadline { - let scroller = app.scrollBars.allElementsBoundByIndex - .first { $0.frame.height > $0.frame.width } - if let scroller { - scroller.coordinate(withNormalizedOffset: - CGVector(dx: 0.5, dy: 0.1)) - .press(forDuration: 0.1, thenDragTo: - scroller.coordinate(withNormalizedOffset: - CGVector(dx: 0.5, dy: 0.6))) - } else { - app.scrollViews["stage-2"].scroll(byDeltaX: 0, deltaY: -1) - } - RunLoop.current.run(until: Date().addingTimeInterval(0.5)) - printPage = app.buttons["btnPrintPage-0"] - } - if printPage.isHittable { - printPage.click() - } else { - // LazyVGrid cells can report a stale a11y frame — click the - // point directly; the lp argv assert below still verifies. + // edge; scroll until it is genuinely hittable (#132). Never + // coordinate-click a stale frame — that point can be the Dock. + let printPage = scrollStage2UntilHittable("btnPrintPage-0") + guard printPage.isHittable else { print("AXTREE-BEGIN frame=\(printPage.frame)\n" + "\(app.debugDescription)\nAXTREE-END") - printPage.coordinate(withNormalizedOffset: - CGVector(dx: 0.5, dy: 0.5)).click() + XCTFail("btnPrintPage-0 never became hittable; frame=\(printPage.frame)") + return } + printPage.click() let argv = waitForLpLine() XCTAssertTrue(argv.contains("AP_ColorMatchingMode"), argv) XCTAssertTrue(argv.contains("page1.tif"), argv) diff --git a/Tests/ICCeryUITests/Milestone6GamutUITests.swift b/Tests/ICCeryUITests/Milestone6GamutUITests.swift index df67c68..b404bb9 100644 --- a/Tests/ICCeryUITests/Milestone6GamutUITests.swift +++ b/Tests/ICCeryUITests/Milestone6GamutUITests.swift @@ -1,10 +1,15 @@ import Foundation +import Metal import XCTest /// Milestone 6 — Issue #28 native SceneKit gamut viewer acceptance tests. @MainActor 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 testRoot: URL! private var binDir: URL! @@ -64,6 +69,10 @@ final class Milestone6GamutUITests: XCTestCase { } 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 = nil if let testRoot { @@ -90,10 +99,29 @@ final class Milestone6GamutUITests: XCTestCase { 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. /// The viewer should load both the reference sRGB mesh and the profile /// gamut copied from that reference. - func testViewGamutOpensSceneKitSheet() throws { + private func openGamutSheet() { app.launch() if !app.wait(for: .runningForeground, timeout: 10) { app.activate() @@ -105,6 +133,12 @@ final class Milestone6GamutUITests: XCTestCase { waitFor("btnViewGamut").click() + _ = waitFor("gamutView") + } + + func testViewGamutOpensSceneKitSheet() throws { + openGamutSheet() + let gamutView = waitFor("gamutView") XCTAssertTrue(gamutView.exists) @@ -112,9 +146,33 @@ final class Milestone6GamutUITests: XCTestCase { let value = status.value as? String ?? "" 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. let reset = waitFor("btnResetGamutCamera") 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() + + closeGamutSheet() } } diff --git a/docs/21-ui-reference.md b/docs/21-ui-reference.md index 3cc677f..e49abb0 100644 --- a/docs/21-ui-reference.md +++ b/docs/21-ui-reference.md @@ -78,7 +78,7 @@ Keyboard: **R** resets gamut camera when Stage 5 is visible. Bind to a focusable | Profile install collision | `profileInstallCollisionDialog` | `profileInstallCollisionMessage`, `profileOverwriteBtn`, `profileRenameBtn`, `profileCancelCollisionBtn` | | Spot read | `spotReadView` | `btnSpotDetectInstruments`, `spotDetectError`, `spotInstrumentSelect`, `spotDefaultMissing`, `spotSetDefault`, `spotXYHint`, `spotPrompt`, `spotLastError`, `spotLogContainer`, `spotLog`, `btnSpotStart`, `btnSpotCalibrate`, `btnSpotTrigger`, `btnSpotStop`, `spotLastSample`, `spotLastEmpty`, `spotLabL`, `spotLabA`, `spotLabB`, `spotXYZ`, `spotSwatch`, `spotDeltaE`, `spotLastInstrument`, `spotLabImplausible`, `spotHistoryTable`, `spotHistoryEmpty`, `spotHistoryRow-{uuid}`, `btnSpotCopyLab`, `btnSpotExportCsv`, `spotSidecarMissing`, `btnCloseSpotRead` | | Project relocate | `projectRelocateSheet` | `btnProjectRelocate`, `btnProjectRelocateCancel` | -| Gamut viewer | `gamutView` | `gamutLayer-sRGB`, `gamutLayer-profile`, `gamutLayer-compare`, `btnGamutAddCompare`, `btnGamutOpenGam`, `btnGamutOpenProfile`, `btnGamutRemoveCompare`, `btnGamutSampleTiff`, `btnResetGamutCamera`, `gamutStatusText`, `gamutNoticeText`, `gamutInspectPanel`, `gamutInspectIdle`, `gamutInspectL`, `gamutInspectA`, `gamutInspectB`, `gamutInspect-sRGB`, `gamutInspect-profile`, `gamutInspect-compare`, `gamutInspectSwatch`, `gamutInspectApprox`, `gamutLabEntryL`, `gamutLabEntryA`, `gamutLabEntryB`, `btnGamutInspectLab`, `gamutTiffPreview`, `btnCloseGamutTiffPreview`, `gamutViewerUnavailable` | +| Gamut viewer | `gamutView` | `gamutLayer-sRGB`, `gamutLayer-profile`, `gamutLayer-compare`, `btnGamutAddCompare`, `btnGamutOpenGam`, `btnGamutOpenProfile`, `btnGamutRemoveCompare`, `btnGamutSampleTiff`, `btnResetGamutCamera`, `gamutStatusText`, `gamutNoticeText`, `gamutInspectPanel`, `gamutInspectIdle`, `gamutInspectL`, `gamutInspectA`, `gamutInspectB`, `gamutInspect-sRGB`, `gamutInspect-profile`, `gamutInspect-compare`, `gamutInspectSwatch`, `gamutInspectApprox`, `gamutLabEntryL`, `gamutLabEntryA`, `gamutLabEntryB`, `btnGamutInspectLab`, `gamutTiffPreview`, `btnCloseGamutTiffPreview`, `gamutViewerUnavailable`, `btnCloseGamut` | ## Dialogs must go through host APIs