Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a8ad4d5d4 | ||
|
|
c2ac9341c8 | ||
|
|
1c310706e4 | ||
|
|
7f6c47d85c | ||
|
|
3b6323378e | ||
|
|
ad7b91cf91 | ||
|
|
8b931e3625 | ||
|
|
73b18dec5b | ||
|
|
3bc0d14a34 | ||
|
|
4aa2815c6e | ||
|
|
8596f15d52 | ||
|
|
407136c5a2 | ||
|
|
83b5f6bf75 | ||
|
|
16df18127c | ||
|
|
78baa49e61 | ||
|
|
04a563c54a | ||
|
|
2cbd43470d | ||
|
|
14f9e6f78d | ||
|
|
f16825be72 | ||
|
|
9fcd21dc2d | ||
|
|
e967502ea7 |
@@ -30,19 +30,26 @@ jobs:
|
||||
fi
|
||||
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
|
||||
# installed on macOS 12 (#109). The script installs a pinned
|
||||
# prebuilt release instead.
|
||||
# prebuilt release instead. dmgbuild is not installed here — the
|
||||
# test job does not package (#95).
|
||||
- name: Ensure host tools
|
||||
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||
run: scripts/ensure-host-tools.sh
|
||||
|
||||
- name: Generate Xcode project
|
||||
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||
run: xcodegen generate --spec project.yml
|
||||
|
||||
# Tests only ever run on the runner's own architecture; build
|
||||
# just that slice. Packaging (scripts/package-release.sh) still
|
||||
# produces the universal Release binary.
|
||||
- name: Build for testing (host arch)
|
||||
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||
run: |
|
||||
xcodebuild build-for-testing \
|
||||
-scheme ICCery \
|
||||
@@ -60,6 +67,7 @@ jobs:
|
||||
# launch (run 31992) — so sign every embedded copy once the build is
|
||||
# done (embed steps run after any build script phase) (#119).
|
||||
- name: Sign package product frameworks
|
||||
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||
run: |
|
||||
find "$DERIVED/Build/Products/Debug" -depth -name '*_PackageProduct.framework' -print0 \
|
||||
| while IFS= read -r -d '' fw; do
|
||||
@@ -68,6 +76,7 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Test unit (ICCeryCoreTests)
|
||||
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||
run: |
|
||||
XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)"
|
||||
if [ -z "$XCTESTRUN" ] || [ ! -f "$XCTESTRUN" ]; then
|
||||
@@ -90,6 +99,7 @@ jobs:
|
||||
# tag packaging can proceed. Real XCTest assertion failures
|
||||
# still fail the job.
|
||||
- name: Test UI (ICCeryUITests)
|
||||
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||
run: |
|
||||
set -o pipefail
|
||||
XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)"
|
||||
@@ -154,7 +164,7 @@ jobs:
|
||||
# xcresult on failure — upload it so UI failures can be triaged
|
||||
# without access to the runner (#126).
|
||||
- name: Prepare Node CA bundle (failure path)
|
||||
if: failure()
|
||||
if: "failure() && !(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||
run: |
|
||||
NODE_CA_FILE="/tmp/macos-ca-bundle.pem"
|
||||
security find-certificate -a -p \
|
||||
@@ -166,7 +176,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Upload UI test xcresult
|
||||
if: failure()
|
||||
if: "failure() && !(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||
uses: actions/upload-artifact@v3
|
||||
env:
|
||||
NODE_EXTRA_CA_CERTS: /tmp/macos-ca-bundle.pem
|
||||
@@ -184,8 +194,10 @@ jobs:
|
||||
|
||||
# scripts/package-release.sh runs `xcodegen generate` and dmgbuild;
|
||||
# see build-and-test for why brew is not used on macOS 12 (#109).
|
||||
# INSTALL_DMGBUILD isolates dmgbuild in build/.venv-dmgbuild so
|
||||
# the test job never pip-installs it (#95).
|
||||
- name: Ensure host tools
|
||||
run: scripts/ensure-host-tools.sh
|
||||
run: INSTALL_DMGBUILD=1 scripts/ensure-host-tools.sh
|
||||
|
||||
- name: Package release
|
||||
run: scripts/package-release.sh
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -7,48 +7,90 @@ struct CalibrationView: View {
|
||||
@ObservedObject var wizard: WizardViewModel
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
VStack(spacing: 0) {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Calibrate Printer")
|
||||
.font(.title2.bold())
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 16)
|
||||
.foregroundStyle(Theme.text)
|
||||
wedgeSection
|
||||
workflowSection
|
||||
if !model.calibrationLog.isEmpty { logSection }
|
||||
}
|
||||
.padding(20)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.background(Theme.background)
|
||||
|
||||
Form {
|
||||
Section("Wedge Settings") {
|
||||
Divider().overlay(Theme.border)
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Return to Profiling", role: .cancel) { model.returnToProfiling() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
.accessibilityIdentifier("btnCalReturn")
|
||||
}
|
||||
.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 {
|
||||
HStack(spacing: 12) {
|
||||
Text("Steps per channel")
|
||||
Spacer()
|
||||
.foregroundStyle(Theme.text)
|
||||
.frame(width: 140, alignment: .leading)
|
||||
TextField("", value: $model.steps, format: .number)
|
||||
.frame(width: 60)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 70)
|
||||
.accessibilityIdentifier("calSteps")
|
||||
}
|
||||
|
||||
HStack {
|
||||
HStack(spacing: 12) {
|
||||
Text("White patches")
|
||||
Spacer()
|
||||
.foregroundStyle(Theme.text)
|
||||
.frame(width: 140, alignment: .leading)
|
||||
TextField("", value: $model.whitePatches, format: .number)
|
||||
.frame(width: 60)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 70)
|
||||
}
|
||||
|
||||
if model.colourSpace == .cmyk {
|
||||
HStack {
|
||||
HStack(spacing: 12) {
|
||||
Text("Ink-limit exploration")
|
||||
Spacer()
|
||||
.foregroundStyle(Theme.text)
|
||||
.frame(width: 140, alignment: .leading)
|
||||
TextField("", text: $model.inkLimit)
|
||||
.frame(width: 60)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 70)
|
||||
.accessibilityIdentifier("calInkExplore")
|
||||
}
|
||||
}
|
||||
|
||||
Toggle("Neutral emphasis", isOn: $model.includeNeutralEmphasis)
|
||||
.toggleStyle(.checkbox)
|
||||
.foregroundStyle(Theme.text)
|
||||
.accessibilityIdentifier("calNeutralEmphasis")
|
||||
}
|
||||
}
|
||||
|
||||
Section("Workflow") {
|
||||
// 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")
|
||||
@@ -73,6 +115,8 @@ struct CalibrationView: View {
|
||||
|
||||
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")
|
||||
|
||||
@@ -81,28 +125,17 @@ struct CalibrationView: View {
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Return to Profiling") { model.returnToProfiling() }
|
||||
.accessibilityIdentifier("btnCalReturn")
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
// 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
|
||||
@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)
|
||||
@@ -142,7 +145,7 @@ struct GamutView: View {
|
||||
Text(layer?.displayName ?? fallback)
|
||||
}
|
||||
.toggleStyle(.checkbox)
|
||||
.disabled(layer == nil || viewModel.viewerUnavailable)
|
||||
.disabled(layer == nil)
|
||||
.help(layer.map { $0.sourceURL.lastPathComponent } ?? "No profile .gam loaded")
|
||||
// macOS 12 puts the identifier on the Toggle's container, an
|
||||
// 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
|
||||
|
||||
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() }
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ struct RootView: View {
|
||||
}
|
||||
WizardStageContent(model: model, workflow: workflow)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.frame(minWidth: 1100, minHeight: 700)
|
||||
.background(Theme.background)
|
||||
|
||||
@@ -69,22 +69,24 @@ struct SettingsView: View {
|
||||
}
|
||||
|
||||
Section("Verification") {
|
||||
HStack {
|
||||
Text("Good ΔE ≤")
|
||||
TextField(
|
||||
"2.0",
|
||||
"Good ΔE ≤",
|
||||
value: $model.settings.deltaEGoodMax,
|
||||
format: .number
|
||||
)
|
||||
.frame(width: 60)
|
||||
Text("Warning ΔE ≤")
|
||||
.accessibilityIdentifier("settingsDeltaEGood")
|
||||
|
||||
TextField(
|
||||
"5.0",
|
||||
"Warning ΔE ≤",
|
||||
value: $model.settings.deltaEWarningMax,
|
||||
format: .number
|
||||
)
|
||||
.frame(width: 60)
|
||||
}
|
||||
.accessibilityIdentifier("settingsDeltaEWarning")
|
||||
|
||||
Text("Swatch and verify status use these as the green / amber cutoffs. Fail is anything above Warning.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
ForEach(model.validationErrors, id: \.self) { error in
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
@@ -93,16 +95,12 @@ struct SettingsView: View {
|
||||
}
|
||||
|
||||
Section("Calibration") {
|
||||
HStack {
|
||||
Text("Stale after")
|
||||
TextField(
|
||||
"30",
|
||||
"Stale after (days)",
|
||||
value: $model.settings.calibrationStaleDays,
|
||||
format: .number
|
||||
)
|
||||
.frame(width: 60)
|
||||
Text("days")
|
||||
}
|
||||
.accessibilityIdentifier("settingsCalStaleDays")
|
||||
}
|
||||
|
||||
Section("Profile install") {
|
||||
@@ -143,6 +141,7 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.leading, 45)
|
||||
|
||||
Divider()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -115,6 +144,11 @@ final class Milestone10GamutCompareUITests: XCTestCase {
|
||||
let compare = waitFor("gamutLayer-compare")
|
||||
XCTAssertTrue(compare.exists)
|
||||
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 {
|
||||
@@ -128,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 {
|
||||
@@ -142,6 +177,7 @@ final class Milestone10GamutCompareUITests: XCTestCase {
|
||||
// The pre-load placeholder also exists — wait for enabled.
|
||||
let compare = waitUntilEnabled("gamutLayer-compare")
|
||||
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 value = status.value as? String ?? ""
|
||||
@@ -149,6 +185,7 @@ final class Milestone10GamutCompareUITests: XCTestCase {
|
||||
|
||||
let remove = waitFor("btnGamutRemoveCompare")
|
||||
XCTAssertTrue(remove.isEnabled)
|
||||
closeGamutSheet()
|
||||
}
|
||||
|
||||
func testOpenProfileRunsIccgamutForCompare() throws {
|
||||
@@ -164,6 +201,13 @@ final class Milestone10GamutCompareUITests: XCTestCase {
|
||||
|
||||
let compare = waitUntilEnabled("gamutLayer-compare")
|
||||
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 {
|
||||
@@ -173,6 +217,7 @@ final class Milestone10GamutCompareUITests: XCTestCase {
|
||||
XCTAssertTrue(panel.exists)
|
||||
XCTAssertTrue(element("gamutInspectIdle").exists)
|
||||
XCTAssertTrue(element("gamutStatusText").exists)
|
||||
closeGamutSheet()
|
||||
}
|
||||
|
||||
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(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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
/// wizard notice and restores the original basename (issue #80).
|
||||
func testCalibrationTargenFailureRestoresBasename() throws {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
|
||||
/// Settings sheet UI tests (issue #165).
|
||||
///
|
||||
/// The Verification thresholds once shared one non-wrapping `HStack` and
|
||||
/// drew past the sheet's right clip on the macOS grouped `Form`. AX
|
||||
/// existence cannot see clipping (#163), so containment is asserted on
|
||||
/// real frame geometry against the sheet's bounds.
|
||||
///
|
||||
/// All three numeric fields also passed their default value as the
|
||||
/// `TextField` label; inside an `HStack` row that label renders inline —
|
||||
/// it is not a placeholder — producing "Stale after 30 [30] days". The
|
||||
/// fields are now direct `Form` children, so the descriptive label
|
||||
/// renders once in the label column and the box fills the control
|
||||
/// column; `testNumericFieldsCarryLabelsNotDuplicatedValues` pins it.
|
||||
@MainActor
|
||||
final class SettingsUITests: XCTestCase {
|
||||
|
||||
private var app: XCUIApplication!
|
||||
|
||||
override func setUp() async throws {
|
||||
continueAfterFailure = false
|
||||
app = XCUIApplication()
|
||||
app.launchEnvironment = ["ICCERY_UI_TESTING": "1"]
|
||||
app.launch()
|
||||
app.activate()
|
||||
}
|
||||
|
||||
override func tearDown() async throws {
|
||||
app?.terminate()
|
||||
app = nil
|
||||
}
|
||||
|
||||
/// Sheet content lives under `app.sheets`, outside the main window's
|
||||
/// a11y tree (Milestone2 pattern).
|
||||
private var sheet: XCUIElement {
|
||||
app.sheets.firstMatch
|
||||
}
|
||||
|
||||
private func openSettings() {
|
||||
let gear = app.buttons["openSettingsBtn"]
|
||||
XCTAssertTrue(gear.waitForExistence(timeout: 10))
|
||||
gear.click()
|
||||
XCTAssertTrue(sheet.waitForExistence(timeout: 10))
|
||||
}
|
||||
|
||||
private func thresholdField(_ fieldID: String) -> XCUIElement {
|
||||
let field = sheet.textFields[fieldID]
|
||||
XCTAssertTrue(field.waitForExistence(timeout: 10), "missing \(fieldID)")
|
||||
return field
|
||||
}
|
||||
|
||||
private func replaceFieldValue(_ field: XCUIElement, with text: String) {
|
||||
field.click()
|
||||
app.typeKey("a", modifierFlags: .command)
|
||||
field.typeText(text)
|
||||
}
|
||||
|
||||
private func waitForSheetDismiss(timeout: TimeInterval = 10) {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if !sheet.exists { return }
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
}
|
||||
XCTAssertFalse(sheet.exists, "Expected sheet to disappear")
|
||||
}
|
||||
|
||||
/// Both ΔE rows must render fully inside the 560×620 sheet: the
|
||||
/// label-column `StaticText`s and the control-column fields all sit
|
||||
/// within the sheet bounds, the two fields share the Form's control
|
||||
/// column margin, and the Warning row sits below the Good row so the
|
||||
/// two cannot overlap on one clipped line.
|
||||
///
|
||||
/// The numeric fields are direct `Form` children, so macOS lifts
|
||||
/// each `TextField` label into the right-aligned label column and
|
||||
/// the editable box fills the control column — the same layout the
|
||||
/// Pickers use. The control column ends only ~3.5 pt inside the
|
||||
/// sheet (PopUpButtons reach it too), so the right-edge assertion is
|
||||
/// "inside the sheet", not the older 12 pt compact-field inset.
|
||||
func testVerificationRowsStayInsideSheet() throws {
|
||||
openSettings()
|
||||
|
||||
let goodField = thresholdField("settingsDeltaEGood")
|
||||
let warningField = thresholdField("settingsDeltaEWarning")
|
||||
|
||||
// Labels render as sibling staticTexts in the label column.
|
||||
let goodLabel = sheet.staticTexts["Good ΔE ≤"]
|
||||
let warningLabel = sheet.staticTexts["Warning ΔE ≤"]
|
||||
XCTAssertTrue(goodLabel.waitForExistence(timeout: 5))
|
||||
XCTAssertTrue(warningLabel.waitForExistence(timeout: 5))
|
||||
|
||||
// Left boundary: labels must be inside the sheet with >=12 pt inset
|
||||
XCTAssertTrue(goodLabel.frame.minX >= sheet.frame.minX + 12.0)
|
||||
XCTAssertTrue(warningLabel.frame.minX >= sheet.frame.minX + 12.0)
|
||||
|
||||
// Right boundary: fields must not draw past the sheet's clip
|
||||
XCTAssertTrue(
|
||||
goodField.frame.maxX <= sheet.frame.maxX,
|
||||
"Good ΔE field clips the sheet's right edge")
|
||||
XCTAssertTrue(
|
||||
warningField.frame.maxX <= sheet.frame.maxX,
|
||||
"Warning ΔE field clips the sheet's right edge")
|
||||
|
||||
// Vertical separation
|
||||
XCTAssertTrue(
|
||||
warningField.frame.minY > goodField.frame.minY,
|
||||
"thresholds must be two separate rows")
|
||||
|
||||
// Both threshold fields align at the same control column margin
|
||||
XCTAssertTrue(
|
||||
abs(goodField.frame.minX - warningField.frame.minX) <= 1.0,
|
||||
"Good and Warning ΔE fields should align at the same column margin")
|
||||
|
||||
// Other labels must not overflow the left boundary
|
||||
let defaultInstLabel = sheet.staticTexts["Default instrument"]
|
||||
XCTAssertTrue(defaultInstLabel.waitForExistence(timeout: 5))
|
||||
XCTAssertTrue(
|
||||
defaultInstLabel.frame.minX >= sheet.frame.minX + 12.0,
|
||||
"Default instrument label must not overflow left edge")
|
||||
|
||||
let bundledSidecarsLabel = sheet.staticTexts["Bundled sidecars"]
|
||||
XCTAssertTrue(bundledSidecarsLabel.waitForExistence(timeout: 5))
|
||||
XCTAssertTrue(
|
||||
bundledSidecarsLabel.frame.minX >= sheet.frame.minX + 12.0,
|
||||
"Bundled sidecars label must not overflow left edge")
|
||||
|
||||
sheet.buttons["Cancel"].click()
|
||||
waitForSheetDismiss(timeout: 5)
|
||||
}
|
||||
|
||||
/// `warning <= good` fails `AppSettings.validate()` and keeps the
|
||||
/// sheet open with the contract error text; restoring valid values
|
||||
/// lets Save dismiss (issue #165 acceptance, strings are the #5
|
||||
/// contract).
|
||||
func testDeltaEValidationBlocksSaveThenValidSaveDismisses() throws {
|
||||
openSettings()
|
||||
|
||||
replaceFieldValue(thresholdField("settingsDeltaEWarning"), with: "1")
|
||||
sheet.buttons["Save"].click()
|
||||
|
||||
let error = sheet.staticTexts[
|
||||
"Good ΔE threshold must be strictly less than the warning threshold."
|
||||
]
|
||||
XCTAssertTrue(error.waitForExistence(timeout: 10))
|
||||
XCTAssertTrue(sheet.exists, "invalid ΔE must not dismiss the sheet")
|
||||
|
||||
replaceFieldValue(thresholdField("settingsDeltaEWarning"), with: "5")
|
||||
sheet.buttons["Save"].click()
|
||||
waitForSheetDismiss(timeout: 10)
|
||||
}
|
||||
|
||||
/// macOS renders a `TextField`'s first argument as a label, not a
|
||||
/// placeholder — inside the old `HStack` rows it drew inline, so the
|
||||
/// sheet read "Stale after 30 [30] days" / "Good ΔE ≤ 2.0 [2.0]".
|
||||
/// As direct `Form` children each label now renders exactly once, in
|
||||
/// the label column; no `staticText` may echo the field's value.
|
||||
func testNumericFieldsCarryLabelsNotDuplicatedValues() throws {
|
||||
openSettings()
|
||||
|
||||
// (identifier, label-column text, rendered default value, the
|
||||
// literal that used to double-render as the field's label)
|
||||
// `value:` shows the formatted number — 2.0 renders as "2".
|
||||
let rows: [(id: String, label: String, value: String, dup: String)] = [
|
||||
("settingsDeltaEGood", "Good ΔE ≤", "2", "2.0"),
|
||||
("settingsDeltaEWarning", "Warning ΔE ≤", "5", "5.0"),
|
||||
("settingsCalStaleDays", "Stale after (days)", "30", "30"),
|
||||
]
|
||||
|
||||
for spec in rows {
|
||||
let field = sheet.textFields[spec.id]
|
||||
XCTAssertTrue(field.waitForExistence(timeout: 10), "missing \(spec.id)")
|
||||
XCTAssertEqual(
|
||||
field.value as? String, spec.value,
|
||||
"\(spec.id) default value changed unexpectedly")
|
||||
XCTAssertTrue(
|
||||
sheet.staticTexts[spec.label].waitForExistence(timeout: 5),
|
||||
"\(spec.id) must render \"\(spec.label)\" once in the label column")
|
||||
for ghost in Set([spec.value, spec.dup]) {
|
||||
XCTAssertFalse(
|
||||
sheet.staticTexts[ghost].exists,
|
||||
"\(spec.id) must not render \"\(ghost)\" as a second label")
|
||||
}
|
||||
XCTAssertTrue(
|
||||
field.frame.maxX <= sheet.frame.maxX,
|
||||
"\(spec.id) field clips the sheet's right edge")
|
||||
}
|
||||
|
||||
sheet.buttons["Cancel"].click()
|
||||
waitForSheetDismiss(timeout: 5)
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -25,7 +25,7 @@ Cone-only mark for window/taskbar. Raster set:
|
||||
|
||||
| File | Use |
|
||||
|------|-----|
|
||||
| `icons/dmg-background.png` (+ `@2x`, `.svg`) | macOS DMG window (ice cream / wordmark scene). Headless `dmgbuild` after #189 |
|
||||
| `Resources/dmg-background.png` (+ `@2x`; source `brand/dmg-background.svg`) | macOS DMG window. `scripts/package-release.sh` builds a HiDPI TIFF and passes it to `dmgbuild==1.6.7` from `build/.venv-dmgbuild` (created by `INSTALL_DMGBUILD=1 scripts/ensure-host-tools.sh`). Monterey Python 3.9 needs `PIP_IGNORE_REQUIRES_PYTHON=1` or pip will keep 1.6.5. Missing art is a hard fail (#95, #189). |
|
||||
| `icons/wix-banner.bmp`, `wix-dialog.bmp` | MSI |
|
||||
| `icons/nsis-header.bmp`, `nsis-sidebar.bmp` | NSIS |
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ targets:
|
||||
- ICCery.entitlements
|
||||
- ICCery.Debug.entitlements
|
||||
- Argyll
|
||||
- dmg-background.png
|
||||
- dmg-background@2x.png
|
||||
- path: Resources/Argyll
|
||||
type: folder
|
||||
dependencies:
|
||||
|
||||
@@ -30,6 +30,11 @@ if [ -z "$TAG" ] || [ "$TAG" = "${GITHUB_REF:-}" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$TAG" in
|
||||
*prerelease*) PRERELEASE=true ;;
|
||||
*) PRERELEASE=false ;;
|
||||
esac
|
||||
|
||||
DMG="${1:-}"
|
||||
if [ -z "$DMG" ]; then
|
||||
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)"
|
||||
|
||||
if [ "$STATUS" = "404" ]; then
|
||||
echo "==> Creating release $TAG"
|
||||
echo "==> Creating release $TAG (prerelease=$PRERELEASE)"
|
||||
STATUS="$(curl -sS -o "$BODY" -w '%{http_code}' \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-X POST "$API/repos/$REPO/releases" \
|
||||
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"prerelease\":true,\"target_commitish\":\"${GITHUB_SHA:-}\"}")"
|
||||
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"prerelease\":$PRERELEASE,\"target_commitish\":\"${GITHUB_SHA:-}\"}")"
|
||||
fi
|
||||
if [ "$STATUS" != "200" ] && [ "$STATUS" != "201" ]; then
|
||||
echo "error: could not load/create release $TAG (HTTP $STATUS)" >&2
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
# scripts/dmgbuild-settings.py
|
||||
#
|
||||
# dmgbuild settings for ICCery. Set DMG_APP, DMG_FILENAME and DMG_VOLUME_NAME
|
||||
# in the environment, or accept the defaults. Background art can be supplied
|
||||
# later by placing a PNG at Resources/dmg-background.png and setting
|
||||
# DMG_BACKGROUND.
|
||||
# dmgbuild settings for ICCery. scripts/package-release.sh exports
|
||||
# DMG_APP, DMG_FILENAME, DMG_VOLUME_NAME, and DMG_BACKGROUND (a
|
||||
# HiDPI TIFF). A missing background is a hard error — a grey
|
||||
# Finder window is not an acceptable release artefact (#95).
|
||||
|
||||
import os
|
||||
import sys
|
||||
@@ -23,15 +23,23 @@ if not app_path or not app_path.endswith('.app') or not os.path.isdir(app_path):
|
||||
|
||||
files = [app_path]
|
||||
|
||||
# Background art is optional. If the referenced PNG does not exist, fall back
|
||||
# to a plain window. See docs/23-assets.md for the DMG background spec.
|
||||
background = os.environ.get('DMG_BACKGROUND', 'Resources/dmg-background.png')
|
||||
if background and not os.path.exists(background):
|
||||
background = None
|
||||
# Finder on Sonoma+ is picky about PNG-with-alpha window pictures and
|
||||
# about classic Alias Manager blobs. package-release.sh always passes
|
||||
# a flattened HiDPI TIFF as DMG_BACKGROUND. dmgbuild 1.6.7 (bookmark
|
||||
# .DS_Store) needs Python >= 3.10, which the Monterey runner does not
|
||||
# have; 1.6.5 + TIFF is what CI can ship (#95).
|
||||
background = os.environ.get('DMG_BACKGROUND', '')
|
||||
if not background or not os.path.isfile(background):
|
||||
sys.stderr.write(
|
||||
'error: DMG_BACKGROUND must point at an existing image '
|
||||
'(got %r)\n' % background)
|
||||
sys.exit(1)
|
||||
|
||||
icon = None
|
||||
|
||||
# Window size is enough for the app icon and the Applications alias.
|
||||
# Bitmap is slightly larger than this rect so title-bar chrome on
|
||||
# 14+ does not crop the wordmark.
|
||||
window_rect = ((100, 100), (660, 400))
|
||||
|
||||
# Use icon view without extra chrome.
|
||||
|
||||
@@ -2,19 +2,79 @@
|
||||
# scripts/ensure-host-tools.sh
|
||||
#
|
||||
# Bootstrap host tools needed by CI on the macOS 12 runner:
|
||||
# - xcodegen: pinned prebuilt release from GitHub (Homebrew's current
|
||||
# formula requires Xcode 15.3, which cannot be installed on macOS 12).
|
||||
# - dmgbuild: via pip3 (used by scripts/package-release.sh).
|
||||
# - xcodegen: always. Pinned prebuilt release from GitHub (Homebrew's
|
||||
# current formula requires Xcode 15.3, which cannot be installed on
|
||||
# macOS 12).
|
||||
# - dmgbuild: only when INSTALL_DMGBUILD=1 or --dmgbuild. Isolated in
|
||||
# build/.venv-dmgbuild so the test job never pip-installs it.
|
||||
#
|
||||
# Safe to run repeatedly: existing tools are left alone.
|
||||
# dmgbuild 1.6.6+, ds_store 1.3.2+ and mac_alias 2.2.3 declare
|
||||
# Requires-Python >= 3.10. The wheels are py3-none-any and run on the
|
||||
# runner's 3.9; PIP_IGNORE_REQUIRES_PYTHON is required or pip will only
|
||||
# offer 1.6.5 and keep a cached venv on that version (#95).
|
||||
# pip itself is capped at <26.1: 26.1+ needs Python 3.10.
|
||||
#
|
||||
# Safe to run repeatedly: existing tools are left alone unless the
|
||||
# dmgbuild pin is not met.
|
||||
|
||||
set -eu
|
||||
|
||||
ROOT="$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||
XCODEGEN_VERSION="2.38.0"
|
||||
INSTALL_ROOT="${XCODEGEN_HOME:-$HOME/.local/xcodegen/$XCODEGEN_VERSION}"
|
||||
VENV="$ROOT/build/.venv-dmgbuild"
|
||||
DMGBUILD_PIN="1.6.7"
|
||||
|
||||
echo "==> Ensuring dmgbuild"
|
||||
python3 -c "import dmgbuild" 2>/dev/null || pip3 install dmgbuild
|
||||
INSTALL_DMGBUILD="${INSTALL_DMGBUILD:-0}"
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--dmgbuild) INSTALL_DMGBUILD=1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$INSTALL_DMGBUILD" = "1" ]; then
|
||||
echo "==> Ensuring dmgbuild==$DMGBUILD_PIN in $VENV"
|
||||
mkdir -p "$ROOT/build"
|
||||
# pip 26.1+ requires Python 3.10 (dataclass slots). A leftover
|
||||
# `pip install --upgrade pip` on this 3.9 venv installed 26.2.1 and
|
||||
# the next pip invocation crashed. Recreate if pip is already dead.
|
||||
if [ -x "$VENV/bin/python" ] \
|
||||
&& ! "$VENV/bin/python" -m pip --version >/dev/null 2>&1; then
|
||||
echo "==> venv pip is broken; recreating $VENV"
|
||||
rm -rf "$VENV"
|
||||
fi
|
||||
if [ ! -x "$VENV/bin/python" ]; then
|
||||
python3 -m venv "$VENV"
|
||||
fi
|
||||
# Without this, pip on Python 3.9 hides 1.6.6+ and leaves 1.6.5.
|
||||
PIP_IGNORE_REQUIRES_PYTHON=1
|
||||
export PIP_IGNORE_REQUIRES_PYTHON
|
||||
"$VENV/bin/python" -m pip install --upgrade 'pip>=24.3,<26.1'
|
||||
"$VENV/bin/python" -m pip install --upgrade --force-reinstall \
|
||||
"dmgbuild==$DMGBUILD_PIN" \
|
||||
'ds_store>=1.3.3' \
|
||||
'mac_alias>=2.2.3'
|
||||
"$VENV/bin/python" -c 'from importlib.metadata import version
|
||||
print("dmgbuild", version("dmgbuild"))
|
||||
print("ds_store", version("ds_store"))
|
||||
print("mac_alias", version("mac_alias"))
|
||||
parts=[]
|
||||
for p in version("dmgbuild").split("."):
|
||||
try:
|
||||
parts.append(int("".join(c for c in p if c.isdigit()) or "0"))
|
||||
except ValueError:
|
||||
parts.append(0)
|
||||
parts += [0, 0, 0]
|
||||
raise SystemExit(0 if tuple(parts[:3]) >= (1, 6, 7) else 1)
|
||||
'
|
||||
if [ -n "${GITHUB_PATH:-}" ]; then
|
||||
echo "$VENV/bin" >> "$GITHUB_PATH"
|
||||
fi
|
||||
PATH="$VENV/bin:$PATH"
|
||||
export PATH
|
||||
else
|
||||
echo "==> Skipping dmgbuild (set INSTALL_DMGBUILD=1 for the package job)"
|
||||
fi
|
||||
|
||||
if command -v xcodegen >/dev/null 2>&1; then
|
||||
echo "==> xcodegen already on PATH: $(xcodegen --version)"
|
||||
|
||||
@@ -103,19 +103,39 @@ EOF
|
||||
scripts/verify-sidecar-signatures.sh "$APP"
|
||||
fi
|
||||
|
||||
echo "==> Installing / locating dmgbuild"
|
||||
echo "==> Locating dmgbuild"
|
||||
# The package CI job already ran INSTALL_DMGBUILD=1 ensure-host-tools.sh,
|
||||
# which created build/.venv-dmgbuild and prepended it to PATH. Local
|
||||
# runs bootstrap the same venv if dmgbuild is missing.
|
||||
VENV="$ROOT/build/.venv-dmgbuild"
|
||||
if ! command -v dmgbuild >/dev/null 2>&1; then
|
||||
VENV="$ROOT/build/.venv-dmgbuild"
|
||||
if [ ! -d "$VENV/bin" ]; then
|
||||
python3 -m venv "$VENV"
|
||||
"$VENV/bin/pip" install --upgrade pip
|
||||
"$VENV/bin/pip" install dmgbuild
|
||||
if [ ! -x "$VENV/bin/dmgbuild" ]; then
|
||||
INSTALL_DMGBUILD=1 "$ROOT/scripts/ensure-host-tools.sh" --dmgbuild
|
||||
fi
|
||||
PATH="$VENV/bin:$PATH"
|
||||
export PATH
|
||||
fi
|
||||
if ! command -v dmgbuild >/dev/null 2>&1; then
|
||||
echo "error: dmgbuild not available. Try 'python3 -m venv .venv && pip install dmgbuild'" >&2
|
||||
echo "error: dmgbuild not on PATH; run INSTALL_DMGBUILD=1 scripts/ensure-host-tools.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "dmgbuild $(command -v dmgbuild)"
|
||||
if [ -x "$VENV/bin/python" ]; then
|
||||
"$VENV/bin/python" -c 'from importlib.metadata import version; print("dmgbuild", version("dmgbuild"))'
|
||||
fi
|
||||
|
||||
PNG1X="$ROOT/Resources/dmg-background.png"
|
||||
PNG2X="$ROOT/Resources/dmg-background@2x.png"
|
||||
if [ ! -f "$PNG1X" ] || [ ! -f "$PNG2X" ]; then
|
||||
echo "error: missing $PNG1X or $PNG2X" >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$ROOT/build"
|
||||
DMG_BACKGROUND="$ROOT/build/dmg-background.tiff"
|
||||
echo "==> Building HiDPI DMG background TIFF"
|
||||
tiffutil -cathidpicheck "$PNG1X" "$PNG2X" -out "$DMG_BACKGROUND"
|
||||
if [ ! -f "$DMG_BACKGROUND" ]; then
|
||||
echo "error: tiffutil did not write $DMG_BACKGROUND" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -128,6 +148,7 @@ VOLUME_NAME="ICCery ${VERSION}"
|
||||
DMG_APP="$APP" \
|
||||
DMG_FILENAME="$DMG" \
|
||||
DMG_VOLUME_NAME="$VOLUME_NAME" \
|
||||
DMG_BACKGROUND="$DMG_BACKGROUND" \
|
||||
dmgbuild -s scripts/dmgbuild-settings.py "$VOLUME_NAME" "$DMG"
|
||||
|
||||
echo "DMG: $PWD/$DMG"
|
||||
|
||||
Reference in New Issue
Block a user