Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
b6cddc21cd
commit
ac8501ff8c
7
PRD.md
7
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/<locale>/<device>/`.
|
||||
|
||||
### Swift Package Architecture
|
||||
|
||||
TheNoiseClock has been refactored to use a modular Swift Package architecture for improved code reusability and maintainability:
|
||||
|
||||
11
README.md
11
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`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -59,6 +66,10 @@ final class AlarmViewModel {
|
||||
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 {
|
||||
Design.debugLog("[alarms] Scheduling AlarmKit alarm for \(alarm.label)")
|
||||
@ -84,6 +95,10 @@ final class AlarmViewModel {
|
||||
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)
|
||||
|
||||
@ -128,6 +148,10 @@ final class AlarmViewModel {
|
||||
alarm.isEnabled.toggle()
|
||||
alarmService.updateAlarm(alarm)
|
||||
|
||||
if isBypassingAlarmKitForUITests {
|
||||
return
|
||||
}
|
||||
|
||||
// Schedule or cancel based on new state
|
||||
if alarm.isEnabled {
|
||||
Design.debugLog("[alarms] Enabling AlarmKit alarm \(alarm.label)")
|
||||
@ -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()
|
||||
|
||||
@ -203,6 +203,7 @@ struct SoundCard: View {
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("noise.soundCard.\(sound.id)")
|
||||
.onLongPressGesture {
|
||||
onPreview()
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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..<attempts {
|
||||
if isScreenVisible(identifier: tab.screenIdentifier, in: app) || isTabSelected(tab, in: app) {
|
||||
return true
|
||||
}
|
||||
usleep(200_000)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func isScreenVisible(identifier: String, in app: XCUIApplication) -> 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
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user