From ac8501ff8c092dcb036404be5e53257458f0f78c Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Mon, 9 Feb 2026 16:38:00 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- PRD.md | 7 + README.md | 11 + .../Alarms/State/AlarmViewModel.swift | 28 ++ .../Views/Components/SoundCategoryView.swift | 1 + .../Features/Noise/Views/NoiseView.swift | 23 ++ .../TheNoiseClockUITests.swift | 366 +++++++++++++----- 6 files changed, 333 insertions(+), 103 deletions(-) diff --git a/PRD.md b/PRD.md index 7f3ca85..854d916 100644 --- a/PRD.md +++ b/PRD.md @@ -170,6 +170,13 @@ TheNoiseClock is a SwiftUI-based iOS application that combines a customizable di ## Technical Architecture +### App Store Screenshot Automation +- **XCTest-driven screenshot pipeline**: `TheNoiseClockUITests` captures canonical App Store assets with deterministic names (`01...08`). +- **Pre-capture state enforcement**: UI tests guarantee at least one enabled alarm and active noise playback before each screenshot. +- **Locale matrix support**: Automated runs for `en`, `fr-CA`, and `es-MX` via `-testLanguage` and `-testRegion`. +- **Device matrix support**: Scripted capture for `iPhone-6.3`, `iPhone-6.5`, `iPhone-6.9`, and `iPad-13`. +- **Automation entrypoint**: `TheNoiseClock/scripts/run-screenshot-matrix.sh` writes screenshots to `Screenshots///`. + ### Swift Package Architecture TheNoiseClock has been refactored to use a modular Swift Package architecture for improved code reusability and maintainability: diff --git a/README.md b/README.md index 30f175a..638bbaa 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,16 @@ cd /Users/mattbruce/Documents/Projects/iPhone/TheNoiseClock xcodebuild -project TheNoiseClock/TheNoiseClock.xcodeproj -scheme TheNoiseClock -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.2' test ``` +### App Store Screenshot Matrix (en, fr-CA, es-MX) +```bash +cd /Users/mattbruce/Documents/Projects/iPhone/TheNoiseClock +./TheNoiseClock/scripts/run-screenshot-matrix.sh +``` +Output is written to: +- `Screenshots/en/iPhone-6.3`, `Screenshots/en/iPhone-6.5`, `Screenshots/en/iPhone-6.9`, `Screenshots/en/iPad-13` +- `Screenshots/fr-CA/iPhone-6.3`, `Screenshots/fr-CA/iPhone-6.5`, `Screenshots/fr-CA/iPhone-6.9`, `Screenshots/fr-CA/iPad-13` +- `Screenshots/es-MX/iPhone-6.3`, `Screenshots/es-MX/iPhone-6.5`, `Screenshots/es-MX/iPhone-6.9`, `Screenshots/es-MX/iPad-13` + --- ## Branding & Theming @@ -149,6 +159,7 @@ Swift access is provided via: - Non-`Text` user-facing strings were migrated to `String(localized:)` and backed by `TheNoiseClock/Localizable.xcstrings` with `en`, `es-MX`, and `fr-CA` translations. - Clock Settings UI copy now uses explicit localization keys (no raw `Text(\"...\")` literals in settings screens). - Localization catalog is normalized to a single pattern: namespaced key-based entries only (no source-string keys). +- Screenshot UITests now enforce deterministic pre-capture state (alarm enabled + noise actively playing) and include locale-safe tab/onboarding navigation for `en`, `fr-CA`, and `es-MX`. --- diff --git a/TheNoiseClock/Features/Alarms/State/AlarmViewModel.swift b/TheNoiseClock/Features/Alarms/State/AlarmViewModel.swift index ea3e4f7..55dac1c 100644 --- a/TheNoiseClock/Features/Alarms/State/AlarmViewModel.swift +++ b/TheNoiseClock/Features/Alarms/State/AlarmViewModel.swift @@ -41,6 +41,10 @@ final class AlarmViewModel { var isShowingErrorAlert = false var errorAlertMessage = "" + private var isBypassingAlarmKitForUITests: Bool { + ProcessInfo.processInfo.arguments.contains("-uiTest.bypassAlarmKit") + } + // MARK: - Initialization init(alarmService: AlarmService = AlarmService.shared) { self.alarmService = alarmService @@ -50,6 +54,9 @@ final class AlarmViewModel { /// Request AlarmKit authorization. Should be called during onboarding. func requestAlarmKitAuthorization() async -> Bool { + if isBypassingAlarmKitForUITests { + return true + } return await alarmKitService.requestAuthorization() } @@ -58,6 +65,10 @@ final class AlarmViewModel { @discardableResult func addAlarm(_ alarm: Alarm, presentErrorAlert: Bool = true) async -> AlarmOperationResult { alarmService.addAlarm(alarm) + + if isBypassingAlarmKitForUITests { + return .success + } // Schedule with AlarmKit if alarm is enabled if alarm.isEnabled { @@ -83,6 +94,10 @@ final class AlarmViewModel { func updateAlarm(_ alarm: Alarm, presentErrorAlert: Bool = true) async -> AlarmOperationResult { let previousAlarm = alarmService.getAlarm(id: alarm.id) alarmService.updateAlarm(alarm) + + if isBypassingAlarmKitForUITests { + return .success + } // Cancel existing and reschedule if enabled alarmKitService.cancelAlarm(id: alarm.id) @@ -114,6 +129,11 @@ final class AlarmViewModel { } func deleteAlarm(id: UUID) async { + if isBypassingAlarmKitForUITests { + alarmService.deleteAlarm(id: id) + return + } + // Cancel AlarmKit alarm first alarmKitService.cancelAlarm(id: id) @@ -127,6 +147,10 @@ final class AlarmViewModel { alarm.isEnabled.toggle() alarmService.updateAlarm(alarm) + + if isBypassingAlarmKitForUITests { + return + } // Schedule or cancel based on new state if alarm.isEnabled { @@ -179,6 +203,10 @@ final class AlarmViewModel { /// Reschedule all enabled alarms with AlarmKit. /// Call this on app launch to ensure alarms are registered. func rescheduleAllAlarms() async { + if isBypassingAlarmKitForUITests { + return + } + Design.debugLog("[alarmkit] ========== RESCHEDULING ALL ALARMS ==========") let enabledAlarms = alarmService.getEnabledAlarms() diff --git a/TheNoiseClock/Features/Noise/Views/Components/SoundCategoryView.swift b/TheNoiseClock/Features/Noise/Views/Components/SoundCategoryView.swift index fac5e8b..1d243d5 100644 --- a/TheNoiseClock/Features/Noise/Views/Components/SoundCategoryView.swift +++ b/TheNoiseClock/Features/Noise/Views/Components/SoundCategoryView.swift @@ -203,6 +203,7 @@ struct SoundCard: View { } } .buttonStyle(.plain) + .accessibilityIdentifier("noise.soundCard.\(sound.id)") .onLongPressGesture { onPreview() } diff --git a/TheNoiseClock/Features/Noise/Views/NoiseView.swift b/TheNoiseClock/Features/Noise/Views/NoiseView.swift index d93e5cd..b638a14 100644 --- a/TheNoiseClock/Features/Noise/Views/NoiseView.swift +++ b/TheNoiseClock/Features/Noise/Views/NoiseView.swift @@ -86,6 +86,11 @@ struct NoiseView: View { .navigationBarTitleDisplayMode(.inline) .animation(.easeInOut(duration: 0.3), value: selectedSound) .accessibilityIdentifier("noise.screen") + .onAppear { + Task { @MainActor in + await seedUITestAutoPlayIfNeeded() + } + } } // MARK: - Computed Properties @@ -241,6 +246,24 @@ struct NoiseView: View { } } +private extension NoiseView { + @MainActor + func seedUITestAutoPlayIfNeeded() async { + let arguments = ProcessInfo.processInfo.arguments + guard arguments.contains("-uiTest.autoPlayNoise") else { return } + guard selectedSound == nil else { return } + + for _ in 0..<12 { + if let candidate = viewModel.availableSounds.first(where: { $0.category != "alarm" }) { + selectedSound = candidate + viewModel.playSound(candidate) + return + } + try? await Task.sleep(for: .milliseconds(250)) + } + } +} + // MARK: - Preview #Preview { NoiseView() diff --git a/TheNoiseClockUITests/TheNoiseClockUITests.swift b/TheNoiseClockUITests/TheNoiseClockUITests.swift index 8108a1f..cdb3ac9 100644 --- a/TheNoiseClockUITests/TheNoiseClockUITests.swift +++ b/TheNoiseClockUITests/TheNoiseClockUITests.swift @@ -8,6 +8,52 @@ import XCTest final class TheNoiseClockUITests: XCTestCase { + private enum CaptureTab: CaseIterable { + case clock + case alarms + case noise + case settings + + var index: Int { + switch self { + case .clock: 0 + case .alarms: 1 + case .noise: 2 + case .settings: 3 + } + } + + var labels: [String] { + switch self { + case .clock: + ["Clock", "Horloge", "Reloj"] + case .alarms: + ["Alarms", "Alarmes", "Alarmas"] + case .noise: + ["Noise", "Bruit", "Ruido"] + case .settings: + ["Settings", "Reglages", "Réglages", "Configuracion", "Configuración", "Ajustes"] + } + } + + var symbolLabels: [String] { + switch self { + case .clock: ["clock", "clock.fill"] + case .alarms: ["alarm", "alarm.fill"] + case .noise: ["waveform", "speaker.wave.2.fill", "speaker.wave.2"] + case .settings: ["gearshape", "gearshape.fill"] + } + } + + var screenIdentifier: String { + switch self { + case .clock: "clock.screen" + case .alarms: "alarms.screen" + case .noise: "noise.screen" + case .settings: "settings.screen" + } + } + } override func setUpWithError() throws { continueAfterFailure = false @@ -18,18 +64,19 @@ final class TheNoiseClockUITests: XCTestCase { let app = XCUIApplication() app.launchArguments += [ "-onboarding.TheNoiseClock.hasCompletedWelcome", "YES", - "-onboarding.TheNoiseClock.hasLaunched", "YES" + "-onboarding.TheNoiseClock.hasLaunched", "YES", + "-uiTest.bypassAlarmKit", "YES", + "-uiTest.autoPlayNoise", "YES" ] app.launch() dismissOnboardingIfNeeded(app) ensureKeepAwakeEnabled(app) - ensureAlarmExistsAndEnabled(app) - captureNoiseViewPortraitAndLandscape(app) - captureClockPortraitAndLandscape(app) - captureAlarmsViewPortraitAndLandscape(app) - captureSettingsViewPortraitAndLandscape(app) + captureClock(app) + captureAlarmsView(app) + captureNoiseView(app) + captureSettingsView(app) XCUIDevice.shared.orientation = .portrait } @@ -37,98 +84,103 @@ final class TheNoiseClockUITests: XCTestCase { // MARK: - Capture Steps @MainActor - private func captureClockPortraitAndLandscape(_ app: XCUIApplication) { + private func captureClock(_ app: XCUIApplication) { XCUIDevice.shared.orientation = .portrait sleep(1) - openTab(named: "Clock", in: app) + ensureCapturePreconditions(app) + openTab(.clock, in: app) waitForClockFullscreenTransition() saveScreenshot(named: "01-clock-view-portrait.png") XCUIDevice.shared.orientation = .landscapeLeft sleep(2) - openTab(named: "Clock", in: app) + ensureCapturePreconditions(app) + openTab(.clock, in: app) waitForClockFullscreenTransition() saveScreenshot(named: "02-clock-view-landscape.png") } @MainActor - private func captureAlarmsViewPortraitAndLandscape(_ app: XCUIApplication) { + private func captureAlarmsView(_ app: XCUIApplication) { XCUIDevice.shared.orientation = .portrait sleep(1) - openTab(named: "Alarms", in: app) + ensureCapturePreconditions(app) + openTab(.alarms, in: app) sleep(1) saveScreenshot(named: "03-alarms-view-portrait.png") - - XCUIDevice.shared.orientation = .landscapeLeft - sleep(2) - openTab(named: "Alarms", in: app) - sleep(1) - saveScreenshot(named: "04-alarms-view-landscape.png") } @MainActor - private func captureNoiseViewPortraitAndLandscape(_ app: XCUIApplication) { + private func captureNoiseView(_ app: XCUIApplication) { XCUIDevice.shared.orientation = .portrait sleep(1) - openTab(named: "Noise", in: app) - ensureNoiseSoundSelectedAndPlaying(app) + ensureCapturePreconditions(app) + openTab(.noise, in: app) sleep(1) - saveScreenshot(named: "05-noise-view-portrait.png") - - XCUIDevice.shared.orientation = .landscapeLeft - sleep(2) - openTab(named: "Noise", in: app) - ensureNoiseSoundSelectedAndPlaying(app) - sleep(1) - saveScreenshot(named: "06-noise-view-landscape.png") + saveScreenshot(named: "04-noise-view-portrait.png") } @MainActor - private func captureSettingsViewPortraitAndLandscape(_ app: XCUIApplication) { + private func captureSettingsView(_ app: XCUIApplication) { XCUIDevice.shared.orientation = .portrait sleep(1) - openTab(named: "Settings", in: app) + ensureCapturePreconditions(app) + openTab(.settings, in: app) sleep(1) - saveScreenshot(named: "07-clock-settings-portrait.png") + saveScreenshot(named: "05-clock-settings-portrait.png") - XCUIDevice.shared.orientation = .landscapeLeft - sleep(2) - openTab(named: "Settings", in: app) - sleep(1) - saveScreenshot(named: "08-clock-settings-landscape.png") + } + + @MainActor + private func ensureCapturePreconditions(_ app: XCUIApplication) { + ensureAlarmExistsAndEnabled(app) + ensureNoiseSoundSelectedAndPlaying(app) } @MainActor private func ensureNoiseSoundSelectedAndPlaying(_ app: XCUIApplication) { - // Select a known sound so controls become visible. - let soundNames = [ - "White Noise", - "Brown Noise", - "Heavy Rain", - "Atmospheric Pad", - "Fan Heater" - ] - var didSelectSound = false - for _ in 0..<8 { - for soundName in soundNames { - let sound = app.staticTexts[soundName] - if sound.exists && sound.isHittable { - sound.tap() - didSelectSound = true - break - } - } - if didSelectSound { - break - } - app.swipeUp() - usleep(250_000) + openTab(.noise, in: app) + guard waitForScreen(.noise, in: app) else { + XCTFail("Could not navigate to noise screen before selecting a sound.") + return } - let playButton = app.buttons["noise.playStopButton"] - if playButton.waitForExistence(timeout: 4) { - playButton.tap() + let playButton = noisePlayControl(in: app) + guard playButton.exists else { + return } + + if !isNoiseCurrentlyPlaying(playButton) { + playButton.tap() + usleep(300_000) + } + } + + private func noisePlayControl(in app: XCUIApplication) -> XCUIElement { + app.descendants(matching: .any).matching(identifier: "noise.playStopButton").firstMatch + } + + private func isNoiseCurrentlyPlaying(_ playButton: XCUIElement) -> Bool { + let normalizedLabel = normalized(playButton.label) + let normalizedValue = normalized((playButton.value as? String) ?? "") + + let stopKeywords = ["stop", "arrêter", "arreter", "detener"] + let playKeywords = ["play", "lire", "jouer", "reproducir"] + + if stopKeywords.contains(where: { normalizedLabel.contains(normalized($0)) || normalizedValue.contains(normalized($0)) }) { + return true + } + if playKeywords.contains(where: { normalizedLabel.contains(normalized($0)) || normalizedValue.contains(normalized($0)) }) { + return false + } + + return false + } + + private func normalized(_ text: String) -> String { + text + .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) + .lowercased() } // MARK: - State Setup @@ -136,38 +188,37 @@ final class TheNoiseClockUITests: XCTestCase { @MainActor private func dismissOnboardingIfNeeded(_ app: XCUIApplication) { let secondaryButton = app.buttons["onboarding.secondaryButton"] - if secondaryButton.waitForExistence(timeout: 3), secondaryButton.label == "Skip" { + if secondaryButton.waitForExistence(timeout: 3) { secondaryButton.tap() waitForMainTabs(app) return } - if app.buttons["Skip"].waitForExistence(timeout: 3) { - app.buttons["Skip"].tap() - waitForMainTabs(app) - return - } - // Fallback: walk onboarding pages if skip is not available. - for _ in 0..<6 { + for _ in 0..<10 { if hasMainTabs(app) { return } - let next = app.buttons["Next"] - let getStarted = app.buttons["Get Started"] - - if getStarted.exists { - getStarted.tap() - waitForMainTabs(app) - return - } - if next.exists { - next.tap() + let primaryButton = app.buttons["onboarding.primaryButton"] + if primaryButton.waitForExistence(timeout: 1) { + primaryButton.tap() usleep(300_000) + continue + } + + // Legacy fallback if accessibility identifiers are unavailable. + let fallbackPrimaryLabels = ["Get Started", "Next", "Commencer", "Suivant", "Comenzar", "Siguiente"] + for label in fallbackPrimaryLabels { + let button = app.buttons[label] + if button.exists && button.isHittable { + button.tap() + usleep(300_000) + break + } } } } @MainActor private func ensureKeepAwakeEnabled(_ app: XCUIApplication) { - openTab(named: "Settings", in: app) + openTab(.settings, in: app) let keepAwakeSwitch = app.switches["settings.keepAwake.toggle"] for _ in 0..<8 { @@ -189,7 +240,8 @@ final class TheNoiseClockUITests: XCTestCase { @MainActor private func ensureAlarmExistsAndEnabled(_ app: XCUIApplication) { - openTab(named: "Alarms", in: app) + openTab(.alarms, in: app) + dismissAlarmErrorAlertIfPresent(in: app) // If no alarms exist, create one with defaults. let firstSwitch = app.switches.firstMatch @@ -200,6 +252,15 @@ final class TheNoiseClockUITests: XCTestCase { XCTAssertTrue(saveButton.waitForExistence(timeout: 5), "Add Alarm sheet did not appear.") saveButton.tap() sleep(1) + dismissAlarmErrorAlertIfPresent(in: app) + + let cancelButton = app.buttons["alarms.add.cancelButton"] + if cancelButton.exists && cancelButton.isHittable { + cancelButton.tap() + sleep(1) + } + + _ = firstSwitch.waitForExistence(timeout: 3) } // Make sure at least one alarm is enabled. @@ -212,13 +273,9 @@ final class TheNoiseClockUITests: XCTestCase { // MARK: - Helpers @MainActor - private func openTab(named tabName: String, in app: XCUIApplication) { - let expectedIndex: Int? = switch tabName { - case "Clock": 0 - case "Alarms": 1 - case "Noise": 2 - case "Settings": 3 - default: nil + private func openTab(_ tab: CaptureTab, in app: XCUIApplication) { + if isScreenVisible(identifier: tab.screenIdentifier, in: app) { + return } func tapByCoordinates(_ element: XCUIElement) { @@ -227,8 +284,28 @@ final class TheNoiseClockUITests: XCTestCase { coordinate.tap() } + func tapAndConfirm(_ tapAction: () -> Bool) -> Bool { + guard tapAction() else { return false } + return waitForScreen(tab, in: app, timeout: 1.5) + } + func tapLabeledButtonAnywhere() -> Bool { - let matches = app.buttons.matching(NSPredicate(format: "label == %@", tabName)) + let matches = app.buttons.matching(NSPredicate(format: "label IN %@", tab.labels)) + .allElementsBoundByIndex + .filter(\.exists) + guard !matches.isEmpty else { return false } + + if let hittable = matches.first(where: \.isHittable) { + hittable.tap() + return true + } + + tapByCoordinates(matches[0]) + return true + } + + func tapSymbolButtonAnywhere() -> Bool { + let matches = app.buttons.matching(NSPredicate(format: "label IN %@", tab.symbolLabels)) .allElementsBoundByIndex .filter(\.exists) guard !matches.isEmpty else { return false } @@ -243,7 +320,21 @@ final class TheNoiseClockUITests: XCTestCase { } func tapLabeledButton() -> Bool { - let query = app.tabBars.buttons.matching(NSPredicate(format: "label == %@", tabName)) + let query = app.tabBars.buttons.matching(NSPredicate(format: "label IN %@", tab.labels)) + let matches = query.allElementsBoundByIndex.filter(\.exists) + guard !matches.isEmpty else { return false } + + if let hittable = matches.first(where: \.isHittable) { + hittable.tap() + return true + } + + tapByCoordinates(matches[0]) + return true + } + + func tapSymbolButton() -> Bool { + let query = app.tabBars.buttons.matching(NSPredicate(format: "label IN %@", tab.symbolLabels)) let matches = query.allElementsBoundByIndex.filter(\.exists) guard !matches.isEmpty else { return false } @@ -257,23 +348,22 @@ final class TheNoiseClockUITests: XCTestCase { } func tapIndexedButton() -> Bool { - guard let expectedIndex else { return false } let tabBar = app.tabBars.firstMatch guard tabBar.exists else { return false } let buttons = tabBar.buttons - guard buttons.count > expectedIndex else { return false } - let button = buttons.element(boundBy: expectedIndex) + guard buttons.count > tab.index else { return false } + let button = buttons.element(boundBy: tab.index) guard button.exists else { return false } button.tap() return true } - if tapLabeledButton() || tapIndexedButton() { + if tapAndConfirm(tapIndexedButton) || tapAndConfirm(tapLabeledButton) || tapAndConfirm(tapSymbolButton) { return } // Fallback for cases where tab buttons are not exposed under tabBars. - if tapLabeledButtonAnywhere() { + if tapAndConfirm(tapLabeledButtonAnywhere) || tapAndConfirm(tapSymbolButtonAnywhere) { return } @@ -283,12 +373,12 @@ final class TheNoiseClockUITests: XCTestCase { app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.98)).tap() usleep(300_000) - if tapLabeledButton() || tapIndexedButton() || tapLabeledButtonAnywhere() { + if tapAndConfirm(tapIndexedButton) || tapAndConfirm(tapLabeledButton) || tapAndConfirm(tapSymbolButton) || tapAndConfirm(tapLabeledButtonAnywhere) || tapAndConfirm(tapSymbolButtonAnywhere) { return } dismissInterferingPromptIfPresent(in: app) - if tapLabeledButton() || tapIndexedButton() || tapLabeledButtonAnywhere() { + if tapAndConfirm(tapIndexedButton) || tapAndConfirm(tapLabeledButton) || tapAndConfirm(tapSymbolButton) || tapAndConfirm(tapLabeledButtonAnywhere) || tapAndConfirm(tapSymbolButtonAnywhere) { return } @@ -296,7 +386,7 @@ final class TheNoiseClockUITests: XCTestCase { .prefix(16) .map(\.label) .joined(separator: ", ") - XCTFail("Could not open tab '\(tabName)' by label or index. tabBars=\(app.tabBars.count), buttons=[\(buttonLabels)]") + XCTFail("Could not open tab '\(tab)' by label/symbol/index. tabBars=\(app.tabBars.count), buttons=[\(buttonLabels)]") } @MainActor @@ -319,11 +409,18 @@ final class TheNoiseClockUITests: XCTestCase { @MainActor private func dismissInterferingPromptIfPresent(in app: XCUIApplication) { guard app.tabBars.count == 0 else { return } - let buttons = app.buttons.allElementsBoundByIndex.filter { $0.exists && $0.isHittable } - guard buttons.count == 2 else { return } + let allButtons = app.buttons.allElementsBoundByIndex.filter(\.exists) + guard allButtons.count >= 2 else { return } // Keep Awake prompt can obscure tabs in some locale/device combinations. - buttons[1].tap() + let candidate = allButtons[1] + if candidate.isHittable { + candidate.tap() + } else { + let coordinate = app.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0)) + .withOffset(CGVector(dx: candidate.frame.midX, dy: candidate.frame.midY)) + coordinate.tap() + } usleep(300_000) } @@ -370,9 +467,72 @@ final class TheNoiseClockUITests: XCTestCase { } } + @MainActor + private func dismissAlarmErrorAlertIfPresent(in app: XCUIApplication) { + let alert = app.alerts.firstMatch + guard alert.waitForExistence(timeout: 1) else { return } + + let dismissLabels = ["OK", "D’accord", "Aceptar", "Close", "Fermer", "Cerrar"] + for label in dismissLabels { + let button = alert.buttons[label] + if button.exists && button.isHittable { + button.tap() + usleep(250_000) + return + } + } + + let fallback = alert.buttons.firstMatch + if fallback.exists { + fallback.tap() + usleep(250_000) + } + } + + private func waitForScreen(_ tab: CaptureTab, in app: XCUIApplication, timeout: TimeInterval = 2) -> Bool { + let attempts = max(1, Int(timeout / 0.2)) + for _ in 0.. Bool { + let match = app.descendants(matching: .any).matching(identifier: identifier).firstMatch + return match.exists + } + + private func isTabSelected(_ tab: CaptureTab, in app: XCUIApplication) -> Bool { + let tabBar = app.tabBars.firstMatch + guard tabBar.exists else { return false } + let buttons = tabBar.buttons + guard buttons.count > tab.index else { return false } + + let button = buttons.element(boundBy: tab.index) + if button.isSelected { + return true + } + + let value = normalized((button.value as? String) ?? "") + return value == "1" || value.contains("selected") + } + private func hasMainTabs(_ app: XCUIApplication) -> Bool { - let labels = ["Clock", "Alarms", "Noise", "Settings"] + let labels = CaptureTab.allCases + .flatMap(\.labels) + .appending(contentsOf: CaptureTab.allCases.flatMap(\.symbolLabels)) if app.tabBars.count > 0 { return true } return labels.contains(where: { app.buttons[$0].exists }) } } + +private extension Array { + func appending(contentsOf elements: [Element]) -> [Element] { + var copy = self + copy.append(contentsOf: elements) + return copy + } +}