remindwall / RemindWallUITests / RemindWallUITests.swift
Back to remindwall — Current RemindWall
//
// RemindWallUITests.swift
// RemindWallUITests
//
// Created by Christopher Hotchkiss on 3/2/26.
//
import XCTest
final class RemindWallUITests: XCTestCase {
override func setUpWithError() throws {
continueAfterFailure = false
}
@MainActor
private func launchApp() -> XCUIApplication {
let app = XCUIApplication()
app.launchEnvironment["UITesting"] = "true"
app.launch()
return app
}
/// On iPad, Form section header toggles render as full-width cells.
/// Tapping the center hits the label, not the switch. This helper taps the actual switch control.
@MainActor
private func tapToggle(_ toggle: XCUIElement) {
let innerSwitch = toggle.switches.firstMatch
if innerSwitch.exists {
innerSwitch.tap()
} else {
toggle.tap()
}
}
@MainActor
func testSettingsScreenLoads() throws {
let app = launchApp()
let settingsNav = app.navigationBars["Settings"]
XCTAssertTrue(settingsNav.waitForExistence(timeout: 5), "Settings navigation bar should appear")
}
@MainActor
func testStartSlideshowButtonExists() throws {
let app = launchApp()
let startButton = app.buttons["Start Slideshow"]
XCTAssertTrue(startButton.waitForExistence(timeout: 5), "Start Slideshow button should exist")
}
@MainActor
func testScreenOffToggleExists() throws {
let app = launchApp()
let screenOffToggle = app.switches["Screen Off"]
XCTAssertTrue(screenOffToggle.waitForExistence(timeout: 5), "Screen Off toggle should appear in section header")
}
@MainActor
func testScreenOffToggleShowsTimePickers() throws {
let app = launchApp()
let screenOffToggle = app.switches["Screen Off"]
XCTAssertTrue(screenOffToggle.waitForExistence(timeout: 5), "Screen Off toggle should exist")
// Enable screen off
if screenOffToggle.value as? String == "0" {
tapToggle(screenOffToggle)
}
// Verify the time picker controls appear
let screenOffLabel = app.descendants(matching: .any)["SCREEN OFF"]
XCTAssertTrue(screenOffLabel.waitForExistence(timeout: 5), "SCREEN OFF label should appear when enabled")
let screenOnLabel = app.descendants(matching: .any)["SCREEN ON"]
XCTAssertTrue(screenOnLabel.waitForExistence(timeout: 5), "SCREEN ON label should appear when enabled")
}
@MainActor
func testScreenOffToggleHidesTimePickers() throws {
let app = launchApp()
let screenOffToggle = app.switches["Screen Off"]
XCTAssertTrue(screenOffToggle.waitForExistence(timeout: 5), "Screen Off toggle should exist")
// Enable then disable
if screenOffToggle.value as? String == "0" {
tapToggle(screenOffToggle)
}
// Now disable
tapToggle(screenOffToggle)
// Verify the time picker controls disappear
let screenOffLabel = app.descendants(matching: .any)["SCREEN OFF"]
XCTAssertFalse(screenOffLabel.exists, "SCREEN OFF label should not appear when disabled")
}
@MainActor
func testDeleteReminderWithButton() throws {
let app = launchApp()
// Wait for Settings to load
let settingsNav = app.navigationBars["Settings"]
XCTAssertTrue(settingsNav.waitForExistence(timeout: 5), "Settings should load")
// Navigate to a trackee detail (seed data provides "Alice")
let aliceCell = app.cells.containing(.staticText, identifier: "Alice").firstMatch
XCTAssertTrue(aliceCell.waitForExistence(timeout: 10), "Alice trackee should appear")
aliceCell.tap()
// Wait for the trackee detail to load
let deleteAliceButton = app.buttons["Delete Alice"]
XCTAssertTrue(deleteAliceButton.waitForExistence(timeout: 10), "Alice detail screen should load")
// Tap the add reminder button
let addButton = app.buttons["Add"]
XCTAssertTrue(addButton.waitForExistence(timeout: 5), "Add button should appear")
addButton.tap()
// Verify Add Reminder sheet appears
let addReminderNav = app.navigationBars["Add Reminder"]
XCTAssertTrue(addReminderNav.waitForExistence(timeout: 5), "Add Reminder sheet should appear")
// Save the default reminder (Sunday)
let saveButton = app.buttons["Save"]
XCTAssertTrue(saveButton.waitForExistence(timeout: 5), "Save button should appear")
saveButton.tap()
// Wait for the reminder row to appear
let sundayText = app.staticTexts["Sunday"]
XCTAssertTrue(sundayText.waitForExistence(timeout: 10), "Reminder row should appear after saving")
// Tap the trash button to delete the reminder
let trashButton = app.buttons["Delete Reminder"]
XCTAssertTrue(trashButton.waitForExistence(timeout: 5), "Per-row delete button should appear")
trashButton.tap()
// Verify the reminder is gone
let reminderGone = sundayText.waitForNonExistence(timeout: 5)
XCTAssertTrue(reminderGone, "Reminder should be deleted after tapping delete button")
}
@MainActor
func testAddReminderSheetStaysOpen() throws {
let app = launchApp()
// Wait for Settings to load
let settingsNav = app.navigationBars["Settings"]
XCTAssertTrue(settingsNav.waitForExistence(timeout: 5), "Settings should load")
// Navigate to a trackee detail (seed data provides "Alice")
// On Catalyst, NavigationLink in a Form may render multiple button elements;
// use the first matching cell to navigate.
let aliceCell = app.cells.containing(.staticText, identifier: "Alice").firstMatch
XCTAssertTrue(aliceCell.waitForExistence(timeout: 10), "Alice trackee should appear")
aliceCell.tap()
// Wait for the trackee detail to load — check for content that appears on the detail screen
let deleteButton = app.buttons["Delete Alice"]
XCTAssertTrue(deleteButton.waitForExistence(timeout: 10), "Alice detail screen should appear with Delete button")
// Tap the add reminder button (the "+" toolbar button)
let addButton = app.buttons["Add"]
XCTAssertTrue(addButton.waitForExistence(timeout: 5), "Add button should appear in toolbar")
addButton.tap()
// Verify the Add Reminder sheet stays visible and doesn't immediately dismiss
let addReminderNav = app.navigationBars["Add Reminder"]
XCTAssertTrue(addReminderNav.waitForExistence(timeout: 5), "Add Reminder sheet should stay open after tapping add button")
// Verify key content is visible on the sheet
let saveButton = app.buttons["Save"]
XCTAssertTrue(saveButton.exists, "Save button should be visible on the Add Reminder sheet")
let cancelButton = app.buttons["Cancel"]
XCTAssertTrue(cancelButton.exists, "Cancel button should be visible on the Add Reminder sheet")
}
@MainActor
func testDashboardScreenLoads() throws {
let app = launchApp()
// Wait for Settings to load
let startButton = app.buttons["Start Slideshow"]
XCTAssertTrue(startButton.waitForExistence(timeout: 5), "Start Slideshow button should exist")
// Tap to navigate to Dashboard
startButton.tap()
// Settings nav should disappear, confirming navigation to Dashboard
let settingsNav = app.navigationBars["Settings"]
let settingsGone = settingsNav.waitForNonExistence(timeout: 10)
XCTAssertTrue(settingsGone, "Settings navigation bar should disappear after starting slideshow")
}
@MainActor
func testSlideshowAdvancesPhotos() throws {
let app = launchApp()
// Handle photo library permission dialog if it appears
addUIInterruptionMonitor(withDescription: "Photo Library Access") { alert in
let allowButton = alert.buttons["Allow Full Access"]
if allowButton.exists {
allowButton.tap()
return true
}
let okButton = alert.buttons["OK"]
if okButton.exists {
okButton.tap()
return true
}
return false
}
// Wait for Settings to load
let settingsNav = app.navigationBars["Settings"]
XCTAssertTrue(settingsNav.waitForExistence(timeout: 5), "Settings should load")
// Enable the Slideshow toggle if not already on
let slideshowToggle = app.switches["Slideshow"]
XCTAssertTrue(slideshowToggle.waitForExistence(timeout: 5), "Slideshow toggle should exist")
if slideshowToggle.value as? String == "0" {
tapToggle(slideshowToggle)
}
// If photo authorization is needed, tap "Authorize Photo Access" first
let authButton = app.buttons["Authorize Photo Access"]
if authButton.waitForExistence(timeout: 3) {
authButton.tap()
app.tap() // trigger interruption monitor for system dialog
}
// Wait for albums to load and the picker to appear.
// On macCatalyst the Picker("Albums",...) with .navigationLink style renders as a button.
// Skip if photo library isn't authorized (environment-dependent)
let albumButton = app.buttons["Albums"]
try XCTSkipUnless(albumButton.waitForExistence(timeout: 15),
"Skipping: photo library not authorized or no albums available")
// Tap the Albums picker - on Catalyst this pushes a navigation view with album options
albumButton.tap()
// On macCatalyst, the navigationLink picker pushes a CollectionView with album cells.
// Each cell contains a Button with the album name.
// The first album may already appear Selected (default), so tap the second one
// to ensure a real selection change that triggers navigation back.
let pickerCollection = app.collectionViews.firstMatch
XCTAssertTrue(pickerCollection.waitForExistence(timeout: 5),
"Picker should push a collection view with album options")
let albumButtons = pickerCollection.buttons
try XCTSkipUnless(albumButtons.count >= 1,
"Skipping: no album buttons available on this simulator")
// Tap an album — prefer the second one for a different selection, fall back to first
let albumIndex = albumButtons.count >= 2 ? 1 : 0
albumButtons.element(boundBy: albumIndex).tap()
// On macCatalyst, the picker may not auto-navigate back.
// If "Start Slideshow" doesn't appear, tap the Back button to navigate back.
let startButton = app.buttons["Start Slideshow"]
if !startButton.waitForExistence(timeout: 3) {
let backButton = app.navigationBars.buttons["Settings"]
if backButton.exists {
backButton.tap()
}
}
// After selecting, the picker should pop back to Settings.
// Wait for "Start Slideshow" to become available.
XCTAssertTrue(startButton.waitForExistence(timeout: 10),
"Start Slideshow button should exist after selecting album")
startButton.tap()
// Verify we navigated to the Dashboard.
// SwiftUI merges child accessibility elements into the parent DashboardView,
// so the photo's accessibility value (asset localIdentifier) appears on the
// DashboardView element itself rather than on a separate SlideshowPhoto element.
let dashboard = app.descendants(matching: .any)["DashboardView"]
XCTAssertTrue(dashboard.waitForExistence(timeout: 15),
"Dashboard should appear after starting slideshow")
// Wait for a photo to load - the dashboard's value will contain the asset identifier
let hasValue = NSPredicate(format: "value != nil AND value != ''")
let valueLoaded = XCTNSPredicateExpectation(predicate: hasValue, object: dashboard)
let loadResult = XCTWaiter.wait(for: [valueLoaded], timeout: 15)
XCTAssertEqual(loadResult, .completed,
"Dashboard should load a photo with an asset identifier value")
// Record the initial photo's accessibility value
let initialValue = dashboard.value as? String ?? ""
XCTAssertFalse(initialValue.isEmpty,
"Dashboard should have a value with the asset identifier")
// Wait for the slideshow to advance (timer is 10 seconds + buffer)
let expectation = XCTNSPredicateExpectation(
predicate: NSPredicate(format: "value != %@", initialValue),
object: dashboard
)
let result = XCTWaiter.wait(for: [expectation], timeout: 20)
XCTAssertEqual(result, .completed,
"Slideshow should advance to a different photo within 20 seconds")
}
@MainActor
func testTapSlideshowReturnsToSettings() throws {
let app = launchApp()
// Handle photo library permission dialog if it appears
addUIInterruptionMonitor(withDescription: "Photo Library Access") { alert in
let allowButton = alert.buttons["Allow Full Access"]
if allowButton.exists {
allowButton.tap()
return true
}
let okButton = alert.buttons["OK"]
if okButton.exists {
okButton.tap()
return true
}
return false
}
// Wait for Settings to load
let settingsNav = app.navigationBars["Settings"]
XCTAssertTrue(settingsNav.waitForExistence(timeout: 5), "Settings should load")
// Enable the Slideshow toggle if not already on
let slideshowToggle = app.switches["Slideshow"]
XCTAssertTrue(slideshowToggle.waitForExistence(timeout: 5), "Slideshow toggle should exist")
if slideshowToggle.value as? String == "0" {
tapToggle(slideshowToggle)
}
// If photo authorization is needed, tap "Authorize Photo Access" first
let authButton = app.buttons["Authorize Photo Access"]
if authButton.waitForExistence(timeout: 3) {
authButton.tap()
app.tap()
}
// Select an album — skip if photo library isn't authorized
let albumButton = app.buttons["Albums"]
try XCTSkipUnless(albumButton.waitForExistence(timeout: 15),
"Skipping: photo library not authorized or no albums available")
albumButton.tap()
let pickerCollection = app.collectionViews.firstMatch
XCTAssertTrue(pickerCollection.waitForExistence(timeout: 5),
"Picker should push a collection view")
let albumButtons = pickerCollection.buttons
try XCTSkipUnless(albumButtons.count >= 1,
"Skipping: no album buttons available on this simulator")
// Tap an album — prefer the second one for a different selection, fall back to first
let albumIndex = albumButtons.count >= 2 ? 1 : 0
albumButtons.element(boundBy: albumIndex).tap()
let startButton = app.buttons["Start Slideshow"]
if !startButton.waitForExistence(timeout: 3) {
let backButton = app.navigationBars.buttons["Settings"]
if backButton.exists {
backButton.tap()
}
}
XCTAssertTrue(startButton.waitForExistence(timeout: 10),
"Start Slideshow button should exist after selecting album")
startButton.tap()
// Wait for the Dashboard with a loaded photo
let dashboard = app.descendants(matching: .any)["DashboardView"]
XCTAssertTrue(dashboard.waitForExistence(timeout: 15),
"Dashboard should appear after starting slideshow")
let hasValue = NSPredicate(format: "value != nil AND value != ''")
let valueLoaded = XCTNSPredicateExpectation(predicate: hasValue, object: dashboard)
let loadResult = XCTWaiter.wait(for: [valueLoaded], timeout: 15)
XCTAssertEqual(loadResult, .completed,
"Dashboard should load a photo")
// Tap the slideshow image to return to settings
dashboard.tap()
// Verify we're back on the Settings screen
XCTAssertTrue(settingsNav.waitForExistence(timeout: 10),
"Settings should reappear after tapping the slideshow")
}
}