Replace preview-based screenshot rendering with simulator XCUITests driven by shooter.sh (per-language locale + status bar overrides, xcparse extraction). Add batteries-list accessibility identifier and update CLAUDE.md screenshot docs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
529 lines
18 KiB
Swift
529 lines
18 KiB
Swift
import XCTest
|
|
|
|
final class CableUITestsScreenshot: XCTestCase {
|
|
private let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
|
|
|
|
private enum UIStringKey: String {
|
|
case addLoad
|
|
case browseLibrary
|
|
case library
|
|
case overviewTab
|
|
case componentsTab
|
|
case batteriesTab
|
|
case chargersTab
|
|
case close
|
|
case cancel
|
|
case settings
|
|
case defaultLoadName
|
|
case billOfMaterials
|
|
case systemEditorTitle
|
|
case systemsTitle
|
|
}
|
|
|
|
private let translations: [UIStringKey: [String: String]] = [
|
|
.addLoad: [
|
|
"en": "Add Load",
|
|
"de": "Verbraucher hinzufügen",
|
|
"es": "Añadir carga",
|
|
"fr": "Ajouter une charge",
|
|
"nl": "Belasting toevoegen",
|
|
],
|
|
.browseLibrary: [
|
|
"en": "Browse Library",
|
|
"de": "Bibliothek durchsuchen",
|
|
"es": "Explorar biblioteca",
|
|
"fr": "Parcourir la bibliothèque",
|
|
"nl": "Bibliotheek bekijken",
|
|
],
|
|
.library: [
|
|
"en": "Library",
|
|
"de": "Bibliothek",
|
|
"es": "Biblioteca",
|
|
"fr": "Bibliothèque",
|
|
"nl": "Bibliotheek",
|
|
],
|
|
.overviewTab: [
|
|
"en": "Overview",
|
|
"de": "Übersicht",
|
|
"es": "Resumen",
|
|
"fr": "Aperçu",
|
|
"nl": "Overzicht",
|
|
],
|
|
.componentsTab: [
|
|
"en": "Components",
|
|
"de": "Verbraucher",
|
|
"es": "Componentes",
|
|
"fr": "Composants",
|
|
"nl": "Componenten",
|
|
],
|
|
.batteriesTab: [
|
|
"en": "Batteries",
|
|
"de": "Batterien",
|
|
"es": "Baterías",
|
|
"fr": "Batteries",
|
|
"nl": "Batterijen",
|
|
],
|
|
.chargersTab: [
|
|
"en": "Chargers",
|
|
"de": "Ladegeräte",
|
|
"es": "Cargadores",
|
|
"fr": "Chargeurs",
|
|
"nl": "Laders",
|
|
],
|
|
.close: [
|
|
"en": "Close",
|
|
"de": "Schließen",
|
|
"es": "Cerrar",
|
|
"fr": "Fermer",
|
|
"nl": "Sluiten",
|
|
],
|
|
.cancel: [
|
|
"en": "Cancel",
|
|
"de": "Abbrechen",
|
|
"es": "Cancelar",
|
|
"fr": "Annuler",
|
|
"nl": "Annuleren",
|
|
],
|
|
.settings: [
|
|
"en": "Settings",
|
|
"de": "Einstellungen",
|
|
"es": "Configuración",
|
|
"fr": "Réglages",
|
|
"nl": "Instellingen",
|
|
],
|
|
.defaultLoadName: [
|
|
"en": "New Load",
|
|
"de": "Neuer Verbraucher",
|
|
"es": "Carga nueva",
|
|
"fr": "Nouvelle charge",
|
|
"nl": "Nieuwe last",
|
|
],
|
|
.billOfMaterials: [
|
|
"en": "Bill of Materials",
|
|
"de": "Stückliste",
|
|
"es": "Lista de materiales",
|
|
"fr": "Liste de matériel",
|
|
"nl": "Stuklijst",
|
|
],
|
|
.systemEditorTitle: [
|
|
"en": "Edit System",
|
|
"de": "System bearbeiten",
|
|
"es": "Editar sistema",
|
|
"fr": "Modifier le système",
|
|
"nl": "Systeem bewerken",
|
|
],
|
|
.systemsTitle: [
|
|
"en": "Systems",
|
|
"de": "Systeme",
|
|
"es": "Sistemas",
|
|
"fr": "Systèmes",
|
|
"nl": "Systemen",
|
|
],
|
|
]
|
|
|
|
override func setUpWithError() throws {
|
|
try super.setUpWithError()
|
|
continueAfterFailure = false
|
|
XCUIDevice.shared.orientation = .portrait
|
|
}
|
|
|
|
// MARK: - Onboarding Screenshots
|
|
|
|
@MainActor
|
|
func testOnboardingScreenshots() throws {
|
|
let app = launchApp(arguments: ["--uitest-reset-data"])
|
|
// Wait for Apple Intelligence and other system notifications to appear, then dismiss
|
|
RunLoop.current.run(until: Date().addingTimeInterval(6))
|
|
dismissNotificationBannersIfNeeded()
|
|
|
|
let createSystemButton = app.buttons["create-system-button"]
|
|
XCTAssertTrue(createSystemButton.waitForExistence(timeout: 8))
|
|
dismissNotificationBannersIfNeeded()
|
|
waitForStability()
|
|
takeScreenshot(named: "01-OnboardingSystemsView")
|
|
|
|
createSystemButton.tap()
|
|
|
|
let addLoadButton = button(in: app.buttons, for: .addLoad)
|
|
XCTAssertTrue(addLoadButton.waitForExistence(timeout: 8))
|
|
let browseLibraryButton = button(in: app.buttons, for: .browseLibrary)
|
|
XCTAssertTrue(browseLibraryButton.waitForExistence(timeout: 4))
|
|
dismissNotificationBannersIfNeeded()
|
|
takeScreenshot(named: "02-OnboardingSystemView")
|
|
|
|
browseLibraryButton.tap()
|
|
let libraryCloseButton = app.buttons["library-view-close-button"]
|
|
XCTAssertTrue(libraryCloseButton.waitForExistence(timeout: 8))
|
|
// Wait for library items AND remote icons to load from PocketBase
|
|
RunLoop.current.run(until: Date().addingTimeInterval(15))
|
|
dismissNotificationBannersIfNeeded()
|
|
takeScreenshot(named: "04-ComponentSelectorView")
|
|
libraryCloseButton.tap()
|
|
|
|
XCTAssertTrue(addLoadButton.waitForExistence(timeout: 4))
|
|
addLoadButton.tap()
|
|
|
|
let newLoadButton = button(in: app.buttons, for: .defaultLoadName)
|
|
XCTAssertTrue(newLoadButton.waitForExistence(timeout: 8))
|
|
waitForStability()
|
|
dismissNotificationBannersIfNeeded()
|
|
takeScreenshot(named: "03-LoadEditorView")
|
|
}
|
|
|
|
// MARK: - Sample Data Screenshots
|
|
|
|
@MainActor
|
|
func testSampleDataScreenshots() throws {
|
|
let app = launchAppWithSampleData()
|
|
dismissNotificationBannersIfNeeded()
|
|
|
|
// Systems list
|
|
let systemsList = resolvedSystemsList(in: app)
|
|
XCTAssertTrue(systemsList.waitForExistence(timeout: 4))
|
|
dismissNotificationBannersIfNeeded()
|
|
takeScreenshot(named: "05-SystemsWithSampleData")
|
|
|
|
// Navigate to first system
|
|
openFirstSystem(in: app, systemsList: systemsList)
|
|
|
|
// Overview tab — wait for navigation animation to complete
|
|
waitForStability()
|
|
dismissNotificationBannersIfNeeded()
|
|
takeScreenshot(named: "06-AdventureVanOverview")
|
|
|
|
// Bill of Materials
|
|
let bomElement = resolveBillOfMaterialsElement(in: app)
|
|
if !bomElement.waitForExistence(timeout: 6) {
|
|
bringElementIntoView(bomElement, in: app)
|
|
}
|
|
XCTAssertTrue(bomElement.exists)
|
|
if !bomElement.isHittable {
|
|
bringElementIntoView(bomElement, in: app, requireHittable: true)
|
|
}
|
|
tapElement(bomElement)
|
|
waitForStability()
|
|
dismissNotificationBannersIfNeeded()
|
|
takeScreenshot(named: "08-BillOfMaterials")
|
|
|
|
let closeButton = app.buttons["system-bom-close-button"]
|
|
XCTAssertTrue(closeButton.waitForExistence(timeout: 6))
|
|
closeButton.tap()
|
|
|
|
// Components tab
|
|
tapTab(.componentsTab, in: app)
|
|
let loadsList = resolvedLoadsList(in: app)
|
|
XCTAssertTrue(loadsList.waitForExistence(timeout: 4))
|
|
dismissNotificationBannersIfNeeded()
|
|
takeScreenshot(named: "07-AdventureVanLoads")
|
|
|
|
// Open first load → Calculator
|
|
let firstLoad = loadsList.cells.element(boundBy: 0)
|
|
XCTAssertTrue(firstLoad.waitForExistence(timeout: 2))
|
|
firstLoad.tap()
|
|
waitForStability()
|
|
dismissNotificationBannersIfNeeded()
|
|
takeScreenshot(named: "09-AdventureVanCalculator")
|
|
|
|
// Navigate back to system tabs
|
|
app.navigationBars.buttons.element(boundBy: 0).tap()
|
|
waitForStability()
|
|
|
|
// Batteries tab
|
|
tapTab(.batteriesTab, in: app)
|
|
waitForStability()
|
|
dismissNotificationBannersIfNeeded()
|
|
takeScreenshot(named: "10-AdventureVanBatteries")
|
|
|
|
// Open first battery → Battery Editor
|
|
let batteriesList = resolvedBatteriesList(in: app)
|
|
if batteriesList.waitForExistence(timeout: 4) {
|
|
let firstBattery = batteriesList.cells.element(boundBy: 0)
|
|
if firstBattery.waitForExistence(timeout: 2) {
|
|
firstBattery.tap()
|
|
waitForStability()
|
|
dismissNotificationBannersIfNeeded()
|
|
takeScreenshot(named: "11-BatteryEditor")
|
|
// Navigate back
|
|
app.navigationBars.buttons.element(boundBy: 0).tap()
|
|
waitForStability()
|
|
}
|
|
}
|
|
|
|
// Chargers tab
|
|
tapTab(.chargersTab, in: app)
|
|
waitForStability()
|
|
dismissNotificationBannersIfNeeded()
|
|
takeScreenshot(named: "12-AdventureVanChargers")
|
|
|
|
// Open first charger → Charger Editor
|
|
let chargersList = resolvedChargersList(in: app)
|
|
if chargersList.waitForExistence(timeout: 4) {
|
|
let firstCharger = chargersList.cells.element(boundBy: 0)
|
|
if firstCharger.waitForExistence(timeout: 2) {
|
|
firstCharger.tap()
|
|
waitForStability()
|
|
dismissNotificationBannersIfNeeded()
|
|
takeScreenshot(named: "13-ChargerEditor")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - App Launch
|
|
|
|
private func launchApp(arguments: [String]) -> XCUIApplication {
|
|
let app = XCUIApplication()
|
|
var launchArguments = ["--uitest-reset-data"]
|
|
launchArguments.append(contentsOf: arguments)
|
|
app.launchArguments = launchArguments
|
|
app.launch()
|
|
return app
|
|
}
|
|
|
|
private func launchAppWithSampleData() -> XCUIApplication {
|
|
let app = XCUIApplication()
|
|
app.launchArguments = ["--uitest-reset-data", "--uitest-sample-data"]
|
|
app.launch()
|
|
return app
|
|
}
|
|
|
|
// MARK: - Navigation Helpers
|
|
|
|
private func openFirstSystem(in app: XCUIApplication, systemsList: XCUIElement) {
|
|
let firstSystemCell = systemsList.cells.element(boundBy: 0)
|
|
XCTAssertTrue(firstSystemCell.waitForExistence(timeout: 2))
|
|
let systemName = firstSystemCell.staticTexts.firstMatch.label
|
|
|
|
let systemButton = firstSystemCell.buttons.firstMatch
|
|
if systemButton.exists {
|
|
systemButton.tap()
|
|
} else {
|
|
firstSystemCell.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
|
}
|
|
|
|
var detailVisible = waitForSystemDetail(named: systemName, in: app)
|
|
if !detailVisible {
|
|
firstSystemCell.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
|
detailVisible = waitForSystemDetail(named: systemName, in: app)
|
|
}
|
|
XCTAssertTrue(detailVisible)
|
|
}
|
|
|
|
private func tapTab(_ key: UIStringKey, in app: XCUIApplication) {
|
|
let identifierMap: [UIStringKey: String] = [
|
|
.overviewTab: "overview-tab",
|
|
.componentsTab: "components-tab",
|
|
.batteriesTab: "batteries-tab",
|
|
.chargersTab: "chargers-tab",
|
|
]
|
|
|
|
// Use .matching + .firstMatch to avoid "multiple matches" error
|
|
// with iOS 26 floating tab bar (which creates duplicate elements)
|
|
if let identifier = identifierMap[key] {
|
|
let tabButton = app.buttons.matching(identifier: identifier).firstMatch
|
|
if tabButton.waitForExistence(timeout: 3) {
|
|
tapElement(tabButton)
|
|
return
|
|
}
|
|
}
|
|
|
|
let tabButton = button(in: app.buttons, for: key)
|
|
XCTAssertTrue(tabButton.waitForExistence(timeout: 3))
|
|
tapElement(tabButton)
|
|
}
|
|
|
|
private func tapElement(_ element: XCUIElement) {
|
|
if element.isHittable {
|
|
element.tap()
|
|
} else {
|
|
element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
|
}
|
|
}
|
|
|
|
// MARK: - Element Resolution
|
|
|
|
private func resolvedSystemsList(in app: XCUIApplication) -> XCUIElement {
|
|
let collection = app.collectionViews["systems-list"]
|
|
if collection.waitForExistence(timeout: 6) {
|
|
return collection
|
|
}
|
|
|
|
if app.collectionViews.firstMatch.waitForExistence(timeout: 2) {
|
|
return app.collectionViews.firstMatch
|
|
}
|
|
|
|
let table = app.tables["systems-list"]
|
|
if table.waitForExistence(timeout: 6) {
|
|
return table
|
|
}
|
|
|
|
XCTAssertTrue(app.tables.firstMatch.waitForExistence(timeout: 2))
|
|
return app.tables.firstMatch
|
|
}
|
|
|
|
private func resolvedLoadsList(in app: XCUIApplication) -> XCUIElement {
|
|
let collection = app.collectionViews["loads-list"]
|
|
if collection.waitForExistence(timeout: 6) {
|
|
return collection
|
|
}
|
|
|
|
let table = app.tables["loads-list"]
|
|
XCTAssertTrue(table.waitForExistence(timeout: 6))
|
|
return table
|
|
}
|
|
|
|
private func resolvedBatteriesList(in app: XCUIApplication) -> XCUIElement {
|
|
let collection = app.collectionViews["batteries-list"]
|
|
if collection.waitForExistence(timeout: 6) {
|
|
return collection
|
|
}
|
|
|
|
let table = app.tables["batteries-list"]
|
|
if table.waitForExistence(timeout: 6) {
|
|
return table
|
|
}
|
|
|
|
// Fallback: any list on screen
|
|
if app.collectionViews.firstMatch.waitForExistence(timeout: 2) {
|
|
return app.collectionViews.firstMatch
|
|
}
|
|
return app.tables.firstMatch
|
|
}
|
|
|
|
private func resolvedChargersList(in app: XCUIApplication) -> XCUIElement {
|
|
let collection = app.collectionViews["chargers-list"]
|
|
if collection.waitForExistence(timeout: 6) {
|
|
return collection
|
|
}
|
|
|
|
let table = app.tables["chargers-list"]
|
|
if table.waitForExistence(timeout: 6) {
|
|
return table
|
|
}
|
|
|
|
if app.collectionViews.firstMatch.waitForExistence(timeout: 2) {
|
|
return app.collectionViews.firstMatch
|
|
}
|
|
return app.tables.firstMatch
|
|
}
|
|
|
|
private func resolveBillOfMaterialsElement(in app: XCUIApplication) -> XCUIElement {
|
|
let identifier = "system-bom-button"
|
|
let buttonByIdentifier = app.buttons.matching(identifier: identifier).firstMatch
|
|
if buttonByIdentifier.exists { return buttonByIdentifier }
|
|
|
|
let elementByIdentifier = app.otherElements.matching(identifier: identifier).firstMatch
|
|
if elementByIdentifier.exists { return elementByIdentifier }
|
|
|
|
let candidates = candidateStrings(for: .billOfMaterials)
|
|
for candidate in candidates {
|
|
let button = app.buttons[candidate]
|
|
if button.exists { return button }
|
|
let other = app.otherElements[candidate]
|
|
if other.exists { return other }
|
|
}
|
|
|
|
return buttonByIdentifier
|
|
}
|
|
|
|
// MARK: - Screenshots & Stability
|
|
|
|
private func takeScreenshot(named name: String) {
|
|
let screenshot = XCUIScreen.main.screenshot()
|
|
let attachment = XCTAttachment(screenshot: screenshot)
|
|
attachment.name = name
|
|
attachment.lifetime = .keepAlways
|
|
add(attachment)
|
|
}
|
|
|
|
private func waitForStability(long: Bool = false) {
|
|
RunLoop.current.run(until: Date().addingTimeInterval(long ? 3.0 : 0.5))
|
|
}
|
|
|
|
private func waitForSystemDetail(named systemName: String, in app: XCUIApplication, timeout: TimeInterval = 6) -> Bool {
|
|
let deadline = Date().addingTimeInterval(timeout)
|
|
while Date() < deadline {
|
|
if app.otherElements["system-overview"].exists {
|
|
return true
|
|
}
|
|
let navBar = app.navigationBars.firstMatch
|
|
if navBar.buttons[systemName].exists || navBar.staticTexts[systemName].exists {
|
|
return true
|
|
}
|
|
RunLoop.current.run(until: Date().addingTimeInterval(0.25))
|
|
}
|
|
return app.otherElements["system-overview"].exists
|
|
}
|
|
|
|
private func bringElementIntoView(
|
|
_ element: XCUIElement,
|
|
in app: XCUIApplication,
|
|
requireHittable: Bool = false,
|
|
attempts: Int = 8
|
|
) {
|
|
let scrollContainer = app.scrollViews.firstMatch.exists ? app.scrollViews.firstMatch : app.tables.firstMatch
|
|
for _ in 0..<attempts {
|
|
if element.exists, (!requireHittable || element.isHittable) {
|
|
return
|
|
}
|
|
if scrollContainer.exists {
|
|
scrollContainer.swipeUp()
|
|
} else {
|
|
app.swipeUp()
|
|
}
|
|
waitForStability()
|
|
_ = element.waitForExistence(timeout: 2)
|
|
}
|
|
}
|
|
|
|
// MARK: - Notification Dismissal
|
|
|
|
private func dismissNotificationBannersIfNeeded() {
|
|
// Try multiple times — notifications can appear with a delay
|
|
for _ in 0..<3 {
|
|
let banner = springboard.otherElements.matching(identifier: "NotificationShortLookView").firstMatch
|
|
guard banner.waitForExistence(timeout: 1) else { return }
|
|
if banner.isHittable {
|
|
banner.swipeUp()
|
|
} else {
|
|
let start = banner.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
|
|
let end = banner.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: -1.5))
|
|
start.press(forDuration: 0.05, thenDragTo: end)
|
|
}
|
|
waitForStability()
|
|
}
|
|
}
|
|
|
|
// MARK: - Localized Element Matching
|
|
|
|
private func candidateStrings(for key: UIStringKey) -> [String] {
|
|
var values = Set<String>()
|
|
if let languageCode = Locale.preferredLanguages.first.flatMap({ String($0.prefix(2)) }),
|
|
let localized = translations[key]?[languageCode] {
|
|
values.insert(localized)
|
|
}
|
|
if let english = translations[key]?["en"] {
|
|
values.insert(english)
|
|
}
|
|
if let others = translations[key]?.values {
|
|
values.formUnion(others)
|
|
}
|
|
return Array(values)
|
|
}
|
|
|
|
private func button(in query: XCUIElementQuery, for key: UIStringKey) -> XCUIElement {
|
|
let candidates = candidateStrings(for: key)
|
|
for candidate in candidates {
|
|
let element = query[candidate]
|
|
if element.exists {
|
|
return element
|
|
}
|
|
}
|
|
let predicate = NSPredicate(
|
|
format: "label IN %@ OR identifier IN %@",
|
|
NSArray(array: candidates),
|
|
NSArray(array: candidates)
|
|
)
|
|
return query.matching(predicate).firstMatch
|
|
}
|
|
}
|