Compare commits

...

3 Commits

Author SHA1 Message Date
d68170bc87 Bump build version to 85
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:39:46 +02:00
b448a1b4f7 Automate App Store screenshots via XCUITests
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>
2026-05-22 10:39:46 +02:00
8b30fabaa2 Route all affiliate links through voltplan.app
Replace client-side Amazon affiliate resolution with server-side
redirects via voltplan.app/{componentId} and voltplan.app/search?q=.
Remove AmazonAffiliate, affiliate link fetching, and per-model
affiliate fields in favor of a single componentID.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-22 10:39:46 +02:00
18 changed files with 1177 additions and 872 deletions

View File

@@ -113,37 +113,61 @@ PDF exports use `UIGraphicsPDFRenderer` with A4 portrait format. The pattern is:
- **ShareSheet** triggered via `@State` item binding in the parent view. - **ShareSheet** triggered via `@State` item binding in the parent view.
- **Toolbar button** (not inline content) for the export action. - **Toolbar button** (not inline content) for the export action.
## Screenshots & Previews ## Screenshots
Screenshot previews for all major views live in `Cable/ScreenshotPreviews.swift`. This file contains wrapper views and realistic sample data so screenshots include full app chrome (NavigationBar, TabBar). App Store screenshots are generated via XCUITests running on simulators, automated by `shooter.sh`.
### How to render screenshots ### Running screenshots
1. Use `mcp__xcode__RenderPreview` with `sourceFilePath: "Cable/ScreenshotPreviews.swift"` and `previewDefinitionIndexInFile` (044, grouped by view × 5 languages). ```bash
2. Output goes to `Shots/Screenshots/`. ./shooter.sh # reads ./screenshot.config
./shooter.sh other.config # explicit config file
VERBOSE=1 ./shooter.sh # full logs on failure
PARALLEL=0 ./shooter.sh # sequential mode
```
### Preview index mapping Requires `xcparse`: `brew install chargepoint/xcparse/xcparse`.
Previews are grouped in blocks of 5 (EN, DE, ES, FR, NL): ### How it works
- 04: Overview tab
- 59: Components tab
- 1014: Batteries tab
- 1519: Chargers tab
- 2024: Systems list
- 2529: Parts Library
- 3034: Load editor (CalculatorView)
- 3539: Battery editor
- 4044: Charger editor
### Key patterns for preview-friendly views 1. `shooter.sh` reads `screenshot.config` for scheme, bundle ID, devices, and languages.
2. Builds once with `build-for-testing`, then runs `test-without-building` per language.
3. Per language run: erases simulator, sets locale via `simctl spawn ... defaults write`, suppresses system notifications (DND, Apple Intelligence), overrides status bar (9:41, full battery).
4. `xcparse` extracts screenshot attachments from `.xcresult` bundles into `Shots/Screenshots/{device-slug}/{lang}/`.
5. Devices run in parallel (languages sequential per device — same simulator).
- **LoadsView** accepts an `initialTab` parameter (`LoadsView.ComponentTab`) to control which tab is shown. Wrap in `NavigationStack` to get the navigation bar. ### Test structure
- **ComponentLibraryView** accepts an optional `viewModel` parameter via `init(viewModel:onSelect:)`. Use `ComponentLibraryViewModel(previewItems:)` to inject mock data and avoid network dependency.
- All preview wrappers inject `.modelContainer(container)` (in-memory) and `.environmentObject(UnitSystemSettings())`.
### Localization limitation - **Test target**: `CableUITestsScreenshot` (scheme: `CableScreenshots`, test plan: `CableScreenshots.xctestplan`)
- **Test file**: `CableUITestsScreenshot/CableUITestsScreenshot.swift`
- **Sample data**: `UITestSampleData.swift` — seeded via `--uitest-sample-data` launch argument, cleared via `--uitest-reset-data`
`.environment(\.locale, Locale(identifier: "xx"))` does **not** affect `String(localized:defaultValue:)` — those resolve from the app bundle, not the SwiftUI environment. All preview screenshots render in the system language. For multi-language screenshots, use UI tests with `-AppleLanguages` launch arguments or change the Xcode scheme language. ### Screenshot inventory
| # | Name | Source |
|---|------|--------|
| 01 | OnboardingSystemsView | Empty state after reset |
| 02 | OnboardingSystemView | New system overview |
| 03 | LoadEditorView | CalculatorView for new load |
| 04 | ComponentSelectorView | Component library (network) |
| 05 | SystemsWithSampleData | Systems list with sample data |
| 06 | AdventureVanOverview | Overview tab |
| 07 | AdventureVanLoads | Components tab |
| 08 | BillOfMaterials | System BOM sheet |
| 09 | AdventureVanCalculator | Load calculator |
| 10 | AdventureVanBatteries | Batteries tab |
| 11 | BatteryEditor | Battery editor |
| 12 | AdventureVanChargers | Chargers tab |
| 13 | ChargerEditor | Charger editor |
### Accessibility identifiers for UI tests
Key identifiers used by the screenshot tests: `create-system-button`, `systems-list`, `system-overview`, `overview-tab`, `components-tab`, `batteries-tab`, `chargers-tab`, `loads-list`, `batteries-list`, `chargers-list`, `system-bom-button`, `system-bom-close-button`, `library-view-close-button`, `create-component-button`, `select-component-button`.
### Preview-friendly view inits
- **LoadsView** accepts `initialTab` (`LoadsView.ComponentTab`) to control which tab is shown.
- **ComponentLibraryView** accepts an optional `viewModel` via `init(viewModel:onSelect:)`. Use `ComponentLibraryViewModel(previewItems:)` to bypass network.
## Model Definitions ## Model Definitions

View File

@@ -418,7 +418,7 @@
CODE_SIGN_ENTITLEMENTS = Cable/Cable.entitlements; CODE_SIGN_ENTITLEMENTS = Cable/Cable.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 84; CURRENT_PROJECT_VERSION = 85;
DEVELOPMENT_TEAM = RE4FXQ754N; DEVELOPMENT_TEAM = RE4FXQ754N;
ENABLE_APP_SANDBOX = YES; ENABLE_APP_SANDBOX = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@@ -454,7 +454,7 @@
CODE_SIGN_ENTITLEMENTS = Cable/Cable.entitlements; CODE_SIGN_ENTITLEMENTS = Cable/Cable.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 84; CURRENT_PROJECT_VERSION = 85;
DEVELOPMENT_TEAM = RE4FXQ754N; DEVELOPMENT_TEAM = RE4FXQ754N;
ENABLE_APP_SANDBOX = YES; ENABLE_APP_SANDBOX = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;

View File

@@ -1,77 +1,21 @@
import Foundation import Foundation
enum AmazonAffiliate { enum VoltPlanRedirect {
private static let fallbackDomain = "www.amazon.com" private static let baseURL = "https://voltplan.app"
private static let fallbackTag: String? = "voltplan-20"
private static let domainsByCountry: [String: String] = [ static func componentURL(id: String) -> URL? {
"US": "www.amazon.com", var components = URLComponents(string: "\(baseURL)/\(id)")
"DE": "www.amazon.de", components?.queryItems = [URLQueryItem(name: "src", value: "cable")]
"FR": "www.amazon.fr", return components?.url
"ES": "www.amazon.es", }
"IT": "www.amazon.it",
"GB": "www.amazon.co.uk",
"CA": "www.amazon.ca",
"JP": "www.amazon.co.jp",
"AU": "www.amazon.com.au",
"NL": "www.amazon.nl",
"SE": "www.amazon.se",
"PL": "www.amazon.pl",
"MX": "www.amazon.com.mx",
"BR": "www.amazon.com.br",
"IN": "www.amazon.in"
]
// Configure Amazon affiliate tracking IDs by country code. static func searchURL(query: String) -> URL? {
private static let tagsByCountry: [String: String] = [
"US": "voltplan-20",
"DE": "voltplan-21",
"AU": "voltplan-22",
"GB": "voltplan00-21",
"FR": "voltplan0f-21",
"CA": "voltplan01-20"
]
private static let countryAliases: [String: String] = [
"UK": "GB"
]
static func searchURL(query: String, countryCode: String?) -> URL? {
guard !query.isEmpty else { return nil } guard !query.isEmpty else { return nil }
var components = URLComponents(string: "\(baseURL)/search")
var components = URLComponents() components?.queryItems = [
components.scheme = "https" URLQueryItem(name: "q", value: query),
components.host = domain(for: countryCode) URLQueryItem(name: "src", value: "cable"),
components.path = "/s" ]
return components?.url
var queryItems = [URLQueryItem(name: "k", value: query)]
if let tag = affiliateTag(for: countryCode), !tag.isEmpty {
queryItems.append(URLQueryItem(name: "tag", value: tag))
}
components.queryItems = queryItems
return components.url
}
static func domain(for countryCode: String?) -> String {
guard let normalized = normalizedCountryCode(from: countryCode) else {
return fallbackDomain
}
return domainsByCountry[normalized] ?? fallbackDomain
}
static func affiliateTag(for countryCode: String?) -> String? {
guard let normalized = normalizedCountryCode(from: countryCode) else {
return fallbackTag
}
return tagsByCountry[normalized] ?? fallbackTag
}
private static func normalizedCountryCode(from countryCode: String?) -> String? {
guard let raw = countryCode?.uppercased(), !raw.isEmpty else { return nil }
if let alias = countryAliases[raw] {
return alias
}
return raw
} }
} }

View File

