Compare commits
3 Commits
4190c95b84
...
01fcaa9731
| Author | SHA1 | Date | |
|---|---|---|---|
| 01fcaa9731 | |||
| ac8501ff8c | |||
| b6cddc21cd |
7
PRD.md
7
PRD.md
@ -170,6 +170,13 @@ TheNoiseClock is a SwiftUI-based iOS application that combines a customizable di
|
|||||||
|
|
||||||
## Technical Architecture
|
## 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
|
### Swift Package Architecture
|
||||||
|
|
||||||
TheNoiseClock has been refactored to use a modular Swift Package architecture for improved code reusability and maintainability:
|
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
|
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
|
## 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.
|
- 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).
|
- 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).
|
- 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 isShowingErrorAlert = false
|
||||||
var errorAlertMessage = ""
|
var errorAlertMessage = ""
|
||||||
|
|
||||||
|
private var isBypassingAlarmKitForUITests: Bool {
|
||||||
|
ProcessInfo.processInfo.arguments.contains("-uiTest.bypassAlarmKit")
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
init(alarmService: AlarmService = AlarmService.shared) {
|
init(alarmService: AlarmService = AlarmService.shared) {
|
||||||
self.alarmService = alarmService
|
self.alarmService = alarmService
|
||||||
@ -50,6 +54,9 @@ final class AlarmViewModel {
|
|||||||
|
|
||||||
/// Request AlarmKit authorization. Should be called during onboarding.
|
/// Request AlarmKit authorization. Should be called during onboarding.
|
||||||
func requestAlarmKitAuthorization() async -> Bool {
|
func requestAlarmKitAuthorization() async -> Bool {
|
||||||
|
if isBypassingAlarmKitForUITests {
|
||||||
|
return true
|
||||||
|
}
|
||||||
return await alarmKitService.requestAuthorization()
|
return await alarmKitService.requestAuthorization()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -59,6 +66,10 @@ final class AlarmViewModel {
|
|||||||
func addAlarm(_ alarm: Alarm, presentErrorAlert: Bool = true) async -> AlarmOperationResult {
|
func addAlarm(_ alarm: Alarm, presentErrorAlert: Bool = true) async -> AlarmOperationResult {
|
||||||
alarmService.addAlarm(alarm)
|
alarmService.addAlarm(alarm)
|
||||||
|
|
||||||
|
if isBypassingAlarmKitForUITests {
|
||||||
|
return .success
|
||||||
|
}
|
||||||
|
|
||||||
// Schedule with AlarmKit if alarm is enabled
|
// Schedule with AlarmKit if alarm is enabled
|
||||||
if alarm.isEnabled {
|
if alarm.isEnabled {
|
||||||
Design.debugLog("[alarms] Scheduling AlarmKit alarm for \(alarm.label)")
|
Design.debugLog("[alarms] Scheduling AlarmKit alarm for \(alarm.label)")
|
||||||
@ -84,6 +95,10 @@ final class AlarmViewModel {
|
|||||||
let previousAlarm = alarmService.getAlarm(id: alarm.id)
|
let previousAlarm = alarmService.getAlarm(id: alarm.id)
|
||||||
alarmService.updateAlarm(alarm)
|
alarmService.updateAlarm(alarm)
|
||||||
|
|
||||||
|
if isBypassingAlarmKitForUITests {
|
||||||
|
return .success
|
||||||
|
}
|
||||||
|
|
||||||
// Cancel existing and reschedule if enabled
|
// Cancel existing and reschedule if enabled
|
||||||
alarmKitService.cancelAlarm(id: alarm.id)
|
alarmKitService.cancelAlarm(id: alarm.id)
|
||||||
|
|
||||||
@ -114,6 +129,11 @@ final class AlarmViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func deleteAlarm(id: UUID) async {
|
func deleteAlarm(id: UUID) async {
|
||||||
|
if isBypassingAlarmKitForUITests {
|
||||||
|
alarmService.deleteAlarm(id: id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Cancel AlarmKit alarm first
|
// Cancel AlarmKit alarm first
|
||||||
alarmKitService.cancelAlarm(id: id)
|
alarmKitService.cancelAlarm(id: id)
|
||||||
|
|
||||||
@ -128,6 +148,10 @@ final class AlarmViewModel {
|
|||||||
alarm.isEnabled.toggle()
|
alarm.isEnabled.toggle()
|
||||||
alarmService.updateAlarm(alarm)
|
alarmService.updateAlarm(alarm)
|
||||||
|
|
||||||
|
if isBypassingAlarmKitForUITests {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Schedule or cancel based on new state
|
// Schedule or cancel based on new state
|
||||||
if alarm.isEnabled {
|
if alarm.isEnabled {
|
||||||
Design.debugLog("[alarms] Enabling AlarmKit alarm \(alarm.label)")
|
Design.debugLog("[alarms] Enabling AlarmKit alarm \(alarm.label)")
|
||||||
@ -179,6 +203,10 @@ final class AlarmViewModel {
|
|||||||
/// Reschedule all enabled alarms with AlarmKit.
|
/// Reschedule all enabled alarms with AlarmKit.
|
||||||
/// Call this on app launch to ensure alarms are registered.
|
/// Call this on app launch to ensure alarms are registered.
|
||||||
func rescheduleAllAlarms() async {
|
func rescheduleAllAlarms() async {
|
||||||
|
if isBypassingAlarmKitForUITests {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
Design.debugLog("[alarmkit] ========== RESCHEDULING ALL ALARMS ==========")
|
Design.debugLog("[alarmkit] ========== RESCHEDULING ALL ALARMS ==========")
|
||||||
|
|
||||||
let enabledAlarms = alarmService.getEnabledAlarms()
|
let enabledAlarms = alarmService.getEnabledAlarms()
|
||||||
|
|||||||
@ -38,8 +38,9 @@ struct AlarmRowView: View {
|
|||||||
|
|
||||||
Text(
|
Text(
|
||||||
String(
|
String(
|
||||||
localized: "alarm.row.sound_prefix",
|
format: String(localized: "alarm.row.sound_prefix", defaultValue: "• %1$@"),
|
||||||
defaultValue: "• \(AlarmSoundService.shared.getSoundDisplayName(alarm.soundName))"
|
locale: .current,
|
||||||
|
AlarmSoundService.shared.getSoundDisplayName(alarm.soundName)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
@ -80,8 +81,10 @@ struct AlarmRowView: View {
|
|||||||
.accessibilityIdentifier("alarms.row.\(alarm.id.uuidString)")
|
.accessibilityIdentifier("alarms.row.\(alarm.id.uuidString)")
|
||||||
.accessibilityLabel(
|
.accessibilityLabel(
|
||||||
String(
|
String(
|
||||||
localized: "alarm.row.accessibility_label",
|
format: String(localized: "alarm.row.accessibility_label", defaultValue: "%1$@, %2$@"),
|
||||||
defaultValue: "\(alarm.label), \(alarm.formattedTime())"
|
locale: .current,
|
||||||
|
alarm.label,
|
||||||
|
alarm.formattedTime()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.accessibilityValue(
|
.accessibilityValue(
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Bedrock
|
import Bedrock
|
||||||
|
import Foundation
|
||||||
|
|
||||||
/// View for selecting snooze duration
|
/// View for selecting snooze duration
|
||||||
struct SnoozeSelectionView: View {
|
struct SnoozeSelectionView: View {
|
||||||
@ -22,8 +23,9 @@ struct SnoozeSelectionView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
Text(
|
Text(
|
||||||
String(
|
String(
|
||||||
localized: "alarm.snooze.duration_minutes",
|
format: String(localized: "alarm.snooze.duration_minutes", defaultValue: "%lld minutes"),
|
||||||
defaultValue: "\(duration) minutes"
|
locale: .current,
|
||||||
|
duration
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.foregroundStyle(AppTextColors.primary)
|
.foregroundStyle(AppTextColors.primary)
|
||||||
|
|||||||
@ -196,8 +196,11 @@ struct ClockView: View {
|
|||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
String(
|
String(
|
||||||
localized: "clock.debug.orientation",
|
format: String(localized: "clock.debug.orientation", defaultValue: "Orientation: %1$@"),
|
||||||
defaultValue: "Orientation: \(isLandscape ? "Landscape" : "Portrait")"
|
locale: .current,
|
||||||
|
isLandscape
|
||||||
|
? String(localized: "clock.debug.orientation.landscape", defaultValue: "Landscape")
|
||||||
|
: String(localized: "clock.debug.orientation.portrait", defaultValue: "Portrait")
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -203,6 +203,7 @@ struct SoundCard: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityIdentifier("noise.soundCard.\(sound.id)")
|
||||||
.onLongPressGesture {
|
.onLongPressGesture {
|
||||||
onPreview()
|
onPreview()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -86,6 +86,11 @@ struct NoiseView: View {
|
|||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.animation(.easeInOut(duration: 0.3), value: selectedSound)
|
.animation(.easeInOut(duration: 0.3), value: selectedSound)
|
||||||
.accessibilityIdentifier("noise.screen")
|
.accessibilityIdentifier("noise.screen")
|
||||||
|
.onAppear {
|
||||||
|
Task { @MainActor in
|
||||||
|
await seedUITestAutoPlayIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Computed Properties
|
// 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
|
// MARK: - Preview
|
||||||
#Preview {
|
#Preview {
|
||||||
NoiseView()
|
NoiseView()
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -8,6 +8,52 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
final class TheNoiseClockUITests: XCTestCase {
|
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 {
|
override func setUpWithError() throws {
|
||||||
continueAfterFailure = false
|
continueAfterFailure = false
|
||||||
@ -18,18 +64,19 @@ final class TheNoiseClockUITests: XCTestCase {
|
|||||||
let app = XCUIApplication()
|
let app = XCUIApplication()
|
||||||
app.launchArguments += [
|
app.launchArguments += [
|
||||||
"-onboarding.TheNoiseClock.hasCompletedWelcome", "YES",
|
"-onboarding.TheNoiseClock.hasCompletedWelcome", "YES",
|
||||||
"-onboarding.TheNoiseClock.hasLaunched", "YES"
|
"-onboarding.TheNoiseClock.hasLaunched", "YES",
|
||||||
|
"-uiTest.bypassAlarmKit", "YES",
|
||||||
|
"-uiTest.autoPlayNoise", "YES"
|
||||||
]
|
]
|
||||||
app.launch()
|
app.launch()
|
||||||
|
|
||||||
dismissOnboardingIfNeeded(app)
|
dismissOnboardingIfNeeded(app)
|
||||||
ensureKeepAwakeEnabled(app)
|
ensureKeepAwakeEnabled(app)
|
||||||
ensureAlarmExistsAndEnabled(app)
|
|
||||||
|
|
||||||
captureNoiseViewPortraitAndLandscape(app)
|
captureClock(app)
|
||||||
captureClockPortraitAndLandscape(app)
|
captureAlarmsView(app)
|
||||||
captureAlarmsViewPortraitAndLandscape(app)
|
captureNoiseView(app)
|
||||||
captureSettingsViewPortraitAndLandscape(app)
|
captureSettingsView(app)
|
||||||
|
|
||||||
XCUIDevice.shared.orientation = .portrait
|
XCUIDevice.shared.orientation = .portrait
|
||||||
}
|
}
|
||||||
@ -37,98 +84,103 @@ final class TheNoiseClockUITests: XCTestCase {
|
|||||||
// MARK: - Capture Steps
|
// MARK: - Capture Steps
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func captureClockPortraitAndLandscape(_ app: XCUIApplication) {
|
private func captureClock(_ app: XCUIApplication) {
|
||||||
XCUIDevice.shared.orientation = .portrait
|
XCUIDevice.shared.orientation = .portrait
|
||||||
sleep(1)
|
sleep(1)
|
||||||
openTab(named: "Clock", in: app)
|
ensureCapturePreconditions(app)
|
||||||
|
openTab(.clock, in: app)
|
||||||
waitForClockFullscreenTransition()
|
waitForClockFullscreenTransition()
|
||||||
saveScreenshot(named: "01-clock-view-portrait.png")
|
saveScreenshot(named: "01-clock-view-portrait.png")
|
||||||
|
|
||||||
XCUIDevice.shared.orientation = .landscapeLeft
|
XCUIDevice.shared.orientation = .landscapeLeft
|
||||||
sleep(2)
|
sleep(2)
|
||||||
openTab(named: "Clock", in: app)
|
ensureCapturePreconditions(app)
|
||||||
|
openTab(.clock, in: app)
|
||||||
waitForClockFullscreenTransition()
|
waitForClockFullscreenTransition()
|
||||||
saveScreenshot(named: "02-clock-view-landscape.png")
|
saveScreenshot(named: "02-clock-view-landscape.png")
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func captureAlarmsViewPortraitAndLandscape(_ app: XCUIApplication) {
|
private func captureAlarmsView(_ app: XCUIApplication) {
|
||||||
XCUIDevice.shared.orientation = .portrait
|
XCUIDevice.shared.orientation = .portrait
|
||||||
sleep(1)
|
sleep(1)
|
||||||
openTab(named: "Alarms", in: app)
|
ensureCapturePreconditions(app)
|
||||||
|
openTab(.alarms, in: app)
|
||||||
sleep(1)
|
sleep(1)
|
||||||
saveScreenshot(named: "03-alarms-view-portrait.png")
|
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
|
@MainActor
|
||||||
private func captureNoiseViewPortraitAndLandscape(_ app: XCUIApplication) {
|
private func captureNoiseView(_ app: XCUIApplication) {
|
||||||
XCUIDevice.shared.orientation = .portrait
|
XCUIDevice.shared.orientation = .portrait
|
||||||
sleep(1)
|
sleep(1)
|
||||||
openTab(named: "Noise", in: app)
|
ensureCapturePreconditions(app)
|
||||||
ensureNoiseSoundSelectedAndPlaying(app)
|
openTab(.noise, in: app)
|
||||||
sleep(1)
|
sleep(1)
|
||||||
saveScreenshot(named: "05-noise-view-portrait.png")
|
saveScreenshot(named: "04-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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func captureSettingsViewPortraitAndLandscape(_ app: XCUIApplication) {
|
private func captureSettingsView(_ app: XCUIApplication) {
|
||||||
XCUIDevice.shared.orientation = .portrait
|
XCUIDevice.shared.orientation = .portrait
|
||||||
sleep(1)
|
sleep(1)
|
||||||
openTab(named: "Settings", in: app)
|
ensureCapturePreconditions(app)
|
||||||
|
openTab(.settings, in: app)
|
||||||
sleep(1)
|
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)
|
@MainActor
|
||||||
sleep(1)
|
private func ensureCapturePreconditions(_ app: XCUIApplication) {
|
||||||
saveScreenshot(named: "08-clock-settings-landscape.png")
|
ensureAlarmExistsAndEnabled(app)
|
||||||
|
ensureNoiseSoundSelectedAndPlaying(app)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func ensureNoiseSoundSelectedAndPlaying(_ app: XCUIApplication) {
|
private func ensureNoiseSoundSelectedAndPlaying(_ app: XCUIApplication) {
|
||||||
// Select a known sound so controls become visible.
|
openTab(.noise, in: app)
|
||||||
let soundNames = [
|
guard waitForScreen(.noise, in: app) else {
|
||||||
"White Noise",
|
XCTFail("Could not navigate to noise screen before selecting a sound.")
|
||||||
"Brown Noise",
|
return
|
||||||
"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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let playButton = app.buttons["Play Sound"]
|
let playButton = noisePlayControl(in: app)
|
||||||
if playButton.waitForExistence(timeout: 4) {
|
guard playButton.exists else {
|
||||||
playButton.tap()
|
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
|
// MARK: - State Setup
|
||||||
@ -136,38 +188,37 @@ final class TheNoiseClockUITests: XCTestCase {
|
|||||||
@MainActor
|
@MainActor
|
||||||
private func dismissOnboardingIfNeeded(_ app: XCUIApplication) {
|
private func dismissOnboardingIfNeeded(_ app: XCUIApplication) {
|
||||||
let secondaryButton = app.buttons["onboarding.secondaryButton"]
|
let secondaryButton = app.buttons["onboarding.secondaryButton"]
|
||||||
if secondaryButton.waitForExistence(timeout: 3), secondaryButton.label == "Skip" {
|
if secondaryButton.waitForExistence(timeout: 3) {
|
||||||
secondaryButton.tap()
|
secondaryButton.tap()
|
||||||
waitForMainTabs(app)
|
waitForMainTabs(app)
|
||||||
return
|
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..<10 {
|
||||||
for _ in 0..<6 {
|
|
||||||
if hasMainTabs(app) { return }
|
if hasMainTabs(app) { return }
|
||||||
let next = app.buttons["Next"]
|
let primaryButton = app.buttons["onboarding.primaryButton"]
|
||||||
let getStarted = app.buttons["Get Started"]
|
if primaryButton.waitForExistence(timeout: 1) {
|
||||||
|
primaryButton.tap()
|
||||||
if getStarted.exists {
|
|
||||||
getStarted.tap()
|
|
||||||
waitForMainTabs(app)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if next.exists {
|
|
||||||
next.tap()
|
|
||||||
usleep(300_000)
|
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
|
@MainActor
|
||||||
private func ensureKeepAwakeEnabled(_ app: XCUIApplication) {
|
private func ensureKeepAwakeEnabled(_ app: XCUIApplication) {
|
||||||
openTab(named: "Settings", in: app)
|
openTab(.settings, in: app)
|
||||||
|
|
||||||
let keepAwakeSwitch = app.switches["settings.keepAwake.toggle"]
|
let keepAwakeSwitch = app.switches["settings.keepAwake.toggle"]
|
||||||
for _ in 0..<8 {
|
for _ in 0..<8 {
|
||||||
@ -189,25 +240,30 @@ final class TheNoiseClockUITests: XCTestCase {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func ensureAlarmExistsAndEnabled(_ app: XCUIApplication) {
|
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.
|
// If no alarms exist, create one with defaults.
|
||||||
if app.staticTexts["No Alarms Set"].exists || app.buttons["Add Your First Alarm"].exists {
|
let firstSwitch = app.switches.firstMatch
|
||||||
let addFirstAlarm = app.buttons["Add Your First Alarm"]
|
if !firstSwitch.waitForExistence(timeout: 2) {
|
||||||
if addFirstAlarm.exists {
|
|
||||||
addFirstAlarm.tap()
|
|
||||||
} else {
|
|
||||||
tapNavigationPlusButton(in: app)
|
tapNavigationPlusButton(in: app)
|
||||||
}
|
|
||||||
|
|
||||||
let saveButton = app.buttons["Save"]
|
let saveButton = app.buttons["alarms.add.saveButton"]
|
||||||
XCTAssertTrue(saveButton.waitForExistence(timeout: 5), "Add Alarm sheet did not appear.")
|
XCTAssertTrue(saveButton.waitForExistence(timeout: 5), "Add Alarm sheet did not appear.")
|
||||||
saveButton.tap()
|
saveButton.tap()
|
||||||
sleep(1)
|
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.
|
// Make sure at least one alarm is enabled.
|
||||||
let firstSwitch = app.switches.firstMatch
|
|
||||||
if firstSwitch.waitForExistence(timeout: 3), !isSwitchOn(firstSwitch) {
|
if firstSwitch.waitForExistence(timeout: 3), !isSwitchOn(firstSwitch) {
|
||||||
firstSwitch.tap()
|
firstSwitch.tap()
|
||||||
sleep(1)
|
sleep(1)
|
||||||
@ -217,13 +273,9 @@ final class TheNoiseClockUITests: XCTestCase {
|
|||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func openTab(named tabName: String, in app: XCUIApplication) {
|
private func openTab(_ tab: CaptureTab, in app: XCUIApplication) {
|
||||||
let expectedIndex: Int? = switch tabName {
|
if isScreenVisible(identifier: tab.screenIdentifier, in: app) {
|
||||||
case "Clock": 0
|
return
|
||||||
case "Alarms": 1
|
|
||||||
case "Noise": 2
|
|
||||||
case "Settings": 3
|
|
||||||
default: nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func tapByCoordinates(_ element: XCUIElement) {
|
func tapByCoordinates(_ element: XCUIElement) {
|
||||||
@ -232,8 +284,28 @@ final class TheNoiseClockUITests: XCTestCase {
|
|||||||
coordinate.tap()
|
coordinate.tap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func tapAndConfirm(_ tapAction: () -> Bool) -> Bool {
|
||||||
|
guard tapAction() else { return false }
|
||||||
|
return waitForScreen(tab, in: app, timeout: 1.5)
|
||||||
|
}
|
||||||
|
|
||||||
func tapLabeledButtonAnywhere() -> Bool {
|
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
|
.allElementsBoundByIndex
|
||||||
.filter(\.exists)
|
.filter(\.exists)
|
||||||
guard !matches.isEmpty else { return false }
|
guard !matches.isEmpty else { return false }
|
||||||
@ -248,7 +320,21 @@ final class TheNoiseClockUITests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func tapLabeledButton() -> Bool {
|
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)
|
let matches = query.allElementsBoundByIndex.filter(\.exists)
|
||||||
guard !matches.isEmpty else { return false }
|
guard !matches.isEmpty else { return false }
|
||||||
|
|
||||||
@ -262,23 +348,22 @@ final class TheNoiseClockUITests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func tapIndexedButton() -> Bool {
|
func tapIndexedButton() -> Bool {
|
||||||
guard let expectedIndex else { return false }
|
|
||||||
let tabBar = app.tabBars.firstMatch
|
let tabBar = app.tabBars.firstMatch
|
||||||
guard tabBar.exists else { return false }
|
guard tabBar.exists else { return false }
|
||||||
let buttons = tabBar.buttons
|
let buttons = tabBar.buttons
|
||||||
guard buttons.count > expectedIndex else { return false }
|
guard buttons.count > tab.index else { return false }
|
||||||
let button = buttons.element(boundBy: expectedIndex)
|
let button = buttons.element(boundBy: tab.index)
|
||||||
guard button.exists else { return false }
|
guard button.exists else { return false }
|
||||||
button.tap()
|
button.tap()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if tapLabeledButton() || tapIndexedButton() {
|
if tapAndConfirm(tapIndexedButton) || tapAndConfirm(tapLabeledButton) || tapAndConfirm(tapSymbolButton) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback for cases where tab buttons are not exposed under tabBars.
|
// Fallback for cases where tab buttons are not exposed under tabBars.
|
||||||
if tapLabeledButtonAnywhere() {
|
if tapAndConfirm(tapLabeledButtonAnywhere) || tapAndConfirm(tapSymbolButtonAnywhere) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -288,7 +373,12 @@ final class TheNoiseClockUITests: XCTestCase {
|
|||||||
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.98)).tap()
|
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.98)).tap()
|
||||||
usleep(300_000)
|
usleep(300_000)
|
||||||
|
|
||||||
if tapLabeledButton() || tapIndexedButton() || tapLabeledButtonAnywhere() {
|
if tapAndConfirm(tapIndexedButton) || tapAndConfirm(tapLabeledButton) || tapAndConfirm(tapSymbolButton) || tapAndConfirm(tapLabeledButtonAnywhere) || tapAndConfirm(tapSymbolButtonAnywhere) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dismissInterferingPromptIfPresent(in: app)
|
||||||
|
if tapAndConfirm(tapIndexedButton) || tapAndConfirm(tapLabeledButton) || tapAndConfirm(tapSymbolButton) || tapAndConfirm(tapLabeledButtonAnywhere) || tapAndConfirm(tapSymbolButtonAnywhere) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -296,12 +386,13 @@ final class TheNoiseClockUITests: XCTestCase {
|
|||||||
.prefix(16)
|
.prefix(16)
|
||||||
.map(\.label)
|
.map(\.label)
|
||||||
.joined(separator: ", ")
|
.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
|
@MainActor
|
||||||
private func tapNavigationPlusButton(in app: XCUIApplication) {
|
private func tapNavigationPlusButton(in app: XCUIApplication) {
|
||||||
let plusCandidates = [
|
let plusCandidates = [
|
||||||
|
app.buttons["alarms.addButton"],
|
||||||
app.navigationBars.buttons["plus"],
|
app.navigationBars.buttons["plus"],
|
||||||
app.navigationBars.buttons["Add"],
|
app.navigationBars.buttons["Add"],
|
||||||
app.buttons["plus"]
|
app.buttons["plus"]
|
||||||
@ -315,6 +406,24 @@ final class TheNoiseClockUITests: XCTestCase {
|
|||||||
XCTFail("Could not find add (+) button.")
|
XCTFail("Could not find add (+) button.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func dismissInterferingPromptIfPresent(in app: XCUIApplication) {
|
||||||
|
guard app.tabBars.count == 0 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.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
private func isSwitchOn(_ toggle: XCUIElement) -> Bool {
|
private func isSwitchOn(_ toggle: XCUIElement) -> Bool {
|
||||||
guard let value = toggle.value as? String else { return false }
|
guard let value = toggle.value as? String else { return false }
|
||||||
return value == "1" || value.lowercased() == "on"
|
return value == "1" || value.lowercased() == "on"
|
||||||
@ -358,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 {
|
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 }
|
if app.tabBars.count > 0 { return true }
|
||||||
return labels.contains(where: { app.buttons[$0].exists })
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
100
scripts/fill-missing-translations.mjs
Executable file
100
scripts/fill-missing-translations.mjs
Executable file
@ -0,0 +1,100 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
|
||||||
|
const filePath = process.argv[2] ?? 'TheNoiseClock/Localizable.xcstrings';
|
||||||
|
const dryRun = process.argv.includes('--dry-run');
|
||||||
|
|
||||||
|
const raw = await fs.readFile(filePath, 'utf8');
|
||||||
|
const catalog = JSON.parse(raw);
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
const cache = new Map();
|
||||||
|
|
||||||
|
async function translate(text, targetLang) {
|
||||||
|
const key = `${targetLang}::${text}`;
|
||||||
|
if (cache.has(key)) return cache.get(key);
|
||||||
|
|
||||||
|
const url = `https://translate.googleapis.com/translate_a/single?client=gtx&sl=en&tl=${encodeURIComponent(targetLang)}&dt=t&q=${encodeURIComponent(text)}`;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0',
|
||||||
|
'Accept': 'application/json, text/plain, */*'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
|
||||||
|
const body = await res.json();
|
||||||
|
const translated = Array.isArray(body?.[0])
|
||||||
|
? body[0].map((part) => part?.[0] ?? '').join('')
|
||||||
|
: '';
|
||||||
|
|
||||||
|
if (!translated || translated.trim().length === 0) {
|
||||||
|
throw new Error('Empty translation payload');
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.set(key, translated);
|
||||||
|
await sleep(80);
|
||||||
|
return translated;
|
||||||
|
} catch (error) {
|
||||||
|
if (attempt === 3) throw error;
|
||||||
|
await sleep(300 * attempt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalUpdated = 0;
|
||||||
|
let esUpdated = 0;
|
||||||
|
let frUpdated = 0;
|
||||||
|
|
||||||
|
for (const [k, entry] of Object.entries(catalog.strings ?? {})) {
|
||||||
|
const localizations = entry.localizations ?? {};
|
||||||
|
const en = localizations.en?.stringUnit?.value ?? '';
|
||||||
|
if (!en) continue;
|
||||||
|
|
||||||
|
const es = localizations['es-MX']?.stringUnit?.value ?? '';
|
||||||
|
const fr = localizations['fr-CA']?.stringUnit?.value ?? '';
|
||||||
|
|
||||||
|
let touched = false;
|
||||||
|
|
||||||
|
if (!es || es === en) {
|
||||||
|
const translated = await translate(en, 'es');
|
||||||
|
entry.localizations ??= {};
|
||||||
|
entry.localizations['es-MX'] ??= { stringUnit: { state: 'translated', value: '' } };
|
||||||
|
entry.localizations['es-MX'].stringUnit ??= { state: 'translated', value: '' };
|
||||||
|
entry.localizations['es-MX'].stringUnit.state = 'translated';
|
||||||
|
entry.localizations['es-MX'].stringUnit.value = translated;
|
||||||
|
esUpdated += 1;
|
||||||
|
touched = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fr || fr === en) {
|
||||||
|
const translated = await translate(en, 'fr');
|
||||||
|
entry.localizations ??= {};
|
||||||
|
entry.localizations['fr-CA'] ??= { stringUnit: { state: 'translated', value: '' } };
|
||||||
|
entry.localizations['fr-CA'].stringUnit ??= { state: 'translated', value: '' };
|
||||||
|
entry.localizations['fr-CA'].stringUnit.state = 'translated';
|
||||||
|
entry.localizations['fr-CA'].stringUnit.value = translated;
|
||||||
|
frUpdated += 1;
|
||||||
|
touched = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (touched) totalUpdated += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dryRun) {
|
||||||
|
await fs.writeFile(filePath, `${JSON.stringify(catalog, null, 2)}\n`, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
filePath,
|
||||||
|
totalKeysTouched: totalUpdated,
|
||||||
|
esUpdated,
|
||||||
|
frUpdated,
|
||||||
|
dryRun
|
||||||
|
}, null, 2));
|
||||||
174
scripts/run-screenshot-matrix.sh
Executable file
174
scripts/run-screenshot-matrix.sh
Executable file
@ -0,0 +1,174 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
WORKSPACE_ROOT="$(cd "${PROJECT_ROOT}/.." && pwd)"
|
||||||
|
|
||||||
|
PROJECT_PATH="${PROJECT_ROOT}/TheNoiseClock.xcodeproj"
|
||||||
|
SCHEME="${SCHEME:-TheNoiseClock}"
|
||||||
|
TEST_IDENTIFIER="${TEST_IDENTIFIER:-TheNoiseClockUITests/TheNoiseClockUITests/testAppStoreScreenshots_iPhone69}"
|
||||||
|
RUNTIME_ID="${RUNTIME_ID:-com.apple.CoreSimulator.SimRuntime.iOS-26-2}"
|
||||||
|
OUTPUT_ROOT="${OUTPUT_ROOT:-${WORKSPACE_ROOT}/Screenshots}"
|
||||||
|
EXPECTED_COUNT=5
|
||||||
|
|
||||||
|
expected_files=(
|
||||||
|
"01-clock-view-portrait.png"
|
||||||
|
"02-clock-view-landscape.png"
|
||||||
|
"03-alarms-view-portrait.png"
|
||||||
|
"04-noise-view-portrait.png"
|
||||||
|
"05-clock-settings-portrait.png"
|
||||||
|
)
|
||||||
|
|
||||||
|
locales=(
|
||||||
|
"fr-CA|fr|CA"
|
||||||
|
"es-MX|es|MX"
|
||||||
|
)
|
||||||
|
|
||||||
|
devices=(
|
||||||
|
"iPhone-6.3|AppStore iPhone 17 Pro 26.2|com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro"
|
||||||
|
"iPhone-6.5|AppStore iPhone 11 Pro Max 26.2|com.apple.CoreSimulator.SimDeviceType.iPhone-11-Pro-Max"
|
||||||
|
"iPhone-6.9|AppStore iPhone 17 Pro Max 26.2|com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro-Max"
|
||||||
|
"iPad-13|AppStore iPad Pro 13-inch (M5) 26.2|com.apple.CoreSimulator.SimDeviceType.iPad-Pro-13-inch-M5-12GB"
|
||||||
|
)
|
||||||
|
|
||||||
|
simctl_json() {
|
||||||
|
xcrun simctl list devices available -j
|
||||||
|
}
|
||||||
|
|
||||||
|
find_sim_udid() {
|
||||||
|
local runtime_id="$1"
|
||||||
|
local name="$2"
|
||||||
|
simctl_json | jq -r --arg runtime_id "$runtime_id" --arg name "$name" '
|
||||||
|
.devices[$runtime_id][]? | select(.isAvailable == true and .name == $name) | .udid
|
||||||
|
' | head -n1
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_simulator() {
|
||||||
|
local name="$1"
|
||||||
|
local device_type="$2"
|
||||||
|
local runtime_id="$3"
|
||||||
|
local udid
|
||||||
|
|
||||||
|
udid="$(find_sim_udid "$runtime_id" "$name")"
|
||||||
|
if [[ -n "$udid" ]]; then
|
||||||
|
echo "$udid"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
udid="$(xcrun simctl create "$name" "$device_type" "$runtime_id")"
|
||||||
|
echo "$udid"
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_output_folder() {
|
||||||
|
local folder="$1"
|
||||||
|
|
||||||
|
local png_count
|
||||||
|
png_count="$(find "$folder" -maxdepth 1 -name '*.png' | wc -l | tr -d ' ')"
|
||||||
|
if [[ "$png_count" -ne "$EXPECTED_COUNT" ]]; then
|
||||||
|
echo "Expected ${EXPECTED_COUNT} screenshots in ${folder}, found ${png_count}." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local missing=0
|
||||||
|
for file_name in "${expected_files[@]}"; do
|
||||||
|
if [[ ! -f "${folder}/${file_name}" ]]; then
|
||||||
|
echo "Missing ${folder}/${file_name}" >&2
|
||||||
|
missing=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [[ "$missing" -ne 0 ]]; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
export_attachments_to_output_folder() {
|
||||||
|
local result_bundle="$1"
|
||||||
|
local folder="$2"
|
||||||
|
local attachments_tmp
|
||||||
|
attachments_tmp="$(mktemp -d "/tmp/noiseclock-attachments.XXXXXX")"
|
||||||
|
|
||||||
|
xcrun xcresulttool export attachments \
|
||||||
|
--path "$result_bundle" \
|
||||||
|
--output-path "$attachments_tmp" >/dev/null
|
||||||
|
|
||||||
|
local manifest_path="${attachments_tmp}/manifest.json"
|
||||||
|
if [[ ! -f "$manifest_path" ]]; then
|
||||||
|
echo "Missing attachment manifest at ${manifest_path}" >&2
|
||||||
|
rm -rf "$attachments_tmp"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local missing=0
|
||||||
|
for file_name in "${expected_files[@]}"; do
|
||||||
|
local base_name="${file_name%.png}"
|
||||||
|
local exported_name
|
||||||
|
exported_name="$(jq -r --arg base_name "$base_name" '
|
||||||
|
[.[].attachments[]?
|
||||||
|
| select((.suggestedHumanReadableName // "") | startswith($base_name + "_"))
|
||||||
|
| .exportedFileName][0] // empty
|
||||||
|
' "$manifest_path")"
|
||||||
|
|
||||||
|
if [[ -z "$exported_name" || ! -f "${attachments_tmp}/${exported_name}" ]]; then
|
||||||
|
echo "Missing exported attachment for ${file_name} in ${result_bundle}" >&2
|
||||||
|
missing=1
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
cp "${attachments_tmp}/${exported_name}" "${folder}/${file_name}"
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ "$missing" -ne 0 ]]; then
|
||||||
|
rm -rf "$attachments_tmp"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -rf "$attachments_tmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Using project: ${PROJECT_PATH}"
|
||||||
|
echo "Using scheme: ${SCHEME}"
|
||||||
|
echo "Output root: ${OUTPUT_ROOT}"
|
||||||
|
echo "Runtime: ${RUNTIME_ID}"
|
||||||
|
|
||||||
|
mkdir -p "$OUTPUT_ROOT"
|
||||||
|
|
||||||
|
for locale_entry in "${locales[@]}"; do
|
||||||
|
IFS='|' read -r locale_folder language_code region_code <<< "$locale_entry"
|
||||||
|
echo ""
|
||||||
|
echo "=== Locale ${locale_folder} (${language_code}-${region_code}) ==="
|
||||||
|
|
||||||
|
for device_entry in "${devices[@]}"; do
|
||||||
|
IFS='|' read -r device_folder sim_name device_type <<< "$device_entry"
|
||||||
|
echo "--- Device ${device_folder} (${sim_name}) ---"
|
||||||
|
|
||||||
|
udid="$(ensure_simulator "$sim_name" "$device_type" "$RUNTIME_ID")"
|
||||||
|
output_dir="${OUTPUT_ROOT}/${locale_folder}/${device_folder}"
|
||||||
|
result_bundle="/tmp/TheNoiseClock-${locale_folder}-${device_folder}.xcresult"
|
||||||
|
|
||||||
|
rm -rf "$result_bundle"
|
||||||
|
mkdir -p "$output_dir"
|
||||||
|
rm -f "${output_dir}"/*.png
|
||||||
|
|
||||||
|
xcrun simctl shutdown "$udid" >/dev/null 2>&1 || true
|
||||||
|
xcrun simctl erase "$udid" >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
SCREENSHOT_OUTPUT_DIR="$output_dir" xcodebuild \
|
||||||
|
-project "$PROJECT_PATH" \
|
||||||
|
-scheme "$SCHEME" \
|
||||||
|
-destination "platform=iOS Simulator,id=${udid}" \
|
||||||
|
-testLanguage "$language_code" \
|
||||||
|
-testRegion "$region_code" \
|
||||||
|
-only-testing:"${TEST_IDENTIFIER}" \
|
||||||
|
-parallel-testing-enabled NO \
|
||||||
|
-resultBundlePath "$result_bundle" \
|
||||||
|
test
|
||||||
|
|
||||||
|
export_attachments_to_output_folder "$result_bundle" "$output_dir"
|
||||||
|
validate_output_folder "$output_dir"
|
||||||
|
echo "Saved ${EXPECTED_COUNT} screenshots to ${output_dir}"
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Screenshot matrix completed successfully."
|
||||||
51
scripts/validate-localization-keys.sh
Executable file
51
scripts/validate-localization-keys.sh
Executable file
@ -0,0 +1,51 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Validates that .xcstrings keys follow key-only convention:
|
||||||
|
# lower_snake segments separated by dots
|
||||||
|
# e.g. "settings.display.section_title"
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# scripts/validate-localization-keys.sh
|
||||||
|
# scripts/validate-localization-keys.sh path/to/Localizable.xcstrings [...]
|
||||||
|
|
||||||
|
if ! command -v jq >/dev/null 2>&1; then
|
||||||
|
echo "error: 'jq' is required but not installed." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|
||||||
|
if [[ $# -gt 0 ]]; then
|
||||||
|
files=("$@")
|
||||||
|
else
|
||||||
|
files=("$repo_root/TheNoiseClock/Localizable.xcstrings")
|
||||||
|
fi
|
||||||
|
|
||||||
|
key_regex='^[a-z0-9_]+(\.[a-z0-9_]+)+$'
|
||||||
|
had_error=0
|
||||||
|
|
||||||
|
for file in "${files[@]}"; do
|
||||||
|
if [[ ! -f "$file" ]]; then
|
||||||
|
echo "error: file not found: $file" >&2
|
||||||
|
had_error=1
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
invalid_keys="$(
|
||||||
|
jq -r '.strings | keys[]' "$file" | rg -v "$key_regex" || true
|
||||||
|
)"
|
||||||
|
|
||||||
|
if [[ -n "$invalid_keys" ]]; then
|
||||||
|
echo "error: invalid localization keys in $file" >&2
|
||||||
|
echo "$invalid_keys" | sed 's/^/ - /' >&2
|
||||||
|
had_error=1
|
||||||
|
else
|
||||||
|
echo "ok: $file"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ $had_error -ne 0 ]]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
Loading…
Reference in New Issue
Block a user