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.
- **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).
2. Output goes to `Shots/Screenshots/`.
```bash
./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):
- 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
### How it works
### 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.
- **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())`.
### Test structure
### 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

View File

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

View File

@@ -1,77 +1,21 @@
import Foundation
enum AmazonAffiliate {
private static let fallbackDomain = "www.amazon.com"
private static let fallbackTag: String? = "voltplan-20"
enum VoltPlanRedirect {
private static let baseURL = "https://voltplan.app"
private static let domainsByCountry: [String: String] = [
"US": "www.amazon.com",
"DE": "www.amazon.de",
"FR": "www.amazon.fr",
"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"
]
static func componentURL(id: String) -> URL? {
var components = URLComponents(string: "\(baseURL)/\(id)")
components?.queryItems = [URLQueryItem(name: "src", value: "cable")]
return components?.url
}
// Configure Amazon affiliate tracking IDs by country code.
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? {
static func searchURL(query: String) -> URL? {
guard !query.isEmpty else { return nil }
var components = URLComponents()
components.scheme = "https"
components.host = domain(for: countryCode)
components.path = "/s"
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
var components = URLComponents(string: "\(baseURL)/search")
components?.queryItems = [
URLQueryItem(name: "q", value: query),
URLQueryItem(name: "src", value: "cable"),
]
return components?.url
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,6 @@
import SwiftUI
struct ComponentLibraryItem: Identifiable, Equatable {
struct AffiliateLink: Identifiable, Equatable {
let id: String
let url: URL
let country: String?
}
let id: String
let name: String
let translations: [String: String]
@@ -16,7 +10,6 @@ struct ComponentLibraryItem: Identifiable, Equatable {
let dutyCyclePercent: Double?
let defaultUtilizationFactorPercent: Double?
let iconURL: URL?
let affiliateLinks: [AffiliateLink]
var displayVoltage: Double? {
voltageIn ?? voltageOut
@@ -65,36 +58,10 @@ struct ComponentLibraryItem: Identifiable, Equatable {
return translation(for: locale)
}
var primaryAffiliateLink: AffiliateLink? {
affiliateLink(matching: Locale.current.region?.identifier)
}
func localizedName(for locale: Locale) -> String {
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? {
guard !translations.isEmpty else { return nil }
@@ -289,7 +256,6 @@ final class ComponentLibraryViewModel: ObservableObject {
page += 1
}
let affiliateLinksByComponent = try await fetchAffiliateLinks(for: allRecords.map(\.id))
let mappedItems = allRecords.map { record in
ComponentLibraryItem(
id: record.id,
@@ -300,8 +266,7 @@ final class ComponentLibraryViewModel: ObservableObject {
watt: record.watt,
dutyCyclePercent: record.dutyCycle,
defaultUtilizationFactorPercent: record.defaultUtilizationFactor,
iconURL: iconURL(for: record),
affiliateLinks: affiliateLinksByComponent[record.id] ?? []
iconURL: iconURL(for: record)
)
}
for item in mappedItems {
@@ -314,110 +279,6 @@ final class ComponentLibraryViewModel: ObservableObject {
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? {
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 {

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

View File

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

View File

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

View File

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

View File

@@ -125,32 +125,21 @@ final class CableUITestsScreenshot: XCTestCase {
try super.setUpWithError()
continueAfterFailure = false
XCUIDevice.shared.orientation = .portrait
//ensureDoNotDisturbEnabled()
//dismissSystemOverlays()
}
override func tearDownWithError() throws {
try super.tearDownWithError()
//dismissSystemOverlays()
}
// MARK: - Onboarding Screenshots
@MainActor
func testOnboardingScreenshots() throws {
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()
waitForStability(long: true)
dismissNotificationBannersIfNeeded()
waitForStability(long: true)
dismissNotificationBannersIfNeeded()
waitForStability(long: true)
dismissNotificationBannersIfNeeded()
let createSystemButton = app.buttons["create-system-button"]
XCTAssertTrue(createSystemButton.waitForExistence(timeout: 8))
waitForStability(long: true)
dismissNotificationBannersIfNeeded()
waitForStability(long: true)
waitForStability()
takeScreenshot(named: "01-OnboardingSystemsView")
createSystemButton.tap()
@@ -159,14 +148,15 @@ final class CableUITestsScreenshot: XCTestCase {
XCTAssertTrue(addLoadButton.waitForExistence(timeout: 8))
let browseLibraryButton = button(in: app.buttons, for: .browseLibrary)
XCTAssertTrue(browseLibraryButton.waitForExistence(timeout: 4))
waitForStability()
dismissNotificationBannersIfNeeded()
takeScreenshot(named: "02-OnboardingSystemView")
browseLibraryButton.tap()
let libraryCloseButton = app.buttons["library-view-close-button"]
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")
libraryCloseButton.tap()
@@ -175,20 +165,130 @@ final class CableUITestsScreenshot: XCTestCase {
let newLoadButton = button(in: app.buttons, for: .defaultLoadName)
XCTAssertTrue(newLoadButton.waitForExistence(timeout: 8))
waitForStability(long: true)
waitForStability()
dismissNotificationBannersIfNeeded()
takeScreenshot(named: "03-LoadEditorView")
}
// MARK: - Sample Data Screenshots
@MainActor
func testSampleDataScreenshots() throws {
let app = launchAppWithSampleData()
dismissNotificationBannersIfNeeded()
// Systems list
let systemsList = resolvedSystemsList(in: app)
XCTAssertTrue(systemsList.waitForExistence(timeout: 4))
dismissNotificationBannersIfNeeded()
takeScreenshot(named: "05-SystemsWithSampleData")
// Navigate to first system
openFirstSystem(in: app, systemsList: systemsList)
// Overview tab wait for navigation animation to complete
waitForStability()
dismissNotificationBannersIfNeeded()
takeScreenshot(named: "06-AdventureVanOverview")
// Bill of Materials
let bomElement = resolveBillOfMaterialsElement(in: app)
if !bomElement.waitForExistence(timeout: 6) {
bringElementIntoView(bomElement, in: app)
}
XCTAssertTrue(bomElement.exists)
if !bomElement.isHittable {
bringElementIntoView(bomElement, in: app, requireHittable: true)
}
tapElement(bomElement)
waitForStability()
dismissNotificationBannersIfNeeded()
takeScreenshot(named: "08-BillOfMaterials")
let closeButton = app.buttons["system-bom-close-button"]
XCTAssertTrue(closeButton.waitForExistence(timeout: 6))
closeButton.tap()
// Components tab
tapTab(.componentsTab, in: app)
let loadsList = resolvedLoadsList(in: app)
XCTAssertTrue(loadsList.waitForExistence(timeout: 4))
dismissNotificationBannersIfNeeded()
takeScreenshot(named: "07-AdventureVanLoads")
// Open first load Calculator
let firstLoad = loadsList.cells.element(boundBy: 0)
XCTAssertTrue(firstLoad.waitForExistence(timeout: 2))
firstLoad.tap()
waitForStability()
dismissNotificationBannersIfNeeded()
takeScreenshot(named: "09-AdventureVanCalculator")
// Navigate back to system tabs
app.navigationBars.buttons.element(boundBy: 0).tap()
waitForStability()
// Batteries tab
tapTab(.batteriesTab, in: app)
waitForStability()
dismissNotificationBannersIfNeeded()
takeScreenshot(named: "10-AdventureVanBatteries")
// Open first battery Battery Editor
let batteriesList = resolvedBatteriesList(in: app)
if batteriesList.waitForExistence(timeout: 4) {
let firstBattery = batteriesList.cells.element(boundBy: 0)
if firstBattery.waitForExistence(timeout: 2) {
firstBattery.tap()
waitForStability()
dismissNotificationBannersIfNeeded()
takeScreenshot(named: "11-BatteryEditor")
// Navigate back
app.navigationBars.buttons.element(boundBy: 0).tap()
waitForStability()
}
}
// Chargers tab
tapTab(.chargersTab, in: app)
waitForStability()
dismissNotificationBannersIfNeeded()
takeScreenshot(named: "12-AdventureVanChargers")
// Open first charger Charger Editor
let chargersList = resolvedChargersList(in: app)
if chargersList.waitForExistence(timeout: 4) {
let firstCharger = chargersList.cells.element(boundBy: 0)
if firstCharger.waitForExistence(timeout: 2) {
firstCharger.tap()
waitForStability()
dismissNotificationBannersIfNeeded()
takeScreenshot(named: "13-ChargerEditor")
}
}
}
// MARK: - App Launch
private func launchApp(arguments: [String]) -> XCUIApplication {
let app = XCUIApplication()
var launchArguments = ["--uitest-reset-data"]
launchArguments.append(contentsOf: arguments)
app.launchArguments = launchArguments
app.launch()
return app
}
private func launchAppWithSampleData() -> XCUIApplication {
let app = XCUIApplication()
app.launchArguments = ["--uitest-reset-data", "--uitest-sample-data"]
app.launch()
return app
}
let systemsList = resolvedSystemsList(in: app)
XCTAssertTrue(systemsList.waitForExistence(timeout: 4))
takeScreenshot(named: "05-SystemsWithSampleData")
// MARK: - Navigation Helpers
private func openFirstSystem(in app: XCUIApplication, systemsList: XCUIElement) {
let firstSystemCell = systemsList.cells.element(boundBy: 0)
XCTAssertTrue(firstSystemCell.waitForExistence(timeout: 2))
let systemName = firstSystemCell.staticTexts.firstMatch.label
@@ -206,69 +306,41 @@ final class CableUITestsScreenshot: XCTestCase {
detailVisible = waitForSystemDetail(named: systemName, in: app)
}
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 {
let app = XCUIApplication()
var launchArguments = ["--uitest-reset-data"]
launchArguments.append(contentsOf: arguments)
app.launchArguments = launchArguments
app.launch()
//dismissSystemOverlays()
return app
private func tapTab(_ key: UIStringKey, in app: XCUIApplication) {
let identifierMap: [UIStringKey: String] = [
.overviewTab: "overview-tab",
.componentsTab: "components-tab",
.batteriesTab: "batteries-tab",
.chargersTab: "chargers-tab",
]
// Use .matching + .firstMatch to avoid "multiple matches" error
// with iOS 26 floating tab bar (which creates duplicate elements)
if let identifier = identifierMap[key] {
let tabButton = app.buttons.matching(identifier: identifier).firstMatch
if tabButton.waitForExistence(timeout: 3) {
tapElement(tabButton)
return
}
}
let tabButton = button(in: app.buttons, for: key)
XCTAssertTrue(tabButton.waitForExistence(timeout: 3))
tapElement(tabButton)
}
private func tapElement(_ element: XCUIElement) {
if element.isHittable {
element.tap()
} else {
element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
}
}
// MARK: - Element Resolution
private func resolvedSystemsList(in app: XCUIApplication) -> XCUIElement {
let collection = app.collectionViews["systems-list"]
if collection.waitForExistence(timeout: 6) {
@@ -299,6 +371,62 @@ final class CableUITestsScreenshot: XCTestCase {
return table
}
private func resolvedBatteriesList(in app: XCUIApplication) -> XCUIElement {
let collection = app.collectionViews["batteries-list"]
if collection.waitForExistence(timeout: 6) {
return collection
}
let table = app.tables["batteries-list"]
if table.waitForExistence(timeout: 6) {
return table
}
// Fallback: any list on screen
if app.collectionViews.firstMatch.waitForExistence(timeout: 2) {
return app.collectionViews.firstMatch
}
return app.tables.firstMatch
}
private func resolvedChargersList(in app: XCUIApplication) -> XCUIElement {
let collection = app.collectionViews["chargers-list"]
if collection.waitForExistence(timeout: 6) {
return collection
}
let table = app.tables["chargers-list"]
if table.waitForExistence(timeout: 6) {
return table
}
if app.collectionViews.firstMatch.waitForExistence(timeout: 2) {
return app.collectionViews.firstMatch
}
return app.tables.firstMatch
}
private func resolveBillOfMaterialsElement(in app: XCUIApplication) -> XCUIElement {
let identifier = "system-bom-button"
let buttonByIdentifier = app.buttons.matching(identifier: identifier).firstMatch
if buttonByIdentifier.exists { return buttonByIdentifier }
let elementByIdentifier = app.otherElements.matching(identifier: identifier).firstMatch
if elementByIdentifier.exists { return elementByIdentifier }
let candidates = candidateStrings(for: .billOfMaterials)
for candidate in candidates {
let button = app.buttons[candidate]
if button.exists { return button }
let other = app.otherElements[candidate]
if other.exists { return other }
}
return buttonByIdentifier
}
// MARK: - Screenshots & Stability
private func takeScreenshot(named name: String) {
let screenshot = XCUIScreen.main.screenshot()
let attachment = XCTAttachment(screenshot: screenshot)
@@ -308,52 +436,7 @@ final class CableUITestsScreenshot: XCTestCase {
}
private func waitForStability(long: Bool = false) {
RunLoop.current.run(until: Date().addingTimeInterval(long ? 5.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
RunLoop.current.run(until: Date().addingTimeInterval(long ? 3.0 : 0.5))
}
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 {
return true
}
let navBar = app.navigationBars.firstMatch
if navBar.buttons[systemName].exists || navBar.staticTexts[systemName].exists {
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.25))
}
return app.otherElements["system-overview"].exists
}
@@ -395,32 +475,13 @@ final class CableUITestsScreenshot: XCTestCase {
}
}
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: - Notification Dismissal
private func dismissNotificationBannersIfNeeded() {
let banner = springboard.otherElements.matching(identifier: "NotificationShortLookView").firstMatch
if banner.waitForExistence(timeout: 1) {
// Try multiple times notifications can appear with a delay
for _ in 0..<3 {
let banner = springboard.otherElements.matching(identifier: "NotificationShortLookView").firstMatch
guard banner.waitForExistence(timeout: 1) else { return }
if banner.isHittable {
banner.swipeUp()
} else {
@@ -432,6 +493,8 @@ final class CableUITestsScreenshot: XCTestCase {
}
}
// MARK: - Localized Element Matching
private func candidateStrings(for key: UIStringKey) -> [String] {
var values = Set<String>()
if let languageCode = Locale.preferredLanguages.first.flatMap({ String($0.prefix(2)) }),
@@ -444,9 +507,6 @@ final class CableUITestsScreenshot: XCTestCase {
if let others = translations[key]?.values {
values.formUnion(others)
}
if key == .settings {
values.insert("gearshape")
}
return Array(values)
}
@@ -465,139 +525,4 @@ final class CableUITestsScreenshot: XCTestCase {
)
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
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 {
false
}
override func setUpWithError() throws {
continueAfterFailure = false
}
@MainActor
func testOnboardingLoadsView() throws {
let app = launchApp(arguments: ["--uitest-reset-data"])
takeScreenshot(name: "01-OnboardingSystemsView")
func testLaunch() throws {
let app = XCUIApplication()
app.launchArguments = ["--uitest-reset-data", "--uitest-sample-data"]
app.launch()
let createSystemButton = app.buttons["create-system-button"]
XCTAssertTrue(createSystemButton.waitForExistence(timeout: 5))
createSystemButton.tap()
takeScreenshot(name: "02-OnboardingLoadsView")
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)
let attachment = XCTAttachment(screenshot: app.screenshot())
attachment.name = "Launch Screen"
attachment.lifetime = .keepAlways
add(attachment)
}
}

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
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}"
APP_BUNDLE_ID="${APP_BUNDLE_ID:-app.voltplan.CableApp}"
UITEST_BUNDLE_ID="${UITEST_BUNDLE_ID:-com.yuzuhub.CableUITestsScreenshot}"
PARALLEL="${PARALLEL:-1}"
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() {
case "$1" in
1|true|TRUE|yes|YES|on|ON) return 0 ;;
0|false|FALSE|no|NO|off|OFF|"") return 1 ;;
*) return 0 ;;
*) return 1 ;;
esac
}
DEVICE_MATRIX=(
"iPhone 17 Pro Max|26.0|iphone-17-pro-max"
"iPad Pro Screenshot|26.0|ipad-pro-13-inch-m4"
)
# ─── Dependency check ─────────────────────────────────────────────────────────
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
}
# Resolve a simulator UDID for the given device name and optional OS (e.g., 18.1)
# ─── Simulator helpers ────────────────────────────────────────────────────────
resolve_udid() {
local name="$1"; local os="$2"
if [[ -n "$os" ]]; then
# Prefer Shutdown state for a clean start
xcrun simctl list devices | awk -v n="$name" -v o="$os" -F '[()]' \
'$0 ~ n && $0 ~ o && /Shutdown/ {print $2; exit}'
xcrun simctl list devices | awk -v n="$name" -v o="$os" -F '[()]' '
/^--.*--$/ { in_section = ($0 ~ o) }
in_section && $0 ~ n { print $2; exit }
'
else
xcrun simctl list devices | awk -v n="$name" -F '[()]' \
'$0 ~ n && /Shutdown/ {print $2; exit}'
xcrun simctl list devices | awk -v n="$name" -F '[()]' '
$0 ~ n { print $2; exit }
'
fi
}
for device_entry in "${DEVICE_MATRIX[@]}"; do
IFS='|' read -r DEVICE_NAME DEVICE_RUNTIME DEVICE_SLUG <<<"$device_entry"
echo "=== Device: $DEVICE_NAME (runtime: ${DEVICE_RUNTIME:-auto}) ==="
ensure_simulator() {
local name="$1"; local runtime="$2"; local device_type="$3"
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
echo "Resetting simulator for a clean start..."
UDID=$(resolve_udid "$DEVICE_NAME" "$DEVICE_RUNTIME")
if [[ -z "$UDID" ]]; then
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
local runtime_id
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}}')
if [[ -z "$runtime_id" ]]; then
fail "Runtime iOS $runtime not found" >&2
return 1
fi
xcrun simctl shutdown "$UDID" || true
if is_truthy "$RESET_SIMULATOR"; then
xcrun simctl erase "$UDID"
info "Creating simulator: $name (iOS $runtime)" >&2
udid=$(xcrun simctl create "$name" "$device_type" "$runtime_id")
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
for bundle in "$APP_BUNDLE_ID" "$UITEST_BUNDLE_ID"; do
if [[ -n "$bundle" ]]; then
xcrun simctl terminate "$UDID" "$bundle" || true
xcrun simctl uninstall "$UDID" "$bundle" || true
fi
done
printf " ${DIM}Full log: $log_file${RST}\n"
fi
echo "Running screenshots for $lang"
region=$(echo "$lang" | tr '[:lower:]' '[:upper:]')
fi
UDID=$(resolve_udid "$DEVICE_NAME" "$DEVICE_RUNTIME")
if [[ -z "$UDID" ]]; then
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 shutdown "$udid" 2>/dev/null || true
return $test_exit
}
xcrun simctl boot "$UDID" || true
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
# ─── Main ─────────────────────────────────────────────────────────────────────
bundle="results-${DEVICE_SLUG}-${lang}.xcresult"
outdir="Shots/Screenshots/${DEVICE_SLUG}/$lang"
rm -rf "$bundle" "$outdir"
mkdir -p "$outdir"
step "Configuration"
info "Scheme: $SCHEME"
info "Bundle ID: $APP_BUNDLE_ID"
info "Output: $OUTPUT_DIR"
info "Languages: ${LANGUAGES[*]}"
info "Devices: ${#DEVICE_MATRIX[@]}"
xcodebuild test \
-scheme "$SCHEME" \
-destination "id=$UDID" \
-resultBundlePath "$bundle"
xcparse screenshots "$bundle" "$outdir"
echo "Exported screenshots to $outdir"
xcrun simctl shutdown "$UDID" || true
done
step "Building for testing"
for device_entry in "${DEVICE_MATRIX[@]}"; do
build_for_testing "$device_entry"
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