@@ -249,6 +249,7 @@ struct BatteriesView: View {
.listStyle(.plain) .listStyle(.plain)
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.environment(\.editMode, $editMode) .environment(\.editMode, $editMode)
.accessibilityIdentifier("batteries-list")
} }
private func batteryRow(for battery: SavedBattery) -> some View { private func batteryRow(for battery: SavedBattery) -> some View {

View File

@@ -16,8 +16,7 @@ class SavedBattery {
var iconName: String = "battery.100" var iconName: String = "battery.100"
var colorName: String = "blue" var colorName: String = "blue"
var system: ElectricalSystem? var system: ElectricalSystem?
var affiliateURLString: String? var componentID: String?
var affiliateCountryCode: String?
var bomCompletedItemIDs: [String] = [] var bomCompletedItemIDs: [String] = []
var timestamp: Date var timestamp: Date
@@ -35,8 +34,7 @@ class SavedBattery {
iconName: String = "battery.100", iconName: String = "battery.100",
colorName: String = "blue", colorName: String = "blue",
system: ElectricalSystem? = nil, system: ElectricalSystem? = nil,
affiliateURLString: String? = nil, componentID: String? = nil,
affiliateCountryCode: String? = nil,
bomCompletedItemIDs: [String] = [], bomCompletedItemIDs: [String] = [],
timestamp: Date = Date() timestamp: Date = Date()
) { ) {
@@ -53,8 +51,7 @@ class SavedBattery {
self.iconName = iconName self.iconName = iconName
self.colorName = colorName self.colorName = colorName
self.system = system self.system = system
self.affiliateURLString = affiliateURLString self.componentID = componentID
self.affiliateCountryCode = affiliateCountryCode
self.bomCompletedItemIDs = bomCompletedItemIDs self.bomCompletedItemIDs = bomCompletedItemIDs
self.timestamp = timestamp self.timestamp = timestamp
} }

View File

@@ -14,8 +14,7 @@ final class SavedCharger {
var system: ElectricalSystem? var system: ElectricalSystem?
var timestamp: Date var timestamp: Date
var remoteIconURLString: String? var remoteIconURLString: String?
var affiliateURLString: String? var componentID: String?
var affiliateCountryCode: String?
var bomCompletedItemIDs: [String] = [] var bomCompletedItemIDs: [String] = []
var identifier: String var identifier: String
var powerSourceType: String = "shore" var powerSourceType: String = "shore"
@@ -67,8 +66,7 @@ final class SavedCharger {
system: ElectricalSystem? = nil, system: ElectricalSystem? = nil,
timestamp: Date = Date(), timestamp: Date = Date(),
remoteIconURLString: String? = nil, remoteIconURLString: String? = nil,
affiliateURLString: String? = nil, componentID: String? = nil,
affiliateCountryCode: String? = nil,
bomCompletedItemIDs: [String] = [], bomCompletedItemIDs: [String] = [],
identifier: String = UUID().uuidString, identifier: String = UUID().uuidString,
powerSourceType: String = "shore" powerSourceType: String = "shore"
@@ -84,8 +82,7 @@ final class SavedCharger {
self.system = system self.system = system
self.timestamp = timestamp self.timestamp = timestamp
self.remoteIconURLString = remoteIconURLString self.remoteIconURLString = remoteIconURLString
self.affiliateURLString = affiliateURLString self.componentID = componentID
self.affiliateCountryCode = affiliateCountryCode
self.bomCompletedItemIDs = bomCompletedItemIDs self.bomCompletedItemIDs = bomCompletedItemIDs
self.identifier = identifier self.identifier = identifier
self.powerSourceType = powerSourceType self.powerSourceType = powerSourceType

View File

@@ -127,8 +127,7 @@ class SavedLoad {
var dailyUsageHours: Double = 24.0 var dailyUsageHours: Double = 24.0
var system: ElectricalSystem? var system: ElectricalSystem?
var remoteIconURLString: String? = nil var remoteIconURLString: String? = nil
var affiliateURLString: String? = nil var componentID: String? = nil
var affiliateCountryCode: String? = nil
var bomCompletedItemIDs: [String] = [] var bomCompletedItemIDs: [String] = []
var identifier: String = UUID().uuidString var identifier: String = UUID().uuidString
@@ -146,8 +145,7 @@ class SavedLoad {
dailyUsageHours: Double = 24.0, dailyUsageHours: Double = 24.0,
system: ElectricalSystem? = nil, system: ElectricalSystem? = nil,
remoteIconURLString: String? = nil, remoteIconURLString: String? = nil,
affiliateURLString: String? = nil, componentID: String? = nil,
affiliateCountryCode: String? = nil,
bomCompletedItemIDs: [String] = [], bomCompletedItemIDs: [String] = [],
identifier: String = UUID().uuidString identifier: String = UUID().uuidString
) { ) {
@@ -165,8 +163,7 @@ class SavedLoad {
self.dailyUsageHours = dailyUsageHours self.dailyUsageHours = dailyUsageHours
self.system = system self.system = system
self.remoteIconURLString = remoteIconURLString self.remoteIconURLString = remoteIconURLString
self.affiliateURLString = affiliateURLString self.componentID = componentID
self.affiliateCountryCode = affiliateCountryCode
self.bomCompletedItemIDs = bomCompletedItemIDs self.bomCompletedItemIDs = bomCompletedItemIDs
self.identifier = identifier self.identifier = identifier
} }

View File

@@ -51,16 +51,14 @@ struct CalculatorView: View {
struct AffiliateLinkInfo: Identifiable, Equatable { struct AffiliateLinkInfo: Identifiable, Equatable {
let id: String let id: String
let affiliateURL: URL? let componentID: String?
let buttonTitle: String let buttonTitle: String
let regionName: String?
let countryCode: String?
} }
struct BOMItem: Identifiable, Equatable { struct BOMItem: Identifiable, Equatable {
enum Destination: Equatable { enum Destination: Equatable {
case affiliate(URL) case component(String)
case amazonSearch(String) case search(String)
} }
let id: String let id: String
@@ -391,7 +389,7 @@ struct CalculatorView: View {
private func billOfMaterialsSheet(info: AffiliateLinkInfo) -> some View { private func billOfMaterialsSheet(info: AffiliateLinkInfo) -> some View {
BillOfMaterialsView( BillOfMaterialsView(
info: info, info: info,
items: buildBillOfMaterialsItems(deviceLink: info.affiliateURL), items: buildBillOfMaterialsItems(componentID: info.componentID),
completedItemIDs: $completedItemIDs completedItemIDs: $completedItemIDs
) )
} }
@@ -467,18 +465,6 @@ struct CalculatorView: View {
} }
private var affiliateLinkInfo: AffiliateLinkInfo { private var affiliateLinkInfo: AffiliateLinkInfo {
let affiliateURL: URL?
if let urlString = savedLoad?.affiliateURLString,
let parsedURL = URL(string: urlString) {
affiliateURL = parsedURL
} else {
affiliateURL = nil
}
let rawCountryCode = savedLoad?.affiliateCountryCode ?? Locale.current.region?.identifier
let countryCode = rawCountryCode?.uppercased()
let regionName = countryCode.flatMap { Locale.current.localizedString(forRegionCode: $0) ?? $0 }
let buttonTitle = String(localized: "affiliate.button.review_parts", comment: "Button title to review a bill of materials before shopping") let buttonTitle = String(localized: "affiliate.button.review_parts", comment: "Button title to review a bill of materials before shopping")
let name = savedLoad?.name ?? calculator.loadName let name = savedLoad?.name ?? calculator.loadName
let timestamp = savedLoad?.timestamp ?? Date(timeIntervalSince1970: 0) let timestamp = savedLoad?.timestamp ?? Date(timeIntervalSince1970: 0)
@@ -486,10 +472,8 @@ struct CalculatorView: View {
return AffiliateLinkInfo( return AffiliateLinkInfo(
id: identifier, id: identifier,
affiliateURL: affiliateURL, componentID: savedLoad?.componentID,
buttonTitle: buttonTitle, buttonTitle: buttonTitle
regionName: regionName,
countryCode: countryCode
) )
} }
@@ -756,7 +740,7 @@ struct CalculatorView: View {
Button { Button {
AnalyticsTracker.log("Review Parts Tapped", properties: [ AnalyticsTracker.log("Review Parts Tapped", properties: [
"load": info.id, "load": info.id,
"has_affiliate": info.affiliateURL != nil, "has_component": info.componentID != nil,
]) ])
presentedAffiliateLink = info presentedAffiliateLink = info
} label: { } label: {
@@ -773,7 +757,7 @@ struct CalculatorView: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
let description = info.affiliateURL != nil let description = info.componentID != nil
? String(localized: "affiliate.description.with_link", defaultValue: "Tapping above shows a full bill of materials before opening the affiliate link. Purchases may support VoltPlan.") ? String(localized: "affiliate.description.with_link", defaultValue: "Tapping above shows a full bill of materials before opening the affiliate link. Purchases may support VoltPlan.")
: String(localized: "affiliate.description.without_link", defaultValue: "Tapping above shows a full bill of materials with shopping searches to help you source parts.") : String(localized: "affiliate.description.without_link", defaultValue: "Tapping above shows a full bill of materials with shopping searches to help you source parts.")
Text(description) Text(description)
@@ -785,7 +769,7 @@ struct CalculatorView: View {
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
} }
private func buildBillOfMaterialsItems(deviceLink: URL?) -> [BOMItem] { private func buildBillOfMaterialsItems(componentID: String?) -> [BOMItem] {
let unitSystem = unitSettings.unitSystem let unitSystem = unitSettings.unitSystem
let lengthValue = calculator.length let lengthValue = calculator.length
let lengthLabel = String(format: "%.1f %@", lengthValue, unitSystem.lengthUnit) let lengthLabel = String(format: "%.1f %@", lengthValue, unitSystem.lengthUnit)
@@ -847,7 +831,7 @@ struct CalculatorView: View {
title: calculator.loadName.isEmpty ? fallbackComponentTitle : calculator.loadName, title: calculator.loadName.isEmpty ? fallbackComponentTitle : calculator.loadName,
detail: powerDetail, detail: powerDetail,
iconSystemName: "bolt.fill", iconSystemName: "bolt.fill",
destination: deviceLink.map { .affiliate($0) } ?? .amazonSearch(deviceQueryBase), destination: componentID.map { .component($0) } ?? .search(deviceQueryBase),
isPrimaryComponent: true isPrimaryComponent: true
) )
) )
@@ -858,7 +842,7 @@ struct CalculatorView: View {
title: String(localized: "bom.item.cable.red", comment: "Title for the red power cable item"), title: String(localized: "bom.item.cable.red", comment: "Title for the red power cable item"),
detail: cableDetail, detail: cableDetail,
iconSystemName: "bolt.horizontal.circle", iconSystemName: "bolt.horizontal.circle",
destination: .amazonSearch(redCableQuery), destination: .search(redCableQuery),
isPrimaryComponent: false isPrimaryComponent: false
) )
) )
@@ -869,7 +853,7 @@ struct CalculatorView: View {
title: String(localized: "bom.item.cable.black", comment: "Title for the black power cable item"), title: String(localized: "bom.item.cable.black", comment: "Title for the black power cable item"),
detail: cableDetail, detail: cableDetail,
iconSystemName: "bolt.horizontal.circle", iconSystemName: "bolt.horizontal.circle",
destination: .amazonSearch(blackCableQuery), destination: .search(blackCableQuery),
isPrimaryComponent: false isPrimaryComponent: false
) )
) )
@@ -880,7 +864,7 @@ struct CalculatorView: View {
title: String(localized: "bom.item.fuse", comment: "Title for the fuse item"), title: String(localized: "bom.item.fuse", comment: "Title for the fuse item"),
detail: fuseDetail, detail: fuseDetail,
iconSystemName: "bolt.shield", iconSystemName: "bolt.shield",
destination: .amazonSearch(fuseQuery), destination: .search(fuseQuery),
isPrimaryComponent: false isPrimaryComponent: false
) )
) )
@@ -891,7 +875,7 @@ struct CalculatorView: View {
title: String(localized: "bom.item.terminals", comment: "Title for the terminals item"), title: String(localized: "bom.item.terminals", comment: "Title for the terminals item"),
detail: cableShoesDetail, detail: cableShoesDetail,
iconSystemName: "wrench.and.screwdriver", iconSystemName: "wrench.and.screwdriver",
destination: .amazonSearch(terminalQuery), destination: .search(terminalQuery),
isPrimaryComponent: false isPrimaryComponent: false
) )
) )
@@ -1495,11 +1479,11 @@ private struct BillOfMaterialsView: View {
return return
} }
if let destinationURL { if let destinationURL {
let isAffiliate: Bool let isComponent: Bool
if case .affiliate = item.destination { isAffiliate = true } else { isAffiliate = false } if case .component = item.destination { isComponent = true } else { isComponent = false }
AnalyticsTracker.log("BOM Item Tapped", properties: [ AnalyticsTracker.log("BOM Item Tapped", properties: [
"item": item.title, "item": item.title,
"is_affiliate": isAffiliate, "is_component": isComponent,
"domain": destinationURL.host ?? "unknown", "domain": destinationURL.host ?? "unknown",
"load": info.id, "load": info.id,
]) ])
@@ -1543,10 +1527,10 @@ private struct BillOfMaterialsView: View {
private func destinationURL(for item: CalculatorView.BOMItem) -> URL? { private func destinationURL(for item: CalculatorView.BOMItem) -> URL? {
switch item.destination { switch item.destination {
case .affiliate(let url): case .component(let id):
return url return VoltPlanRedirect.componentURL(id: id)
case .amazonSearch(let query): case .search(let query):
return AmazonAffiliate.searchURL(query: query, countryCode: info.countryCode) return VoltPlanRedirect.searchURL(query: query)
} }
} }
} }

View File

@@ -1,12 +1,6 @@
import SwiftUI import SwiftUI
struct ComponentLibraryItem: Identifiable, Equatable { struct ComponentLibraryItem: Identifiable, Equatable {
struct AffiliateLink: Identifiable, Equatable {
let id: String
let url: URL
let country: String?
}
let id: String let id: String
let name: String let name: String
let translations: [String: String] let translations: [String: String]
@@ -16,7 +10,6 @@ struct ComponentLibraryItem: Identifiable, Equatable {
let dutyCyclePercent: Double? let dutyCyclePercent: Double?
let defaultUtilizationFactorPercent: Double? let defaultUtilizationFactorPercent: Double?
let iconURL: URL? let iconURL: URL?
let affiliateLinks: [AffiliateLink]
var displayVoltage: Double? { var displayVoltage: Double? {
voltageIn ?? voltageOut voltageIn ?? voltageOut
@@ -65,36 +58,10 @@ struct ComponentLibraryItem: Identifiable, Equatable {
return translation(for: locale) return translation(for: locale)
} }
var primaryAffiliateLink: AffiliateLink? {
affiliateLink(matching: Locale.current.region?.identifier)
}
func localizedName(for locale: Locale) -> String { func localizedName(for locale: Locale) -> String {
translation(for: locale) ?? name translation(for: locale) ?? name
} }
func affiliateLink(matching regionCode: String?) -> AffiliateLink? {
guard !affiliateLinks.isEmpty else { return nil }
let normalizedRegionCode = regionCode?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
if let normalizedRegionCode, !normalizedRegionCode.isEmpty {
if let exactMatch = affiliateLinks.first(where: { link in
link.country?.lowercased() == normalizedRegionCode
}) {
return exactMatch
}
}
if let fallbackWithoutCountry = affiliateLinks.first(where: { $0.country == nil }) {
return fallbackWithoutCountry
}
return affiliateLinks.first
}
private func translation(for locale: Locale) -> String? { private func translation(for locale: Locale) -> String? {
guard !translations.isEmpty else { return nil } guard !translations.isEmpty else { return nil }
@@ -289,7 +256,6 @@ final class ComponentLibraryViewModel: ObservableObject {
page += 1 page += 1
} }
let affiliateLinksByComponent = try await fetchAffiliateLinks(for: allRecords.map(\.id))
let mappedItems = allRecords.map { record in let mappedItems = allRecords.map { record in
ComponentLibraryItem( ComponentLibraryItem(
id: record.id, id: record.id,
@@ -300,8 +266,7 @@ final class ComponentLibraryViewModel: ObservableObject {
watt: record.watt, watt: record.watt,
dutyCyclePercent: record.dutyCycle, dutyCyclePercent: record.dutyCycle,
defaultUtilizationFactorPercent: record.defaultUtilizationFactor, defaultUtilizationFactorPercent: record.defaultUtilizationFactor,
iconURL: iconURL(for: record), iconURL: iconURL(for: record)
affiliateLinks: affiliateLinksByComponent[record.id] ?? []
) )
} }
for item in mappedItems { for item in mappedItems {
@@ -314,110 +279,6 @@ final class ComponentLibraryViewModel: ObservableObject {
return mappedItems return mappedItems
} }
private func fetchAffiliateLinks(for componentIDs: [String]) async throws -> [String: [ComponentLibraryItem.AffiliateLink]] {
let uniqueIDs = Array(Set(componentIDs))
guard !uniqueIDs.isEmpty else { return [:] }
let idSet = Set(uniqueIDs)
let perPage = 200
let chunkSize = 15
let chunks: [[String]] = stride(from: 0, to: uniqueIDs.count, by: chunkSize).map { index in
let upperBound = min(index + chunkSize, uniqueIDs.count)
return Array(uniqueIDs[index..<upperBound])
}
var aggregated: [String: [ComponentLibraryItem.AffiliateLink]] = [:]
for chunk in chunks {
guard !chunk.isEmpty else { continue }
let filterValue = chunk
.map { "component='\(escapeFilterValue($0))'" }
.joined(separator: " || ")
var page = 1
while true {
var components = URLComponents(
url: baseURL.appendingPathComponent("api/collections/affiliate_links/records"),
resolvingAgainstBaseURL: false
)
var queryItems = [
URLQueryItem(name: "page", value: "\(page)"),
URLQueryItem(name: "perPage", value: "\(perPage)"),
URLQueryItem(name: "fields", value: "id,url,component,country")
]
if !filterValue.isEmpty {
queryItems.append(URLQueryItem(name: "filter", value: "(\(filterValue))"))
}
components?.queryItems = queryItems
guard let url = components?.url else {
throw URLError(.badURL)
}
var request = URLRequest(url: url)
request.cachePolicy = .reloadIgnoringLocalCacheData
let (data, response) = try await urlSession.data(for: request)
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
throw URLError(.badServerResponse)
}
let decoded = try JSONDecoder().decode(AffiliateLinksResponse.self, from: data)
for record in decoded.items {
guard let componentID = record.component, idSet.contains(componentID) else { continue }
guard let url = URL(string: record.url) else { continue }
let normalizedCountry = record.country?
.trimmingCharacters(in: .whitespacesAndNewlines)
let countryCode = normalizedCountry?.isEmpty == true ? nil : normalizedCountry?.uppercased()
let link = ComponentLibraryItem.AffiliateLink(
id: record.id,
url: url,
country: countryCode
)
var links = aggregated[componentID, default: []]
if !links.contains(where: { $0.id == record.id }) {
links.append(link)
aggregated[componentID] = links
}
}
let isLastPage: Bool
if decoded.totalPages > 0 {
isLastPage = page >= decoded.totalPages
} else {
isLastPage = decoded.items.count < perPage
}
if isLastPage { break }
page += 1
}
}
for key in Array(aggregated.keys) {
aggregated[key]?.sort { lhs, rhs in
let lhsCountry = lhs.country ?? ""
let rhsCountry = rhs.country ?? ""
if lhsCountry == rhsCountry {
return lhs.url.absoluteString < rhs.url.absoluteString
}
return lhsCountry < rhsCountry
}
}
return aggregated
}
private func iconURL(for record: PocketBaseRecord) -> URL? { private func iconURL(for record: PocketBaseRecord) -> URL? {
guard let icon = record.icon else { return nil } guard let icon = record.icon else { return nil }
@@ -518,18 +379,6 @@ final class ComponentLibraryViewModel: ObservableObject {
} }
} }
private struct AffiliateLinksResponse: Decodable {
let page: Int
let totalPages: Int
let items: [AffiliateLinkRecord]
}
private struct AffiliateLinkRecord: Decodable {
let id: String
let url: String
let component: String?
let country: String?
}
} }
struct ComponentLibraryView: View { struct ComponentLibraryView: View {

View File

@@ -0,0 +1,559 @@
//
// ScreenshotPreviews.swift
// Cable
//
// Screenshot previews for all views in all supported languages.
//
import SwiftUI
import SwiftData
// MARK: - Sample Data
private enum ScreenshotData {
@MainActor
static func makeContainer() -> ModelContainer {
let container = try! ModelContainer(
for: ElectricalSystem.self, SavedLoad.self, SavedBattery.self, SavedCharger.self, Item.self,
configurations: ModelConfiguration(isStoredInMemoryOnly: true)
)
let ctx = container.mainContext
// System 1: Sailboat
let sailboat = ElectricalSystem(
name: "Sailboat Aurora",
location: "Marina 7",
iconName: "sailboat",
colorName: "blue",
targetRuntimeHours: 15,
targetChargeTimeHours: 3
)
ctx.insert(sailboat)
let sailLoads: [(String, Double, Double, Double, Double, Double, String, String, Double, Double)] = [
("Navigation Lights", 12.8, 2.4, 28.8, 5.0, 2.5, "light.beacon.max", "red", 100, 10),
("Refrigerator", 12.8, 4.0, 48.0, 3.0, 2.5, "refrigerator", "blue", 40, 24),
("VHF Radio", 12.8, 6.0, 72.0, 8.0, 4.0, "antenna.radiowaves.left.and.right", "green", 30, 8),
("Anchor Windlass", 12.8, 80.0, 960.0, 6.0, 35.0, "arrow.up.and.down", "orange", 5, 0.5),
("LED Cabin Lights", 12.8, 1.5, 18.0, 4.0, 1.5, "lightbulb", "yellow", 100, 6),
]
for (name, voltage, current, power, length, crossSection, icon, color, duty, hours) in sailLoads {
ctx.insert(SavedLoad(
name: name, voltage: voltage, current: current, power: power,
length: length, crossSection: crossSection,
iconName: icon, colorName: color,
dutyCyclePercent: duty, dailyUsageHours: hours,
system: sailboat
))
}
ctx.insert(SavedBattery(
name: "House Bank",
nominalVoltage: 12.8, capacityAmpHours: 200,
chemistry: .lithiumIronPhosphate,
iconName: "battery.100.bolt", colorName: "green",
system: sailboat
))
ctx.insert(SavedBattery(
name: "Starter Battery",
nominalVoltage: 12.0, capacityAmpHours: 90,
chemistry: .agm,
iconName: "bolt", colorName: "orange",
system: sailboat
))
ctx.insert(SavedCharger(
name: "Shore Charger",
inputVoltage: 230, outputVoltage: 14.4,
maxCurrentAmps: 40, maxPowerWatts: 580,
iconName: "powerplug", colorName: "orange",
system: sailboat, powerSourceType: "shore"
))
ctx.insert(SavedCharger(
name: "Solar MPPT",
inputVoltage: 36, outputVoltage: 14.2,
maxCurrentAmps: 30, maxPowerWatts: 426,
iconName: "sun.max.fill", colorName: "yellow",
system: sailboat, powerSourceType: "solar"
))
// System 2: Camper Van
let camper = ElectricalSystem(
name: "Camper Van",
location: "Road Trip",
iconName: "bus",
colorName: "teal",
targetRuntimeHours: 24,
targetChargeTimeHours: 4
)
ctx.insert(camper)
let camperLoads: [(String, Double, Double, Double, Double, Double, String, String)] = [
("Water Pump", 12.8, 3.5, 42.0, 4.0, 2.5, "drop", "cyan"),
("USB Charger", 12.8, 2.0, 24.0, 2.0, 1.5, "cable.connector", "gray"),
]
for (name, voltage, current, power, length, crossSection, icon, color) in camperLoads {
ctx.insert(SavedLoad(
name: name, voltage: voltage, current: current, power: power,
length: length, crossSection: crossSection,
iconName: icon, colorName: color, system: camper
))
}
ctx.insert(SavedBattery(
name: "LiFePO4 200Ah",
nominalVoltage: 12.8, capacityAmpHours: 200,
chemistry: .lithiumIronPhosphate,
iconName: "battery.100.bolt", colorName: "green",
system: camper
))
// System 3: Cabin (empty, for variety)
let cabin = ElectricalSystem(
name: "Off-Grid Cabin",
location: "Mountains",
iconName: "house",
colorName: "brown"
)
ctx.insert(cabin)
return container
}
@MainActor
static func firstSystem(in container: ModelContainer) -> ElectricalSystem {
let systems = try! container.mainContext.fetch(
FetchDescriptor<ElectricalSystem>(sortBy: [SortDescriptor(\.timestamp)])
)
return systems.first!
}
}
// MARK: - Wrapper Views
private struct LoadsViewScreenshot: View {
let container: ModelContainer
let system: ElectricalSystem
var initialTab: LoadsView.ComponentTab = .overview
var body: some View {
NavigationStack {
LoadsView(system: system, initialTab: initialTab)
}
.modelContainer(container)
.environmentObject(UnitSystemSettings())
}
}
private struct SystemsViewScreenshot: View {
let container: ModelContainer
var body: some View {
SystemsView()
.modelContainer(container)
.environmentObject(UnitSystemSettings())
}
}
private struct CalculatorViewScreenshot: View {
let container: ModelContainer
var body: some View {
NavigationStack {
CalculatorView()
}
.modelContainer(container)
.environmentObject(UnitSystemSettings())
}
}
private struct BatteryEditorScreenshot: View {
var body: some View {
NavigationStack {
BatteryEditorView(
configuration: BatteryConfiguration(
name: "House Bank",
nominalVoltage: 12.8,
capacityAmpHours: 200,
chemistry: .lithiumIronPhosphate,
iconName: "battery.100.bolt",
colorName: "green",
system: ElectricalSystem(name: "Sailboat Aurora")
),
onSave: { _ in }
)
}
.environmentObject(UnitSystemSettings())
}
}
private struct ChargerEditorScreenshot: View {
var body: some View {
NavigationStack {
ChargerEditorView(
configuration: ChargerConfiguration(
name: "Shore Charger",
inputVoltage: 230,
outputVoltage: 14.4,
maxCurrentAmps: 40,
maxPowerWatts: 580,
iconName: "powerplug",
colorName: "orange",
system: ElectricalSystem(name: "Sailboat Aurora")
),
onSave: { _ in }
)
}
}
}
private struct ComponentLibraryScreenshot: View {
var body: some View {
ComponentLibraryView(
viewModel: ComponentLibraryViewModel(previewItems: Self.sampleItems),
onSelect: { _ in }
)
}
private static let sampleItems: [ComponentLibraryItem] = [
ComponentLibraryItem(
id: "1", name: "Navigation Lights",
translations: ["de": "Navigationslichter", "es": "Luces de navegación", "fr": "Feux de navigation", "nl": "Navigatieverlichting"],
voltageIn: 12.8, voltageOut: nil, watt: 25,
dutyCyclePercent: 100, defaultUtilizationFactorPercent: 40,
iconURL: nil
),
ComponentLibraryItem(
id: "2", name: "Refrigerator Compressor",
translations: ["de": "Kühlschrank-Kompressor", "es": "Compresor de refrigerador", "fr": "Compresseur réfrigérateur", "nl": "Koelkastcompressor"],
voltageIn: 12.8, voltageOut: nil, watt: 48,
dutyCyclePercent: 40, defaultUtilizationFactorPercent: 100,
iconURL: nil
),
ComponentLibraryItem(
id: "3", name: "Anchor Windlass",
translations: ["de": "Ankerwinde", "es": "Molinete de ancla", "fr": "Guindeau", "nl": "Ankerlier"],
voltageIn: 12.8, voltageOut: nil, watt: 960,
dutyCyclePercent: 5, defaultUtilizationFactorPercent: 2,
iconURL: nil
),
ComponentLibraryItem(
id: "4", name: "VHF Radio",
translations: ["de": "UKW Funkgerät", "es": "Radio VHF", "fr": "Radio VHF", "nl": "Marifoon"],
voltageIn: 12.8, voltageOut: nil, watt: 72,
dutyCyclePercent: 30, defaultUtilizationFactorPercent: 33,
iconURL: nil
),
ComponentLibraryItem(
id: "5", name: "LED Interior Lights",
translations: ["de": "LED Innenbeleuchtung", "es": "Iluminación LED interior", "fr": "Éclairage LED intérieur", "nl": "LED binnenverlichting"],
voltageIn: 12.8, voltageOut: nil, watt: 18,
dutyCyclePercent: 100, defaultUtilizationFactorPercent: 25,
iconURL: nil
),
ComponentLibraryItem(
id: "6", name: "Water Pump",
translations: ["de": "Wasserpumpe", "es": "Bomba de agua", "fr": "Pompe à eau", "nl": "Waterpomp"],
voltageIn: 12.8, voltageOut: nil, watt: 42,
dutyCyclePercent: 20, defaultUtilizationFactorPercent: 10,
iconURL: nil
),
ComponentLibraryItem(
id: "7", name: "Diesel Heater",
translations: ["de": "Dieselheizung", "es": "Calefactor diésel", "fr": "Chauffage diesel", "nl": "Dieselverwarming"],
voltageIn: 12.8, voltageOut: nil, watt: 36,
dutyCyclePercent: 60, defaultUtilizationFactorPercent: 50,
iconURL: nil
),
ComponentLibraryItem(
id: "8", name: "USB Charging Station",
translations: ["de": "USB-Ladestation", "es": "Estación de carga USB", "fr": "Station de charge USB", "nl": "USB-laadstation"],
voltageIn: 12.8, voltageOut: nil, watt: 24,
dutyCyclePercent: 100, defaultUtilizationFactorPercent: 30,
iconURL: nil
),
]
}
// MARK: - Language helper
private let locales: [(String, String)] = [
("EN", "en"),
("DE", "de"),
("ES", "es"),
("FR", "fr"),
("NL", "nl"),
]
// MARK: - 1. Overview Tab
#Preview("Overview EN") {
let c = ScreenshotData.makeContainer()
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .overview)
.environment(\.locale, Locale(identifier: "en"))
}
#Preview("Overview DE") {
let c = ScreenshotData.makeContainer()
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .overview)
.environment(\.locale, Locale(identifier: "de"))
}
#Preview("Overview ES") {
let c = ScreenshotData.makeContainer()
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .overview)
.environment(\.locale, Locale(identifier: "es"))
}
#Preview("Overview FR") {
let c = ScreenshotData.makeContainer()
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .overview)
.environment(\.locale, Locale(identifier: "fr"))
}
#Preview("Overview NL") {
let c = ScreenshotData.makeContainer()
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .overview)
.environment(\.locale, Locale(identifier: "nl"))
}
// MARK: - 2. Components Tab
#Preview("Components EN") {
let c = ScreenshotData.makeContainer()
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .components)
.environment(\.locale, Locale(identifier: "en"))
}
#Preview("Components DE") {
let c = ScreenshotData.makeContainer()
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .components)
.environment(\.locale, Locale(identifier: "de"))
}
#Preview("Components ES") {
let c = ScreenshotData.makeContainer()
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .components)
.environment(\.locale, Locale(identifier: "es"))
}
#Preview("Components FR") {
let c = ScreenshotData.makeContainer()
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .components)
.environment(\.locale, Locale(identifier: "fr"))
}
#Preview("Components NL") {
let c = ScreenshotData.makeContainer()
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .components)
.environment(\.locale, Locale(identifier: "nl"))
}
// MARK: - 3. Batteries Tab
#Preview("Batteries EN") {
let c = ScreenshotData.makeContainer()
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .batteries)
.environment(\.locale, Locale(identifier: "en"))
}
#Preview("Batteries DE") {
let c = ScreenshotData.makeContainer()
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .batteries)
.environment(\.locale, Locale(identifier: "de"))
}
#Preview("Batteries ES") {
let c = ScreenshotData.makeContainer()
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .batteries)
.environment(\.locale, Locale(identifier: "es"))
}
#Preview("Batteries FR") {
let c = ScreenshotData.makeContainer()
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .batteries)
.environment(\.locale, Locale(identifier: "fr"))
}
#Preview("Batteries NL") {
let c = ScreenshotData.makeContainer()
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .batteries)
.environment(\.locale, Locale(identifier: "nl"))
}
// MARK: - 4. Chargers Tab
#Preview("Chargers EN") {
let c = ScreenshotData.makeContainer()
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .chargers)
.environment(\.locale, Locale(identifier: "en"))
}
#Preview("Chargers DE") {
let c = ScreenshotData.makeContainer()
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .chargers)
.environment(\.locale, Locale(identifier: "de"))
}
#Preview("Chargers ES") {
let c = ScreenshotData.makeContainer()
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .chargers)
.environment(\.locale, Locale(identifier: "es"))
}
#Preview("Chargers FR") {
let c = ScreenshotData.makeContainer()
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .chargers)
.environment(\.locale, Locale(identifier: "fr"))
}
#Preview("Chargers NL") {
let c = ScreenshotData.makeContainer()
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .chargers)
.environment(\.locale, Locale(identifier: "nl"))
}
// MARK: - 5. Systems List
#Preview("Systems EN") {
let c = ScreenshotData.makeContainer()
SystemsViewScreenshot(container: c)
.environment(\.locale, Locale(identifier: "en"))
}
#Preview("Systems DE") {
let c = ScreenshotData.makeContainer()
SystemsViewScreenshot(container: c)
.environment(\.locale, Locale(identifier: "de"))
}
#Preview("Systems ES") {
let c = ScreenshotData.makeContainer()
SystemsViewScreenshot(container: c)
.environment(\.locale, Locale(identifier: "es"))
}
#Preview("Systems FR") {
let c = ScreenshotData.makeContainer()
SystemsViewScreenshot(container: c)
.environment(\.locale, Locale(identifier: "fr"))
}
#Preview("Systems NL") {
let c = ScreenshotData.makeContainer()
SystemsViewScreenshot(container: c)
.environment(\.locale, Locale(identifier: "nl"))
}
// MARK: - 6. Parts Library
#Preview("Library EN") {
ComponentLibraryScreenshot()
.environment(\.locale, Locale(identifier: "en"))
}
#Preview("Library DE") {
ComponentLibraryScreenshot()
.environment(\.locale, Locale(identifier: "de"))
}
#Preview("Library ES") {
ComponentLibraryScreenshot()
.environment(\.locale, Locale(identifier: "es"))
}
#Preview("Library FR") {
ComponentLibraryScreenshot()
.environment(\.locale, Locale(identifier: "fr"))
}
#Preview("Library NL") {
ComponentLibraryScreenshot()
.environment(\.locale, Locale(identifier: "nl"))
}
// MARK: - 7. Load Editor (Calculator)
#Preview("LoadEditor EN") {
let c = ScreenshotData.makeContainer()
CalculatorViewScreenshot(container: c)
.environment(\.locale, Locale(identifier: "en"))
}
#Preview("LoadEditor DE") {
let c = ScreenshotData.makeContainer()
CalculatorViewScreenshot(container: c)
.environment(\.locale, Locale(identifier: "de"))
}
#Preview("LoadEditor ES") {
let c = ScreenshotData.makeContainer()
CalculatorViewScreenshot(container: c)
.environment(\.locale, Locale(identifier: "es"))
}
#Preview("LoadEditor FR") {
let c = ScreenshotData.makeContainer()
CalculatorViewScreenshot(container: c)
.environment(\.locale, Locale(identifier: "fr"))
}
#Preview("LoadEditor NL") {
let c = ScreenshotData.makeContainer()
CalculatorViewScreenshot(container: c)
.environment(\.locale, Locale(identifier: "nl"))
}
// MARK: - 8. Battery Editor
#Preview("BatteryEditor EN") {
BatteryEditorScreenshot()
.environment(\.locale, Locale(identifier: "en"))
}
#Preview("BatteryEditor DE") {
BatteryEditorScreenshot()
.environment(\.locale, Locale(identifier: "de"))
}
#Preview("BatteryEditor ES") {
BatteryEditorScreenshot()
.environment(\.locale, Locale(identifier: "es"))
}
#Preview("BatteryEditor FR") {
BatteryEditorScreenshot()
.environment(\.locale, Locale(identifier: "fr"))
}
#Preview("BatteryEditor NL") {
BatteryEditorScreenshot()
.environment(\.locale, Locale(identifier: "nl"))
}
// MARK: - 9. Charger Editor
#Preview("ChargerEditor EN") {
ChargerEditorScreenshot()
.environment(\.locale, Locale(identifier: "en"))
}
#Preview("ChargerEditor DE") {
ChargerEditorScreenshot()
.environment(\.locale, Locale(identifier: "de"))
}
#Preview("ChargerEditor ES") {
ChargerEditorScreenshot()
.environment(\.locale, Locale(identifier: "es"))
}
#Preview("ChargerEditor FR") {
ChargerEditorScreenshot()
.environment(\.locale, Locale(identifier: "fr"))
}
#Preview("ChargerEditor NL") {
ChargerEditorScreenshot()
.environment(\.locale, Locale(identifier: "nl"))
}

