diff --git a/SunHatUITests/AccessibilityAuditUITests.swift b/SunHatUITests/AccessibilityAuditUITests.swift new file mode 100644 index 0000000..8753527 --- /dev/null +++ b/SunHatUITests/AccessibilityAuditUITests.swift @@ -0,0 +1,120 @@ +// +// AccessibilityAuditUITests.swift +// SunHatUITests +// +// Runs XCTest's built-in accessibility auditor over every primary screen. +// The auditor catches the classes of defect that are invisible in a normal +// UI test but block real users: unlabeled controls, images whose label +// repeats the trait, text that clips at large Dynamic Type sizes, contrast +// below the WCAG threshold, and hit regions under 44pt. +// +// Each screen is audited independently so a failure names the screen it came +// from. The ad SDK is disabled: Google's banner is a third-party view whose +// accessibility this project cannot fix, and its network fetch makes the +// audit nondeterministic. +// + +import XCTest + +final class AccessibilityAuditUITests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = true + } + + @MainActor + private func launchApp() -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments += [ + "-hasCompletedOnboarding", "YES", + "-hasCreatedFirstReminder", "YES", + "-sunhatDisableAdSDK", + ] + app.launch() + XCTAssertTrue( + app.tabBars.buttons["Home"].waitForExistence(timeout: 20), + "App should reach the main tab bar" + ) + return app + } + + /// Audits the current screen and reports every issue as a test failure + /// tagged with the screen name, so one run enumerates all of them rather + /// than stopping at the first. + @MainActor + private func audit(_ app: XCUIApplication, screen: String) throws { + try app.performAccessibilityAudit { issue in + // element carries the specific control the issue was raised + // against, which the audit type and compactDescription alone + // don't name — without it a contrast or clipping failure can't + // be traced back to the view that needs fixing. + let element = issue.element?.debugDescription ?? "no element" + XCTFail("[\(screen)] \(issue.compactDescription)\n element: \(element)") + // Already reported above; returning true stops XCTest from + // double-reporting the same issue. + return true + } + } + + @MainActor + private func openTab(_ app: XCUIApplication, _ name: String) { + let tab = app.tabBars.buttons[name] + XCTAssertTrue(tab.waitForExistence(timeout: 10), "\(name) tab should exist") + tab.tap() + } + + // MARK: - Per-screen audits + + @MainActor + func testDashboardIsAccessible() throws { + let app = launchApp() + try audit(app, screen: "Dashboard") + } + + @MainActor + func testRemindersIsAccessible() throws { + let app = launchApp() + openTab(app, "Reminders") + try audit(app, screen: "Reminders") + } + + @MainActor + func testWeatherIsAccessible() throws { + let app = launchApp() + openTab(app, "Weather") + try audit(app, screen: "Weather") + } + + @MainActor + func testSettingsIsAccessible() throws { + let app = launchApp() + openTab(app, "Settings") + try audit(app, screen: "Settings") + } + + // MARK: - Largest accessibility text size + + /// The audit's dynamicTypeSupport check only exercises the size the app is + /// currently running at, so this repeats the sweep pinned to the largest + /// accessibility size — where clipping and truncation actually appear. + @MainActor + func testPrimaryScreensAtLargestAccessibilityTextSize() throws { + let app = XCUIApplication() + app.launchArguments += [ + "-hasCompletedOnboarding", "YES", + "-hasCreatedFirstReminder", "YES", + "-sunhatDisableAdSDK", + "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL", + ] + app.launch() + XCTAssertTrue( + app.tabBars.buttons["Home"].waitForExistence(timeout: 20), + "App should reach the main tab bar at AX XXXL" + ) + + try audit(app, screen: "Dashboard @ AX-XXXL") + for tab in ["Reminders", "Weather", "Settings"] { + openTab(app, tab) + try audit(app, screen: "\(tab) @ AX-XXXL") + } + } +} diff --git a/SunHatUITests/VisualQAScreenshotTests.swift b/SunHatUITests/VisualQAScreenshotTests.swift new file mode 100644 index 0000000..80ca9d2 --- /dev/null +++ b/SunHatUITests/VisualQAScreenshotTests.swift @@ -0,0 +1,97 @@ +// +// VisualQAScreenshotTests.swift +// SunHatUITests +// +// Captures the four primary screens across the appearance, Dynamic Type and +// localization combinations that break Liquid Glass layouts in practice: +// dark mode (glass contrast), the largest accessibility text size (clipping +// and truncation), and Spanish (string expansion, typically 20-30% longer +// than English). +// +// These tests assert only that each screen renders and stays navigable; the +// screenshots are the deliverable, written to /tmp/sunhat-shots and attached +// to the xcresult for review. +// + +import XCTest + +final class VisualQAScreenshotTests: XCTestCase { + private static let screens = ["Home", "Reminders", "Weather", "Settings"] + + override func setUpWithError() throws { + continueAfterFailure = false + } + + @MainActor + private func launch( + extraArguments: [String] = [], + appearance: XCUIDevice.Appearance = .light + ) -> XCUIApplication { + XCUIDevice.shared.appearance = appearance + let app = XCUIApplication() + app.launchArguments += [ + "-hasCompletedOnboarding", "YES", + "-hasCreatedFirstReminder", "YES", + "-sunhatDisableAdSDK", + ] + extraArguments + app.launch() + XCTAssertTrue( + app.tabBars.buttons["Home"].waitForExistence(timeout: 20), + "App should reach the main tab bar" + ) + return app + } + + @MainActor + private func captureAllScreens(_ app: XCUIApplication, variant: String) { + for screen in Self.screens { + let tab = app.tabBars.buttons[screen] + XCTAssertTrue(tab.waitForExistence(timeout: 10), "\(screen) tab should exist in \(variant)") + tab.tap() + // Let the tab's content settle before the shot; the glass tab bar + // animates its selection and the dashboard loads asynchronously. + _ = app.staticTexts.firstMatch.waitForExistence(timeout: 5) + save(app.screenshot(), name: "\(variant)-\(screen.lowercased())") + } + } + + @MainActor + func testLightModeDefaultType() throws { + captureAllScreens(launch(appearance: .light), variant: "light-default") + } + + @MainActor + func testDarkModeDefaultType() throws { + captureAllScreens(launch(appearance: .dark), variant: "dark-default") + } + + @MainActor + func testLightModeLargestAccessibilityType() throws { + let app = launch( + extraArguments: ["-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL"], + appearance: .light + ) + captureAllScreens(app, variant: "light-ax-xxxl") + } + + @MainActor + func testSpanishLocalization() throws { + let app = launch( + extraArguments: ["-AppleLanguages", "(es)", "-AppleLocale", "es_ES"], + appearance: .light + ) + captureAllScreens(app, variant: "spanish-default") + } + + @MainActor + private func save(_ screenshot: XCUIScreenshot, name: String) { + let attachment = XCTAttachment(screenshot: screenshot) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + + let directory = URL(fileURLWithPath: "/tmp/sunhat-shots", isDirectory: true) + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try? screenshot.pngRepresentation.write(to: directory.appendingPathComponent("\(name).png")) + } +}