Compare commits
3 Commits
61f340a870
...
d68170bc87
| Author | SHA1 | Date | |
|---|---|---|---|
| d68170bc87 | |||
| b448a1b4f7 | |||
| 8b30fabaa2 |
68
CLAUDE.md
68
CLAUDE.md
@@ -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` (0–44, 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
|
||||||
- 0–4: Overview tab
|
|
||||||
- 5–9: Components tab
|
|
||||||
- 10–14: Batteries tab
|
|
||||||
- 15–19: Chargers tab
|
|
||||||
- 20–24: Systems list
|
|
||||||
- 25–29: Parts Library
|
|
||||||
- 30–34: Load editor (CalculatorView)
|
|
||||||
- 35–39: Battery editor
|
|
||||||
- 40–44: 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
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
559
Cable/ScreenshotPreviews.swift
Normal file
559
Cable/ScreenshotPreviews.swift
Normal 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"))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
dismissNotificationBannersIfNeeded()
|
RunLoop.current.run(until: Date().addingTimeInterval(6))
|
||||||
waitForStability(long: true)
|
|
||||||
dismissNotificationBannersIfNeeded()
|
|
||||||
waitForStability(long: true)
|
|
||||||
dismissNotificationBannersIfNeeded()
|
|
||||||
waitForStability(long: true)
|
|
||||||
dismissNotificationBannersIfNeeded()
|
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +1,6 @@
|
|||||||
//
|
|
||||||
// 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
|
||||||
@@ -57,110 +10,15 @@ final class CableUITestsScreenshotLaunchTests: XCTestCase {
|
|||||||
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
14
screenshot.config
Normal 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"
|
||||||
|
)
|
||||||
328
shooter.sh
328
shooter.sh
@@ -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
|
step "Configuration"
|
||||||
|
info "Scheme: $SCHEME"
|
||||||
|
info "Bundle ID: $APP_BUNDLE_ID"
|
||||||
|
info "Output: $OUTPUT_DIR"
|
||||||
|
info "Languages: ${LANGUAGES[*]}"
|
||||||
|
info "Devices: ${#DEVICE_MATRIX[@]}"
|
||||||
|
|
||||||
bundle="results-${DEVICE_SLUG}-${lang}.xcresult"
|
step "Building for testing"
|
||||||
outdir="Shots/Screenshots/${DEVICE_SLUG}/$lang"
|
for device_entry in "${DEVICE_MATRIX[@]}"; do
|
||||||
rm -rf "$bundle" "$outdir"
|
build_for_testing "$device_entry"
|
||||||
mkdir -p "$outdir"
|
|
||||||
|
|
||||||
xcodebuild test \
|
|
||||||
-scheme "$SCHEME" \
|
|
||||||
-destination "id=$UDID" \
|
|
||||||
-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
|
||||||
|
|||||||
Reference in New Issue
Block a user