View File

@@ -111,8 +111,8 @@ struct SystemBillOfMaterialsView: View {
private struct Item: Identifiable { private struct Item: Identifiable {
enum Destination { enum Destination {
case affiliate(URL) case component(String)
case amazonSearch(String) case search(String)
} }
let id: String let id: String
@@ -436,12 +436,12 @@ struct SystemBillOfMaterialsView: View {
} }
private func trackAffiliateTap(item: Item, url: URL) { private func trackAffiliateTap(item: Item, url: URL) {
let isAffiliate: Bool let isComponent: Bool
if case .affiliate = item.destination { isAffiliate = true } else { isAffiliate = false } if case .component = item.destination { isComponent = true } else { isComponent = false }
AnalyticsTracker.log("BOM Item Tapped", properties: [ AnalyticsTracker.log("BOM Item Tapped", properties: [
"item": item.title, "item": item.title,
"category": item.category.rawValue, "category": item.category.rawValue,
"is_affiliate": isAffiliate, "is_component": isComponent,
"domain": url.host ?? "unknown", "domain": url.host ?? "unknown",
"system": systemName, "system": systemName,
]) ])
@@ -585,7 +585,6 @@ struct SystemBillOfMaterialsView: View {
cableShoesDetailFormat, cableShoesDetailFormat,
crossSectionLabel.lowercased() crossSectionLabel.lowercased()
) )
let affiliateURL = load.affiliateURLString.flatMap { URL(string: $0) }
let deviceFallbackFormat = String(localized: "bom.search.device.fallback", defaultValue: "DC device %.0fW %.0fV") let deviceFallbackFormat = String(localized: "bom.search.device.fallback", defaultValue: "DC device %.0fW %.0fV")
let deviceQuery = load.name.isEmpty let deviceQuery = load.name.isEmpty
? String(format: deviceFallbackFormat, calculatedPower, load.voltage) ? String(format: deviceFallbackFormat, calculatedPower, load.voltage)
@@ -619,7 +618,7 @@ struct SystemBillOfMaterialsView: View {
title: load.name.isEmpty ? String(localized: "component.fallback.name", comment: "Fallback name for a component when no name is provided") : load.name, title: load.name.isEmpty ? String(localized: "component.fallback.name", comment: "Fallback name for a component when no name is provided") : load.name,
detail: powerDetail, detail: powerDetail,
iconSystemName: "bolt.fill", iconSystemName: "bolt.fill",
destination: affiliateURL.map { .affiliate($0) } ?? .amazonSearch(deviceQuery), destination: load.componentID.map { .component($0) } ?? .search(deviceQuery),
isPrimaryComponent: true, isPrimaryComponent: true,
components: [component], components: [component],
category: .components, category: .components,
@@ -636,7 +635,7 @@ struct SystemBillOfMaterialsView: View {
title: String(localized: "bom.item.cable.red", comment: "Title for the red power cable item"), title: String(localized: "bom.item.cable.red", comment: "Title for the red power cable item"),
detail: "", detail: "",
iconSystemName: "bolt.horizontal.circle", iconSystemName: "bolt.horizontal.circle",
destination: .amazonSearch(redCableQuery), destination: .search(redCableQuery),
isPrimaryComponent: false, isPrimaryComponent: false,
components: [component], components: [component],
category: .cables, category: .cables,
@@ -653,7 +652,7 @@ struct SystemBillOfMaterialsView: View {
title: String(localized: "bom.item.cable.black", comment: "Title for the black power cable item"), title: String(localized: "bom.item.cable.black", comment: "Title for the black power cable item"),
detail: "", detail: "",
iconSystemName: "bolt.horizontal.circle", iconSystemName: "bolt.horizontal.circle",
destination: .amazonSearch(blackCableQuery), destination: .search(blackCableQuery),
isPrimaryComponent: false, isPrimaryComponent: false,
components: [component], components: [component],
category: .cables, category: .cables,
@@ -670,7 +669,7 @@ struct SystemBillOfMaterialsView: View {
title: String(localized: "bom.item.fuse", comment: "Title for the fuse and holder item"), title: String(localized: "bom.item.fuse", comment: "Title for the fuse and holder item"),
detail: fuseDetail, detail: fuseDetail,
iconSystemName: "bolt.shield", iconSystemName: "bolt.shield",
destination: .amazonSearch(fuseQuery), destination: .search(fuseQuery),
isPrimaryComponent: false, isPrimaryComponent: false,
components: [component], components: [component],
category: .fuses, category: .fuses,
@@ -687,7 +686,7 @@ struct SystemBillOfMaterialsView: View {
title: String(localized: "bom.item.terminals", comment: "Title for the cable terminals item"), title: String(localized: "bom.item.terminals", comment: "Title for the cable terminals item"),
detail: cableShoesDetail, detail: cableShoesDetail,
iconSystemName: "wrench.and.screwdriver", iconSystemName: "wrench.and.screwdriver",
destination: .amazonSearch(terminalQuery), destination: .search(terminalQuery),
isPrimaryComponent: false, isPrimaryComponent: false,
components: [component], components: [component],
category: .accessories, category: .accessories,
@@ -712,7 +711,7 @@ struct SystemBillOfMaterialsView: View {
let voltageQuery = max(1, Int(round(battery.nominalVoltage))) let voltageQuery = max(1, Int(round(battery.nominalVoltage)))
let batterySearchFormat = String(localized: "bom.search.battery", defaultValue: "%dAh %dV %@ battery") let batterySearchFormat = String(localized: "bom.search.battery", defaultValue: "%dAh %dV %@ battery")
let query = String(format: batterySearchFormat, capacityQuery, voltageQuery, battery.chemistry.displayName) let query = String(format: batterySearchFormat, capacityQuery, voltageQuery, battery.chemistry.displayName)
let affiliateURL = battery.affiliateURLString.flatMap { URL(string: $0) } let componentID = battery.componentID
let storageKey = Self.storageKey(for: component, itemID: "battery") let storageKey = Self.storageKey(for: component, itemID: "battery")
return [ return [
@@ -723,7 +722,7 @@ struct SystemBillOfMaterialsView: View {
title: battery.name.isEmpty ? String(localized: "component.fallback.name", comment: "Fallback name for a component when no name is provided") : battery.name, title: battery.name.isEmpty ? String(localized: "component.fallback.name", comment: "Fallback name for a component when no name is provided") : battery.name,
detail: detail, detail: detail,
iconSystemName: battery.iconName, iconSystemName: battery.iconName,
destination: affiliateURL.map { .affiliate($0) } ?? .amazonSearch(query), destination: componentID.map { .component($0) } ?? .search(query),
isPrimaryComponent: true, isPrimaryComponent: true,
components: [component], components: [component],
category: .batteries, category: .batteries,
@@ -746,7 +745,7 @@ struct SystemBillOfMaterialsView: View {
let voltageQuery = max(1, Int(round(charger.outputVoltage))) let voltageQuery = max(1, Int(round(charger.outputVoltage)))
let currentQuery = max(1, Int(round(charger.maxCurrentAmps))) let currentQuery = max(1, Int(round(charger.maxCurrentAmps)))
let query = "\(voltageQuery)V \(currentQuery)A battery charger" let query = "\(voltageQuery)V \(currentQuery)A battery charger"
let affiliateURL = charger.affiliateURLString.flatMap { URL(string: $0) } let componentID = charger.componentID
let storageKey = Self.storageKey(for: component, itemID: "charger") let storageKey = Self.storageKey(for: component, itemID: "charger")
return [ return [
@@ -757,7 +756,7 @@ struct SystemBillOfMaterialsView: View {
title: charger.name.isEmpty ? String(localized: "component.fallback.name", comment: "Fallback name for a component when no name is provided") : charger.name, title: charger.name.isEmpty ? String(localized: "component.fallback.name", comment: "Fallback name for a component when no name is provided") : charger.name,
detail: detail, detail: detail,
iconSystemName: charger.iconName, iconSystemName: charger.iconName,
destination: affiliateURL.map { .affiliate($0) } ?? .amazonSearch(query), destination: componentID.map { .component($0) } ?? .search(query),
isPrimaryComponent: true, isPrimaryComponent: true,
components: [component], components: [component],
category: .components, category: .components,
@@ -772,22 +771,10 @@ struct SystemBillOfMaterialsView: View {
private func destinationURL(for destination: Item.Destination, component: Component) -> URL? { private func destinationURL(for destination: Item.Destination, component: Component) -> URL? {
switch destination { switch destination {
case .affiliate(let url): case .component(let id):
return url return VoltPlanRedirect.componentURL(id: id)
case .amazonSearch(let query): case .search(let query):
let countryCode = affiliateCountryCode(for: component) ?? Locale.current.region?.identifier return VoltPlanRedirect.searchURL(query: query)
return AmazonAffiliate.searchURL(query: query, countryCode: countryCode)
}
}
private func affiliateCountryCode(for component: Component) -> String? {
switch component {
case .load(let load):
return load.affiliateCountryCode
case .battery(let battery):
return battery.affiliateCountryCode
case .charger(let charger):
return charger.affiliateCountryCode
} }
} }
@@ -905,8 +892,7 @@ struct SystemBillOfMaterialsView: View {
dailyUsageHours: 0, dailyUsageHours: 0,
system: nil, system: nil,
remoteIconURLString: nil, remoteIconURLString: nil,
affiliateURLString: nil, componentID: nil,
affiliateCountryCode: nil,
bomCompletedItemIDs: [], bomCompletedItemIDs: [],
identifier: UUID().uuidString identifier: UUID().uuidString
) )
@@ -952,8 +938,7 @@ struct SystemBillOfMaterialsView: View {
dailyUsageHours: 0, dailyUsageHours: 0,
system: nil, system: nil,
remoteIconURLString: nil, remoteIconURLString: nil,
affiliateURLString: nil, componentID: nil,
affiliateCountryCode: nil,
bomCompletedItemIDs: ["component", "cable-red"], bomCompletedItemIDs: ["component", "cable-red"],
identifier: UUID().uuidString identifier: UUID().uuidString
) )

View File

@@ -66,7 +66,6 @@ struct SystemComponentsPersistence {
current = 0 current = 0
} }
let affiliateLink = item.primaryAffiliateLink
let dutyCyclePercent = item.normalizedDutyCyclePercent ?? 100 let dutyCyclePercent = item.normalizedDutyCyclePercent ?? 100
let dailyUsageHours = item.defaultDailyUsageHours ?? 1 let dailyUsageHours = item.defaultDailyUsageHours ?? 1
@@ -84,8 +83,7 @@ struct SystemComponentsPersistence {
dailyUsageHours: dailyUsageHours, dailyUsageHours: dailyUsageHours,
system: system, system: system,
remoteIconURLString: item.iconURL?.absoluteString, remoteIconURLString: item.iconURL?.absoluteString,
affiliateURLString: affiliateLink?.url.absoluteString, componentID: item.id
affiliateCountryCode: affiliateLink?.country
) )
context.insert(newLoad) context.insert(newLoad)

View File

@@ -372,7 +372,6 @@ struct SystemsView: View {
current = 0 current = 0
} }
let affiliateLink = item.primaryAffiliateLink
let dutyCyclePercent = item.normalizedDutyCyclePercent ?? 100 let dutyCyclePercent = item.normalizedDutyCyclePercent ?? 100
let dailyUsageHours = item.defaultDailyUsageHours ?? 1 let dailyUsageHours = item.defaultDailyUsageHours ?? 1
@@ -390,8 +389,7 @@ struct SystemsView: View {
dailyUsageHours: dailyUsageHours, dailyUsageHours: dailyUsageHours,
system: system, system: system,
remoteIconURLString: item.iconURL?.absoluteString, remoteIconURLString: item.iconURL?.absoluteString,
affiliateURLString: affiliateLink?.url.absoluteString, componentID: item.id
affiliateCountryCode: affiliateLink?.country
) )
modelContext.insert(newLoad) modelContext.insert(newLoad)
@@ -549,8 +547,7 @@ struct SystemsView: View {
isWattMode: false, isWattMode: false,
system: system1, system: system1,
remoteIconURLString: nil, remoteIconURLString: nil,
affiliateURLString: nil, componentID: nil
affiliateCountryCode: nil
) )
let load2 = SavedLoad( let load2 = SavedLoad(
@@ -565,8 +562,7 @@ struct SystemsView: View {
isWattMode: false, isWattMode: false,
system: system1, system: system1,
remoteIconURLString: nil, remoteIconURLString: nil,
affiliateURLString: nil, componentID: nil
affiliateCountryCode: nil
) )
// Sample loads for system 2 // Sample loads for system 2
@@ -582,8 +578,7 @@ struct SystemsView: View {
isWattMode: false, isWattMode: false,
system: system2, system: system2,
remoteIconURLString: nil, remoteIconURLString: nil,
affiliateURLString: nil, componentID: nil
affiliateCountryCode: nil
) )
context.insert(load1) context.insert(load1)

View File

@@ -15,7 +15,6 @@ struct ComponentLibraryItemTests {
dutyCyclePercent: nil, dutyCyclePercent: nil,
defaultUtilizationFactorPercent: nil, defaultUtilizationFactorPercent: nil,
iconURL: nil, iconURL: nil,
affiliateLinks: []
) )
let german = Foundation.Locale(identifier: "de_DE") let german = Foundation.Locale(identifier: "de_DE")
@@ -33,7 +32,6 @@ struct ComponentLibraryItemTests {
dutyCyclePercent: nil, dutyCyclePercent: nil,
defaultUtilizationFactorPercent: nil, defaultUtilizationFactorPercent: nil,
iconURL: nil, iconURL: nil,
affiliateLinks: []
) )
let french = Foundation.Locale(identifier: "fr_FR") let french = Foundation.Locale(identifier: "fr_FR")
@@ -54,7 +52,6 @@ struct ComponentLibraryItemTests {
dutyCyclePercent: nil, dutyCyclePercent: nil,
defaultUtilizationFactorPercent: nil, defaultUtilizationFactorPercent: nil,
iconURL: nil, iconURL: nil,
affiliateLinks: []
) )
let languages = ["fr-FR", "de-DE", "es-ES"] let languages = ["fr-FR", "de-DE", "es-ES"]
@@ -72,7 +69,6 @@ struct ComponentLibraryItemTests {
dutyCyclePercent: nil, dutyCyclePercent: nil,
defaultUtilizationFactorPercent: nil, defaultUtilizationFactorPercent: nil,
iconURL: nil, iconURL: nil,
affiliateLinks: []
) )
let spanishMexico = Foundation.Locale(identifier: "es_MX") let spanishMexico = Foundation.Locale(identifier: "es_MX")
@@ -90,7 +86,6 @@ struct ComponentLibraryItemTests {
dutyCyclePercent: nil, dutyCyclePercent: nil,
defaultUtilizationFactorPercent: nil, defaultUtilizationFactorPercent: nil,
iconURL: nil, iconURL: nil,
affiliateLinks: []
) )
let germanSwitzerland = Foundation.Locale(identifier: "de_CH") let germanSwitzerland = Foundation.Locale(identifier: "de_CH")
@@ -108,7 +103,6 @@ struct ComponentLibraryItemTests {
dutyCyclePercent: nil, dutyCyclePercent: nil,
defaultUtilizationFactorPercent: nil, defaultUtilizationFactorPercent: nil,
iconURL: nil, iconURL: nil,
affiliateLinks: []
) )
let french = Foundation.Locale(identifier: "fr_FR") let french = Foundation.Locale(identifier: "fr_FR")
@@ -126,7 +120,6 @@ struct ComponentLibraryItemTests {
dutyCyclePercent: 0, dutyCyclePercent: 0,
defaultUtilizationFactorPercent: nil, defaultUtilizationFactorPercent: nil,
iconURL: nil, iconURL: nil,
affiliateLinks: []
) )
#expect(item.normalizedDutyCyclePercent == 100) #expect(item.normalizedDutyCyclePercent == 100)
@@ -143,7 +136,6 @@ struct ComponentLibraryItemTests {
dutyCyclePercent: nil, dutyCyclePercent: nil,
defaultUtilizationFactorPercent: 50, defaultUtilizationFactorPercent: 50,
iconURL: nil, iconURL: nil,
affiliateLinks: []
) )
#expect(item.defaultDailyUsageHours == 12) #expect(item.defaultDailyUsageHours == 12)

View File

@@ -125,32 +125,21 @@ final class CableUITestsScreenshot: XCTestCase {
try super.setUpWithError() try super.setUpWithError()
continueAfterFailure = false continueAfterFailure = false
XCUIDevice.shared.orientation = .portrait XCUIDevice.shared.orientation = .portrait
//ensureDoNotDisturbEnabled()
//dismissSystemOverlays()
} }
override func tearDownWithError() throws { // MARK: - Onboarding Screenshots
try super.tearDownWithError()
//dismissSystemOverlays()
}
@MainActor @MainActor
func testOnboardingScreenshots() throws { func testOnboardingScreenshots() throws {
let app = launchApp(arguments: ["--uitest-reset-data"]) let app = launchApp(arguments: ["--uitest-reset-data"])
waitForStability(long: true) // Wait for Apple Intelligence and other system notifications to appear, then dismiss
RunLoop.current.run(until: Date().addingTimeInterval(6))
dismissNotificationBannersIfNeeded() dismissNotificationBannersIfNeeded()
waitForStability(long: true)
dismissNotificationBannersIfNeeded()
waitForStability(long: true)
dismissNotificationBannersIfNeeded()
waitForStability(long: true)
dismissNotificationBannersIfNeeded()
let createSystemButton = app.buttons["create-system-button"] let createSystemButton = app.buttons["create-system-button"]
XCTAssertTrue(createSystemButton.waitForExistence(timeout: 8)) XCTAssertTrue(createSystemButton.waitForExistence(timeout: 8))
waitForStability(long: true)
dismissNotificationBannersIfNeeded() dismissNotificationBannersIfNeeded()
waitForStability(long: true) waitForStability()
takeScreenshot(named: "01-OnboardingSystemsView") takeScreenshot(named: "01-OnboardingSystemsView")
createSystemButton.tap() createSystemButton.tap()
@@ -159,14 +148,15 @@ final class CableUITestsScreenshot: XCTestCase {
XCTAssertTrue(addLoadButton.waitForExistence(timeout: 8)) XCTAssertTrue(addLoadButton.waitForExistence(timeout: 8))
let browseLibraryButton = button(in: app.buttons, for: .browseLibrary) let browseLibraryButton = button(in: app.buttons, for: .browseLibrary)
XCTAssertTrue(browseLibraryButton.waitForExistence(timeout: 4)) XCTAssertTrue(browseLibraryButton.waitForExistence(timeout: 4))
dismissNotificationBannersIfNeeded()
waitForStability()
takeScreenshot(named: "02-OnboardingSystemView") takeScreenshot(named: "02-OnboardingSystemView")
browseLibraryButton.tap() browseLibraryButton.tap()
let libraryCloseButton = app.buttons["library-view-close-button"] let libraryCloseButton = app.buttons["library-view-close-button"]
XCTAssertTrue(libraryCloseButton.waitForExistence(timeout: 8)) XCTAssertTrue(libraryCloseButton.waitForExistence(timeout: 8))
waitForStability(long: true) // Wait for library items AND remote icons to load from PocketBase
RunLoop.current.run(until: Date().addingTimeInterval(15))
dismissNotificationBannersIfNeeded()
takeScreenshot(named: "04-ComponentSelectorView") takeScreenshot(named: "04-ComponentSelectorView")
libraryCloseButton.tap() libraryCloseButton.tap()
@@ -175,20 +165,130 @@ final class CableUITestsScreenshot: XCTestCase {
let newLoadButton = button(in: app.buttons, for: .defaultLoadName) let newLoadButton = button(in: app.buttons, for: .defaultLoadName)
XCTAssertTrue(newLoadButton.waitForExistence(timeout: 8)) XCTAssertTrue(newLoadButton.waitForExistence(timeout: 8))
waitForStability(long: true) waitForStability()
dismissNotificationBannersIfNeeded()
takeScreenshot(named: "03-LoadEditorView") takeScreenshot(named: "03-LoadEditorView")
} }
// MARK: - Sample Data Screenshots
@MainActor @MainActor
func testSampleDataScreenshots() throws { 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() let app = XCUIApplication()
app.launchArguments = ["--uitest-reset-data", "--uitest-sample-data"] app.launchArguments = ["--uitest-reset-data", "--uitest-sample-data"]
app.launch() app.launch()
return app
}
let systemsList = resolvedSystemsList(in: app) // MARK: - Navigation Helpers
XCTAssertTrue(systemsList.waitForExistence(timeout: 4))
takeScreenshot(named: "05-SystemsWithSampleData")
private func openFirstSystem(in app: XCUIApplication, systemsList: XCUIElement) {
let firstSystemCell = systemsList.cells.element(boundBy: 0) let firstSystemCell = systemsList.cells.element(boundBy: 0)
XCTAssertTrue(firstSystemCell.waitForExistence(timeout: 2)) XCTAssertTrue(firstSystemCell.waitForExistence(timeout: 2))
let systemName = firstSystemCell.staticTexts.firstMatch.label let systemName = firstSystemCell.staticTexts.firstMatch.label
@@ -206,69 +306,41 @@ final class CableUITestsScreenshot: XCTestCase {
detailVisible = waitForSystemDetail(named: systemName, in: app) detailVisible = waitForSystemDetail(named: systemName, in: app)
} }
XCTAssertTrue(detailVisible) XCTAssertTrue(detailVisible)
takeScreenshot(named: "06-AdventureVanOverview")
// let overviewTab = app.buttons["overview-tab"]
// XCTAssertTrue(overviewTab.waitForExistence(timeout: 3))
// overviewTab.tap()
waitForStability(long: false)
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)
}
if bomElement.isHittable {
bomElement.tap()
} else {
bomElement.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
}
waitForStability(long: true)
takeScreenshot(named: "08-BillOfMaterials")
let closeButton = app.buttons["system-bom-close-button"]
XCTAssertTrue(closeButton.waitForExistence(timeout: 6))
closeButton.tap()
let componentsTab = componentsTabButton(in: app)
XCTAssertTrue(componentsTab.waitForExistence(timeout: 3))
if componentsTab.isHittable {
componentsTab.tap()
} else {
componentsTab.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
}
let loadsList = resolvedLoadsList(in: app)
XCTAssertTrue(loadsList.waitForExistence(timeout: 4))
takeScreenshot(named: "07-AdventureVanLoads")
waitForStability()
let firstLoad = loadsList.cells.element(boundBy: 0)
XCTAssertTrue(firstLoad.waitForExistence(timeout: 2))
let loadName = firstLoad.staticTexts.firstMatch.label
firstLoad.tap()
let loadNavButton = app.navigationBars.buttons[loadName]
XCTAssertTrue(loadNavButton.waitForExistence(timeout: 3))
takeScreenshot(named: "09-AdventureVanCalculator")
} }
private func launchApp(arguments: [String]) -> XCUIApplication { private func tapTab(_ key: UIStringKey, in app: XCUIApplication) {
let app = XCUIApplication() let identifierMap: [UIStringKey: String] = [
var launchArguments = ["--uitest-reset-data"] .overviewTab: "overview-tab",
launchArguments.append(contentsOf: arguments) .componentsTab: "components-tab",
app.launchArguments = launchArguments .batteriesTab: "batteries-tab",
app.launch() .chargersTab: "chargers-tab",
//dismissSystemOverlays() ]
return app
// 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 { private func resolvedSystemsList(in app: XCUIApplication) -> XCUIElement {
let collection = app.collectionViews["systems-list"] let collection = app.collectionViews["systems-list"]
if collection.waitForExistence(timeout: 6) { if collection.waitForExistence(timeout: 6) {
@@ -299,6 +371,62 @@ final class CableUITestsScreenshot: XCTestCase {
return table 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) { private func takeScreenshot(named name: String) {
let screenshot = XCUIScreen.main.screenshot() let screenshot = XCUIScreen.main.screenshot()
let attachment = XCTAttachment(screenshot: screenshot) let attachment = XCTAttachment(screenshot: screenshot)
@@ -308,52 +436,7 @@ final class CableUITestsScreenshot: XCTestCase {
} }
private func waitForStability(long: Bool = false) { private func waitForStability(long: Bool = false) {
RunLoop.current.run(until: Date().addingTimeInterval(long ? 5.0 : 0.5)) RunLoop.current.run(until: Date().addingTimeInterval(long ? 3.0 : 0.5))
}
private func componentsTabButton(in app: XCUIApplication) -> XCUIElement {
let identifierMatch = app.descendants(matching: .any)
.matching(identifier: "components-tab").firstMatch
if identifierMatch.exists {
return identifierMatch
}
let localizedLabels = [
"Components", "Verbraucher", "Componentes", "Composants", "Componenten"
]
for label in localizedLabels {
let button = app.buttons[label]
if button.exists {
return button
}
let tabBarButton = app.tabBars.buttons[label]
if tabBarButton.exists {
return tabBarButton
}
let segmentedButton = app.segmentedControls.buttons[label]
if segmentedButton.exists {
return segmentedButton
}
let segmentedOther = app.segmentedControls.otherElements[label]
if segmentedOther.exists {
return segmentedOther
}
}
let fallbackSegmented = app.segmentedControls.buttons.element(boundBy: 1)
if fallbackSegmented.exists {
return fallbackSegmented
}
let tabBarButton = app.tabBars.buttons.element(boundBy: 1)
if tabBarButton.exists {
return tabBarButton
}
return app.tabBars.descendants(matching: .any).firstMatch
} }
private func waitForSystemDetail(named systemName: String, in app: XCUIApplication, timeout: TimeInterval = 6) -> Bool { private func waitForSystemDetail(named systemName: String, in app: XCUIApplication, timeout: TimeInterval = 6) -> Bool {
@@ -362,15 +445,12 @@ final class CableUITestsScreenshot: XCTestCase {
if app.otherElements["system-overview"].exists { if app.otherElements["system-overview"].exists {
return true return true
} }
let navBar = app.navigationBars.firstMatch let navBar = app.navigationBars.firstMatch
if navBar.buttons[systemName].exists || navBar.staticTexts[systemName].exists { if navBar.buttons[systemName].exists || navBar.staticTexts[systemName].exists {
return true return true
} }
RunLoop.current.run(until: Date().addingTimeInterval(0.25)) RunLoop.current.run(until: Date().addingTimeInterval(0.25))
} }
return app.otherElements["system-overview"].exists return app.otherElements["system-overview"].exists
} }
@@ -395,32 +475,13 @@ final class CableUITestsScreenshot: XCTestCase {
} }
} }
private func resolveBillOfMaterialsElement(in app: XCUIApplication) -> XCUIElement { // MARK: - Notification Dismissal
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
}
private func dismissNotificationBannersIfNeeded() { private func dismissNotificationBannersIfNeeded() {
let banner = springboard.otherElements.matching(identifier: "NotificationShortLookView").firstMatch // Try multiple times notifications can appear with a delay
if banner.waitForExistence(timeout: 1) { for _ in 0..<3 {
let banner = springboard.otherElements.matching(identifier: "NotificationShortLookView").firstMatch
guard banner.waitForExistence(timeout: 1) else { return }
if banner.isHittable { if banner.isHittable {
banner.swipeUp() banner.swipeUp()
} else { } else {
@@ -432,6 +493,8 @@ final class CableUITestsScreenshot: XCTestCase {
} }
} }
// MARK: - Localized Element Matching
private func candidateStrings(for key: UIStringKey) -> [String] { private func candidateStrings(for key: UIStringKey) -> [String] {
var values = Set<String>() var values = Set<String>()
if let languageCode = Locale.preferredLanguages.first.flatMap({ String($0.prefix(2)) }), if let languageCode = Locale.preferredLanguages.first.flatMap({ String($0.prefix(2)) }),
@@ -444,9 +507,6 @@ final class CableUITestsScreenshot: XCTestCase {
if let others = translations[key]?.values { if let others = translations[key]?.values {
values.formUnion(others) values.formUnion(others)
} }
if key == .settings {
values.insert("gearshape")
}
return Array(values) return Array(values)
} }
@@ -465,139 +525,4 @@ final class CableUITestsScreenshot: XCTestCase {
) )
return query.matching(predicate).firstMatch return query.matching(predicate).firstMatch
} }
private func optionalButton(in query: XCUIElementQuery, for key: UIStringKey) -> XCUIElement? {
let element = button(in: query, for: key)
return element.exists ? element : nil
}
private func tabButton(in app: XCUIApplication, key: UIStringKey) -> XCUIElement {
let tabSpecific = button(in: app.tabBars.buttons, for: key)
if tabSpecific.exists {
return tabSpecific
}
return button(in: app.buttons, for: key)
}
private func navigationBar(in app: XCUIApplication, key: UIStringKey) -> XCUIElement {
let candidates = candidateStrings(for: key)
for candidate in candidates {
let bar = app.navigationBars[candidate]
if bar.exists {
return bar
}
}
return app.navigationBars.element(boundBy: 0)
}
private func tapButtonIfPresent(app: XCUIApplication, key: UIStringKey) {
let candidates = candidateStrings(for: key)
for candidate in candidates {
let button = app.buttons[candidate]
if button.waitForExistence(timeout: 2) {
button.tap()
return
}
}
}
private func openBillOfMaterials(app: XCUIApplication) {
let bomButton = button(in: app.buttons, for: .billOfMaterials)
XCTAssertTrue(bomButton.waitForExistence(timeout: 6))
bomButton.tap()
let bomView = app.otherElements["system-bom-view"]
XCTAssertTrue(bomView.waitForExistence(timeout: 8))
waitForStability(long: true)
}
private func closeBillOfMaterials(app: XCUIApplication) {
tapButtonIfPresent(app: app, key: .close)
}
private func navigateBack(app: XCUIApplication) {
let backButton = app.navigationBars.buttons.element(boundBy: 0)
if backButton.exists {
backButton.tap()
} else {
app.swipeRight()
}
}
private func openSettings(app: XCUIApplication) {
let systemsBar = navigationBar(in: app, key: .systemsTitle)
let settingsButton = button(in: systemsBar.buttons, for: .settings)
if settingsButton.exists {
settingsButton.tap()
} else {
systemsBar.buttons.element(boundBy: 0).tap()
}
}
private func ensureDoNotDisturbEnabled() {
springboard.activate()
let pullStart = springboard.coordinate(withNormalizedOffset: CGVector(dx: 0.98, dy: 0.02))
let pullEnd = springboard.coordinate(withNormalizedOffset: CGVector(dx: 0.98, dy: 0.30))
pullStart.press(forDuration: 0.1, thenDragTo: pullEnd)
let focusTile = springboard.otherElements["Focus"]
let focusButton = springboard.buttons["Focus"]
if focusTile.waitForExistence(timeout: 2) {
focusTile.press(forDuration: 1.0)
} else if focusButton.waitForExistence(timeout: 2) {
focusButton.press(forDuration: 1.0)
} else {
return
}
let dndButton = springboard.buttons["Do Not Disturb"]
if dndButton.waitForExistence(timeout: 1) {
if !dndButton.isSelected {
dndButton.tap()
}
} else {
let dndCell = springboard.cells["Do Not Disturb"]
if dndCell.waitForExistence(timeout: 1) && !dndCell.isSelected {
dndCell.tap()
} else {
let dndLabel = springboard.staticTexts["Do Not Disturb"]
if dndLabel.waitForExistence(timeout: 1) {
dndLabel.tap()
}
}
}
let dismissStart = springboard.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.85))
let dismissEnd = springboard.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.15))
dismissStart.press(forDuration: 0.1, thenDragTo: dismissEnd)
}
private func dismissSystemOverlays() {
let app = XCUIApplication()
let alertButtons = [
"OK", "Allow", "Later", "Not Now", "Close",
"Continue", "Remind Me Later", "Maybe Later",
]
if app.alerts.firstMatch.exists {
handleAlerts(in: app, buttons: alertButtons)
}
if springboard.alerts.firstMatch.exists || springboard.scrollViews.firstMatch.exists {
handleAlerts(in: springboard, buttons: alertButtons + ["Enable Later"])
}
}
private func handleAlerts(in application: XCUIApplication, buttons: [String]) {
for buttonLabel in buttons {
let button = application.buttons[buttonLabel]
if button.waitForExistence(timeout: 0.5) {
button.tap()
return
}
}
let closeButton = application.buttons.matching(NSPredicate(format: "identifier CONTAINS[c] %@", "Close")).firstMatch
if closeButton.exists {
closeButton.tap()
}
}
} }

