Compare commits

...
Author SHA1 Message Date
gronod ad7b91cf91 Merge pull request 'fix(ui): settings sheet — ΔE clip + duplicated numeric-field labels (#165)' (#166) from fix/165-settings-deltae-clip into develop
macOS CI / package (push) Successful in 4m48s
macOS CI / build-and-test (push) Successful in 43m0s
Reviewed-on: #166
2026-09-14 07:56:58 +01:00
gronod 8b931e3625 fix(ui): settings numeric fields no longer render default value as inline label (#165)
macOS CI / build-and-test (pull_request) Successful in 37m40s
macOS CI / package (pull_request) Skipped
- TextField("30"/"2.0"/"5.0") passed the default value as the label,
  which macOS draws inline next to the box — the rows read
  "Stale after 30 [30] days" / "Good ΔE ≤ 2.0 [2.0]".
- The three fields are now direct Form children carrying their
  descriptive label, so it renders once in the label column and the box
  fills the control column, matching the Pickers. The stale-days row
  folds "days" into the label ("Stale after (days)").
- New identifier settingsCalStaleDays; docs/21 roster 357→358.
- New testNumericFieldsCarryLabelsNotDuplicatedValues asserts each
  field's value, a single label-column staticText, and no staticText
  echoing the old label literal. testVerificationRowsStayInsideSheet
  updated for label-column geometry (fields end ~3.5 pt inside the
  sheet, same as the PopUpButtons — the 12 pt inset only applied to the
  old 60 pt boxes).

Refs #165
2026-09-14 02:05:06 +01:00
gronod 73b18dec5b test(settings): drop waitForNonExistence for Xcode 14.2 CI (#165)
macOS CI / build-and-test (pull_request) Failing after 4m12s
macOS CI / package (pull_request) Skipped
waitForNonExistence requires the macOS 14 SDK XCTest; the macos-12 runner toolchain has no such member on XCUIElement. Poll sheet.exists on the run loop instead, matching the waitForGone pattern in Milestone10GamutCompareUITests.
2026-09-14 01:08:01 +01:00
gronod 3bc0d14a34 test(settings): add Foundation import and simplify column alignment assertion
macOS CI / build-and-test (pull_request) Failing after 1m40s
macOS CI / package (pull_request) Skipped
2026-09-14 00:46:32 +01:00
gronod 4aa2815c6e test(settings): use abs diff instead of accuracy for Swift 5.7 compatibility
macOS CI / build-and-test (pull_request) Failing after 1m50s
macOS CI / package (pull_request) Skipped
2026-09-14 00:42:32 +01:00
gronod 8596f15d52 fix(ui): settings ΔE threshold rows no longer clip the sheet edge (#165)
macOS CI / build-and-test (pull_request) Failing after 2m55s
macOS CI / package (pull_request) Skipped
- Split Section("Verification") from one 4-across non-wrapping HStack into
  two adjacent label+field rows (#settingsDeltaEGood, #settingsDeltaEWarning).
- Add .padding(.leading, 45) to the Settings Form so the control column
  aligns at ~522 pt, matching develop, and all labels have 41–100 pt
  breathing room from the left boundary.
- Add SettingsUITests with frame-containment, control alignment, and
  validation/save round-trip coverage.
- Update docs/21-ui-reference.md Settings entry with new identifiers.

Refs #165
2026-09-14 00:34:35 +01:00
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
15 changed files with 677 additions and 153 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)
+25 -26
View File
@@ -69,22 +69,24 @@ struct SettingsView: View {
} }
Section("Verification") { Section("Verification") {
HStack { TextField(
Text("Good ΔE ≤") "Good ΔE ≤",
TextField( value: $model.settings.deltaEGoodMax,
"2.0", format: .number
value: $model.settings.deltaEGoodMax, )
format: .number .accessibilityIdentifier("settingsDeltaEGood")
)
.frame(width: 60) TextField(
Text("Warning ΔE ≤") "Warning ΔE ≤",
TextField( value: $model.settings.deltaEWarningMax,
"5.0", format: .number
value: $model.settings.deltaEWarningMax, )
format: .number .accessibilityIdentifier("settingsDeltaEWarning")
)
.frame(width: 60) 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 ForEach(model.validationErrors, id: \.self) { error in
Text(error) Text(error)
.font(.caption) .font(.caption)
@@ -93,16 +95,12 @@ struct SettingsView: View {
} }
Section("Calibration") { Section("Calibration") {
HStack { TextField(
Text("Stale after") "Stale after (days)",
TextField( value: $model.settings.calibrationStaleDays,
"30", format: .number
value: $model.settings.calibrationStaleDays, )
format: .number .accessibilityIdentifier("settingsCalStaleDays")
)
.frame(width: 60)
Text("days")
}
} }
Section("Profile install") { Section("Profile install") {
@@ -143,6 +141,7 @@ struct SettingsView: View {
} }
} }
} }
.padding(.leading, 45)
Divider() Divider()
@@ -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()
} }
} }
+192
View File
@@ -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
+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