Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-02-09 16:38:00 -06:00
parent b6cddc21cd
commit ac8501ff8c
6 changed files with 333 additions and 103 deletions

7
PRD.md
View File

@ -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:

View File

@ -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`.
---

View File

@ -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()

View File

@ -203,6 +203,7 @@ struct SoundCard: View {
}
}
.buttonStyle(.plain)
.accessibilityIdentifier("noise.soundCard.\(sound.id)")
.onLongPressGesture {
onPreview()
}

View File

@ -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()

View File

@ -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", "Daccord", "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
}
}