View File

@@ -1,166 +1,24 @@
//
// CableUITestsScreenshotLaunchTests.swift
// CableUITestsScreenshot
//
// Created by Stefan Lange-Hegermann on 06.10.25.
//
import XCTest import XCTest
final class CableUITestsScreenshotLaunchTests: XCTestCase { final class CableUITestsScreenshotLaunchTests: XCTestCase {
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 resolvedSystemsList(in app: XCUIApplication) -> XCUIElement {
let collection = app.collectionViews["systems-list"]
if collection.waitForExistence(timeout: 6) {
return collection
}
let table = app.tables["systems-list"]
XCTAssertTrue(table.waitForExistence(timeout: 6))
return table
}
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 takeScreenshot(name: String,
lifetime: XCTAttachment.Lifetime = .keepAlways) {
let screenshot = XCUIScreen.main.screenshot()
let attachment = XCTAttachment(screenshot: screenshot)
attachment.name = name
attachment.lifetime = lifetime
add(attachment)
}
override class var runsForEachTargetApplicationUIConfiguration: Bool { override class var runsForEachTargetApplicationUIConfiguration: Bool {
false false
} }
override func setUpWithError() throws { override func setUpWithError() throws {
continueAfterFailure = false continueAfterFailure = false
} }
@MainActor @MainActor
func testOnboardingLoadsView() throws { func testLaunch() throws {
let app = launchApp(arguments: ["--uitest-reset-data"]) let app = XCUIApplication()
takeScreenshot(name: "01-OnboardingSystemsView") app.launchArguments = ["--uitest-reset-data", "--uitest-sample-data"]
app.launch()
let createSystemButton = app.buttons["create-system-button"] let attachment = XCTAttachment(screenshot: app.screenshot())
XCTAssertTrue(createSystemButton.waitForExistence(timeout: 5)) attachment.name = "Launch Screen"
createSystemButton.tap() attachment.lifetime = .keepAlways
takeScreenshot(name: "02-OnboardingLoadsView") add(attachment)
let componentsTab = app.buttons["components-tab"]
XCTAssertTrue(componentsTab.waitForExistence(timeout: 3))
componentsTab.tap()
let libraryCloseButton = app.buttons["library-view-close-button"]
let browseLibraryButton = onboardingSecondaryButton(in: app)
XCTAssertTrue(browseLibraryButton.waitForExistence(timeout: 5))
browseLibraryButton.tap()
XCTAssertTrue(libraryCloseButton.waitForExistence(timeout: 5))
Thread.sleep(forTimeInterval: 10)
takeScreenshot(name: "04-ComponentSelectorView")
libraryCloseButton.tap()
let createComponentButton = onboardingPrimaryButton(in: app)
XCTAssertTrue(createComponentButton.waitForExistence(timeout: 5))
createComponentButton.tap()
takeScreenshot(name: "03-LoadEditorView")
}
func testWithSampleData() throws {
let app = launchApp(arguments: ["--uitest-sample-data"])
let systemsList = resolvedSystemsList(in: app)
let firstSystemCell = systemsList.cells.element(boundBy: 0)
XCTAssertTrue(firstSystemCell.waitForExistence(timeout: 3))
let systemName = firstSystemCell.staticTexts.firstMatch.label
takeScreenshot(name: "05-SystemsWithSampleData")
let rowButton = firstSystemCell.buttons.firstMatch
if rowButton.waitForExistence(timeout: 2) {
rowButton.tap()
} else {
firstSystemCell.tap()
}
let navButton = app.navigationBars.buttons[systemName]
if !navButton.waitForExistence(timeout: 3) {
let coordinate = firstSystemCell.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
coordinate.tap()
XCTAssertTrue(navButton.waitForExistence(timeout: 3))
}
tapComponentsTab(in: app)
let loadsElement = resolvedLoadsList(in: app)
XCTAssertTrue(loadsElement.waitForExistence(timeout: 6))
XCTAssertTrue(loadsElement.cells.firstMatch.waitForExistence(timeout: 3))
Thread.sleep(forTimeInterval: 1)
takeScreenshot(name: "06-AdventureVanLoads")
let bomButton = app.buttons["system-bom-button"]
XCTAssertTrue(bomButton.waitForExistence(timeout: 2))
bomButton.tap()
// let bomView = app.otherElements["system-bom-view"]
// XCTAssertTrue(bomView.waitForExistence(timeout: 3))
//
// Thread.sleep(forTimeInterval: 1)
// takeScreenshot(name: "07-AdventureVanBillOfMaterials")
}
private func tapComponentsTab(in app: XCUIApplication) {
let button = componentsTabButton(in: app)
XCTAssertTrue(button.waitForExistence(timeout: 3))
button.tap()
}
private func onboardingPrimaryButton(in app: XCUIApplication) -> XCUIElement {
let button = app.buttons["create-component-button"]
if button.exists { return button }
return app.buttons["onboarding-primary-button"]
}
private func onboardingSecondaryButton(in app: XCUIApplication) -> XCUIElement {
let button = app.buttons["select-component-button"]
if button.exists { return button }
return app.buttons["onboarding-secondary-button"]
}
private func componentsTabButton(in app: XCUIApplication) -> XCUIElement {
let idButton = app.buttons["components-tab"]
if idButton.exists {
return idButton
}
let labels = ["Components", "Verbraucher", "Componentes", "Composants", "Componenten"]
for label in labels {
let button = app.buttons[label]
if button.exists { return button }
}
return app.tabBars.buttons.element(boundBy: 1)
} }
} }

