Christopher Hotchkiss Christopher Hotchkiss
Login

Code

remindwall / RemindWallUITests / RemindWallUITests.swift

Back to remindwall — Current RemindWall

Download this file

//
//  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")
    }
}