14
screenshot.config Normal file
View File

@@ -0,0 +1,14 @@
# Screenshot configuration for Cable (VoltPlan)
SCHEME="CableScreenshots"
APP_BUNDLE_ID="app.voltplan.CableApp"
UITEST_BUNDLE_ID="com.yuzuhub.CableUITestsScreenshot"
OUTPUT_DIR="Shots/Screenshots"
LANGUAGES=(de fr en es nl)
# Format: "Simulator Name|Runtime|Slug|Device Type ID"
DEVICE_MATRIX=(
"iPhone 17 Pro Max|26.4|iphone-17-pro-max|com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro-Max"
"iPad Pro Screenshot|26.4|ipad-pro-13-inch-m4|com.apple.CoreSimulator.SimDeviceType.iPad-Pro-13-inch-M4-8GB"
)

View File

@@ -1,102 +1,288 @@
#!/bin/bash #!/bin/bash
set -euo pipefail set -euo pipefail
SCHEME="CableScreenshots" # Kill all child processes on Ctrl-C
trap 'printf "\n\033[31m ✘\033[0m Interrupted — stopping all jobs...\n"; kill 0; exit 130' INT TERM
# ─── Configuration ────────────────────────────────────────────────────────────
# Override these via environment variables, a config file, or CLI argument.
#
# Config file format (shell):
# SCHEME="MyAppScreenshots"
# APP_BUNDLE_ID="com.example.myapp"
# UITEST_BUNDLE_ID="com.example.myapp.UITests"
# LANGUAGES=(en de fr)
# DEVICE_MATRIX=(
# "iPhone 17 Pro Max|26.4|iphone-17-pro-max|com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro-Max"
# )
# OUTPUT_DIR="Shots/Screenshots"
CONFIG_FILE="${1:-./screenshot.config}"
if [[ -f "$CONFIG_FILE" ]]; then
# shellcheck source=/dev/null
source "$CONFIG_FILE"
fi
# Required — must be set in config or environment
SCHEME="${SCHEME:?Set SCHEME in $CONFIG_FILE or environment}"
APP_BUNDLE_ID="${APP_BUNDLE_ID:?Set APP_BUNDLE_ID in $CONFIG_FILE or environment}"
UITEST_BUNDLE_ID="${UITEST_BUNDLE_ID:-}"
# Optional with defaults
OUTPUT_DIR="${OUTPUT_DIR:-Shots/Screenshots}"
DERIVED_DATA="${DERIVED_DATA:-DerivedData-Screenshots}"
RESET_SIMULATOR="${RESET_SIMULATOR:-1}" RESET_SIMULATOR="${RESET_SIMULATOR:-1}"
APP_BUNDLE_ID="${APP_BUNDLE_ID:-app.voltplan.CableApp}" PARALLEL="${PARALLEL:-1}"
UITEST_BUNDLE_ID="${UITEST_BUNDLE_ID:-com.yuzuhub.CableUITestsScreenshot}" VERBOSE="${VERBOSE:-0}"
STATUS_BAR_TIME="${STATUS_BAR_TIME:-9:41}"
if [[ -z "${LANGUAGES+x}" ]]; then
LANGUAGES=(en)
fi
if [[ -z "${DEVICE_MATRIX+x}" ]]; then
DEVICE_MATRIX=(
"iPhone 17 Pro Max|26.4|iphone-17-pro-max|com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro-Max"
)
fi
# ─── Pretty output ────────────────────────────────────────────────────────────
BOLD="\033[1m"
DIM="\033[2m"
GREEN="\033[32m"
RED="\033[31m"
CYAN="\033[36m"
RST="\033[0m"
ok() { printf "${GREEN}${RST} %s\n" "$*"; }
fail() { printf "${RED}${RST} %s\n" "$*"; }
info() { printf "${CYAN} ${RST} %s\n" "$*"; }
step() { printf "\n${BOLD}%s${RST}\n" "$*"; }
is_truthy() { is_truthy() {
case "$1" in case "$1" in
1|true|TRUE|yes|YES|on|ON) return 0 ;; 1|true|TRUE|yes|YES|on|ON) return 0 ;;
0|false|FALSE|no|NO|off|OFF|"") return 1 ;; *) return 1 ;;
*) return 0 ;;
esac esac
} }
DEVICE_MATRIX=( # ─── Dependency check ─────────────────────────────────────────────────────────
"iPhone 17 Pro Max|26.0|iphone-17-pro-max"
"iPad Pro Screenshot|26.0|ipad-pro-13-inch-m4"
)
command -v xcparse >/dev/null 2>&1 || { command -v xcparse >/dev/null 2>&1 || {
echo "xcparse not found. Install with: brew install chargepoint/xcparse/xcparse" >&2 fail "xcparse not found. Install with: brew install chargepoint/xcparse/xcparse"
exit 1 exit 1
} }
# Resolve a simulator UDID for the given device name and optional OS (e.g., 18.1) # ─── Simulator helpers ────────────────────────────────────────────────────────
resolve_udid() { resolve_udid() {
local name="$1"; local os="$2" local name="$1"; local os="$2"
if [[ -n "$os" ]]; then if [[ -n "$os" ]]; then
# Prefer Shutdown state for a clean start xcrun simctl list devices | awk -v n="$name" -v o="$os" -F '[()]' '
xcrun simctl list devices | awk -v n="$name" -v o="$os" -F '[()]' \ /^--.*--$/ { in_section = ($0 ~ o) }
'$0 ~ n && $0 ~ o && /Shutdown/ {print $2; exit}' in_section && $0 ~ n { print $2; exit }
'
else else
xcrun simctl list devices | awk -v n="$name" -F '[()]' \ xcrun simctl list devices | awk -v n="$name" -F '[()]' '
'$0 ~ n && /Shutdown/ {print $2; exit}' $0 ~ n { print $2; exit }
'
fi fi
} }
for device_entry in "${DEVICE_MATRIX[@]}"; do ensure_simulator() {
IFS='|' read -r DEVICE_NAME DEVICE_RUNTIME DEVICE_SLUG <<<"$device_entry" local name="$1"; local runtime="$2"; local device_type="$3"
echo "=== Device: $DEVICE_NAME (runtime: ${DEVICE_RUNTIME:-auto}) ===" local udid
udid=$(resolve_udid "$name" "$runtime")
if [[ -n "$udid" ]]; then
echo "$udid"
return 0
fi
for lang in de fr en es nl; do local runtime_id
echo "Resetting simulator for a clean start..." runtime_id=$(xcrun simctl list runtimes | awk -v r="iOS $runtime" '$0 ~ r {for(i=1;i<=NF;i++) if($i ~ /com\.apple/) {print $i; exit}}')
UDID=$(resolve_udid "$DEVICE_NAME" "$DEVICE_RUNTIME") if [[ -z "$runtime_id" ]]; then
if [[ -z "$UDID" ]]; then fail "Runtime iOS $runtime not found" >&2
UDID=$(xcrun simctl list devices | awk -v n="$DEVICE_NAME" -F '[()]' '$0 ~ n {print $2; exit}') return 1
fi fi
if [[ -z "$UDID" ]]; then
echo "Could not resolve UDID for $DEVICE_NAME" >&2; exit 1
fi
xcrun simctl shutdown "$UDID" || true info "Creating simulator: $name (iOS $runtime)" >&2
if is_truthy "$RESET_SIMULATOR"; then udid=$(xcrun simctl create "$name" "$device_type" "$runtime_id")
xcrun simctl erase "$UDID" echo "$udid"
}
prepare_simulator() {
local udid="$1" lang="$2"
local region
region=$(echo "$lang" | tr '[:lower:]' '[:upper:]')
xcrun simctl boot "$udid" 2>/dev/null || true
# Language & locale
xcrun simctl spawn "$udid" defaults write 'Apple Global Domain' AppleLanguages -array "$lang"
xcrun simctl spawn "$udid" defaults write 'Apple Global Domain' AppleLocale "${lang}_${region}"
# Suppress notifications
xcrun simctl spawn "$udid" defaults write com.apple.springboard DoNotDisturb -bool true 2>/dev/null || true
xcrun simctl spawn "$udid" defaults write com.apple.generativeexperiences.corefollowup \
DateOfLastAppleIntelligenceReadinessCFU -date "2020-01-01T00:00:00Z" 2>/dev/null || true
xcrun simctl spawn "$udid" defaults write com.apple.corefollow DisableFollowUp -bool true 2>/dev/null || true
xcrun simctl spawn "$udid" defaults write com.apple.corefollowup DisableFollowUp -bool true 2>/dev/null || true
# Reboot for language change
xcrun simctl shutdown "$udid" 2>/dev/null || true
xcrun simctl boot "$udid"
# Clean status bar
xcrun simctl status_bar "$udid" override \
--time "$STATUS_BAR_TIME" \
--batteryState charged --batteryLevel 100 \
--wifiBars 3 \
2>/dev/null || true
}
# ─── Build ────────────────────────────────────────────────────────────────────
build_for_testing() {
local device_entry="$1"
IFS='|' read -r dev_name dev_runtime dev_slug dev_type <<<"$device_entry"
local udid
udid=$(ensure_simulator "$dev_name" "$dev_runtime" "$dev_type")
if [[ -z "$udid" ]]; then
fail "Could not resolve or create simulator for $dev_name"; return 1
fi
info "Building $dev_name..."
local log_file="/tmp/shooter-build-${dev_slug}.log"
if xcodebuild build-for-testing \
-scheme "$SCHEME" \
-destination "id=$udid" \
-derivedDataPath "$DERIVED_DATA" \
-quiet \
> "$log_file" 2>&1; then
ok "Build succeeded ($dev_name)"
else
fail "Build failed ($dev_name)"
cat "$log_file"
return 1
fi
}
# ─── Test one language ────────────────────────────────────────────────────────
run_one_language() {
local device_entry="$1"
local lang="$2"
IFS='|' read -r dev_name dev_runtime dev_slug dev_type <<<"$device_entry"
local label="${dev_slug}/${lang}"
local udid
udid=$(resolve_udid "$dev_name" "$dev_runtime")
if [[ -z "$udid" ]]; then
udid=$(ensure_simulator "$dev_name" "$dev_runtime" "$dev_type")
fi
if [[ -z "$udid" ]]; then
fail "[$label] Could not resolve simulator"; return 1
fi
# Reset
xcrun simctl shutdown "$udid" 2>/dev/null || true
if is_truthy "$RESET_SIMULATOR"; then
xcrun simctl erase "$udid"
else
for bundle in "$APP_BUNDLE_ID" ${UITEST_BUNDLE_ID:+"$UITEST_BUNDLE_ID"}; do
xcrun simctl terminate "$udid" "$bundle" 2>/dev/null || true
xcrun simctl uninstall "$udid" "$bundle" 2>/dev/null || true
done
fi
udid=$(resolve_udid "$dev_name" "$dev_runtime")
prepare_simulator "$udid" "$lang"
local bundle="results-${dev_slug}-${lang}.xcresult"
local outdir="${OUTPUT_DIR}/${dev_slug}/$lang"
rm -rf "$bundle" "$outdir"
mkdir -p "$outdir"
local log_file="/tmp/shooter-test-${dev_slug}-${lang}.log"
info "[$label] Testing..."
local test_exit=0
xcodebuild test-without-building \
-scheme "$SCHEME" \
-destination "id=$udid" \
-derivedDataPath "$DERIVED_DATA" \
-resultBundlePath "$bundle" \
> "$log_file" 2>&1 || test_exit=$?
if [[ $test_exit -eq 0 ]]; then
xcparse screenshots "$bundle" "$outdir" > /dev/null 2>&1
local count
count=$(find "$outdir" -name '*.png' 2>/dev/null | wc -l | tr -d ' ')
ok "[$label] ${count} screenshots"
else
fail "[$label] Tests failed"
grep -E '(error:|FAIL|failed)' "$log_file" | head -20
if is_truthy "$VERBOSE"; then
printf "${DIM}"; cat "$log_file"; printf "${RST}"
else else
for bundle in "$APP_BUNDLE_ID" "$UITEST_BUNDLE_ID"; do printf " ${DIM}Full log: $log_file${RST}\n"
if [[ -n "$bundle" ]]; then
xcrun simctl terminate "$UDID" "$bundle" || true
xcrun simctl uninstall "$UDID" "$bundle" || true
fi
done
fi fi
echo "Running screenshots for $lang" fi
region=$(echo "$lang" | tr '[:lower:]' '[:upper:]')
UDID=$(resolve_udid "$DEVICE_NAME" "$DEVICE_RUNTIME") xcrun simctl shutdown "$udid" 2>/dev/null || true
if [[ -z "$UDID" ]]; then return $test_exit
UDID=$(xcrun simctl list devices | awk -v n="$DEVICE_NAME" -F '[()]' '$0 ~ n {print $2; exit}') }
fi
if [[ -z "$UDID" ]]; then
echo "Could not resolve UDID for $DEVICE_NAME" >&2; exit 1
fi
xcrun simctl boot "$UDID" || true # ─── Main ─────────────────────────────────────────────────────────────────────
xcrun simctl spawn "$UDID" defaults write 'Apple Global Domain' AppleLanguages -array "$lang"
xcrun simctl spawn "$UDID" defaults write 'Apple Global Domain' AppleLocale "${lang}_${region}"
xcrun simctl shutdown "$UDID" || true
xcrun simctl boot "$UDID"
xcrun simctl status_bar booted override \
--time "9:41" \
--batteryState charged --batteryLevel 100 \
--wifiBars 3
xcrun simctl spawn "$UDID" defaults write com.apple.springboard DoNotDisturb -bool true
bundle="results-${DEVICE_SLUG}-${lang}.xcresult" step "Configuration"
outdir="Shots/Screenshots/${DEVICE_SLUG}/$lang" info "Scheme: $SCHEME"
rm -rf "$bundle" "$outdir" info "Bundle ID: $APP_BUNDLE_ID"
mkdir -p "$outdir" info "Output: $OUTPUT_DIR"
info "Languages: ${LANGUAGES[*]}"
info "Devices: ${#DEVICE_MATRIX[@]}"
xcodebuild test \ step "Building for testing"
-scheme "$SCHEME" \ for device_entry in "${DEVICE_MATRIX[@]}"; do
-destination "id=$UDID" \ build_for_testing "$device_entry"
-resultBundlePath "$bundle"
xcparse screenshots "$bundle" "$outdir"
echo "Exported screenshots to $outdir"
xcrun simctl shutdown "$UDID" || true
done
done done
step "Running screenshot tests"
total_runs=$((${#DEVICE_MATRIX[@]} * ${#LANGUAGES[@]}))
info "${#DEVICE_MATRIX[@]} devices × ${#LANGUAGES[@]} languages = ${total_runs} runs"
failed=0
if is_truthy "$PARALLEL"; then
info "Devices run in parallel"
pids=()
for device_entry in "${DEVICE_MATRIX[@]}"; do
(
for lang in "${LANGUAGES[@]}"; do
run_one_language "$device_entry" "$lang" || true
done
) &
pids+=($!)
done
for pid in "${pids[@]}"; do
wait "$pid" || failed=$((failed + 1))
done
else
for device_entry in "${DEVICE_MATRIX[@]}"; do
for lang in "${LANGUAGES[@]}"; do
run_one_language "$device_entry" "$lang" || failed=$((failed + 1))
done
done
fi
# ─── Summary ──────────────────────────────────────────────────────────────────
step "Done"
total_screenshots=$(find "$OUTPUT_DIR" -name '*.png' 2>/dev/null | wc -l | tr -d ' ')
if [[ $failed -eq 0 ]]; then
ok "All runs passed — ${total_screenshots} screenshots in ${OUTPUT_DIR}/"
else
fail "${failed} run(s) had errors — ${total_screenshots} screenshots in ${OUTPUT_DIR}/"
printf " ${DIM}Re-run with VERBOSE=1 for full logs${RST}\n"
exit 1
fi