Compare commits
4 Commits
88e79d79bf
...
165827c4d4
| Author | SHA1 | Date | |
|---|---|---|---|
| 165827c4d4 | |||
| af0687c362 | |||
| eb9efee9af | |||
| 38183f5282 |
13
.gitignore
vendored
13
.gitignore
vendored
@@ -4,4 +4,15 @@ xcshareddata
|
||||
Vendor
|
||||
Shots/Framed
|
||||
Shots/Screenshots
|
||||
*.xcresult
|
||||
*.xcresult
|
||||
|
||||
# Build products
|
||||
DerivedData*/
|
||||
build/
|
||||
*.ipa
|
||||
*.dSYM.zip
|
||||
*.xcuserstate
|
||||
*.xcuserdatad
|
||||
|
||||
# Credentials
|
||||
PocketBase-LLM/pb-credentials.json
|
||||
|
||||
@@ -418,7 +418,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Cable/Cable.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 87;
|
||||
CURRENT_PROJECT_VERSION = 88;
|
||||
DEVELOPMENT_TEAM = RE4FXQ754N;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -436,7 +436,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.8.0;
|
||||
MARKETING_VERSION = 1.8.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.voltplan.CableApp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@@ -454,7 +454,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Cable/Cable.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 87;
|
||||
CURRENT_PROJECT_VERSION = 88;
|
||||
DEVELOPMENT_TEAM = RE4FXQ754N;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -472,7 +472,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.8.0;
|
||||
MARKETING_VERSION = 1.8.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.voltplan.CableApp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
||||
@@ -117,10 +117,6 @@
|
||||
"bom.quantity.terminal.badge" = "%1$d× · %2$@";
|
||||
"bom.quantity.cable.badge" = "%1$.1f %2$@ · %3$@";
|
||||
"bom.quantity.single.badge" = "1× • %@";
|
||||
"cable.pro.privacy.label" = "Privacy";
|
||||
"cable.pro.privacy.url" = "https://voltplan.app/privacy";
|
||||
"cable.pro.terms.label" = "Terms";
|
||||
"cable.pro.terms.url" = "https://voltplan.app/terms";
|
||||
"calculator.advanced.duty_cycle.helper" = "Percentage of each active session where the load actually draws power.";
|
||||
"calculator.advanced.duty_cycle.title" = "Duty Cycle";
|
||||
"calculator.advanced.section.title" = "Advanced Settings";
|
||||
@@ -199,7 +195,7 @@
|
||||
"loads.overview.metric.current" = "Total Current";
|
||||
"loads.overview.metric.power" = "Total Power";
|
||||
"loads.overview.status.missing_details.banner" = "Finish configuring your loads";
|
||||
"loads.overview.status.missing_details.message" = "Enter cable length and wire size for %d %@ to see accurate recommendations.";
|
||||
"loads.overview.status.missing_details.message" = "Enter cable length and current draw for %d %@ so Cable can size the wiring.";
|
||||
"loads.overview.status.missing_details.plural" = "loads";
|
||||
"loads.overview.status.missing_details.singular" = "load";
|
||||
"loads.overview.status.missing_details.title" = "Missing load details";
|
||||
@@ -289,47 +285,7 @@
|
||||
"settings.pro.manage.url" = "https://apps.apple.com/account/subscriptions";
|
||||
"settings.pro.day.one" = "%@ day";
|
||||
"settings.pro.day.other" = "%@ days";
|
||||
"cable.pro.terms.label" = "Terms";
|
||||
"cable.pro.privacy.label" = "Privacy";
|
||||
"cable.pro.terms.url" = "https://voltplan.app/terms";
|
||||
"cable.pro.privacy.url" = "https://voltplan.app/privacy";
|
||||
"cable.pro.paywall.title" = "Cable PRO";
|
||||
"cable.pro.paywall.subtitle" = "Cable PRO enables more configuration options for loads, batteries and chargers.";
|
||||
"cable.pro.feature.dutyCycle" = "Duty-cycle aware cable calculators";
|
||||
"cable.pro.feature.batteryCapacity" = "Configure usable battery capacity";
|
||||
"cable.pro.feature.usageBased" = "Usage based calculations";
|
||||
"cable.pro.button.unlock" = "Unlock Now";
|
||||
"cable.pro.button.freeTrial" = "Start Free Trial";
|
||||
"cable.pro.button.unlocked" = "Unlocked";
|
||||
"cable.pro.restore.button" = "Restore Purchases";
|
||||
"cable.pro.alert.success.title" = "Cable PRO Unlocked";
|
||||
"cable.pro.alert.success.body" = "Thanks for supporting Cable PRO!";
|
||||
"cable.pro.alert.pending.title" = "Purchase Pending";
|
||||
"cable.pro.alert.pending.body" = "Your purchase is awaiting approval.";
|
||||
"cable.pro.alert.restored.title" = "Purchases Restored";
|
||||
"cable.pro.alert.restored.body" = "Your purchases are available again.";
|
||||
"cable.pro.alert.error.title" = "Purchase Failed";
|
||||
"cable.pro.alert.error.generic" = "Something went wrong. Please try again.";
|
||||
"generic.ok" = "OK";
|
||||
"cable.pro.trial.badge" = "Includes a %@ free trial";
|
||||
"cable.pro.subscription.renews" = "Renews %@.";
|
||||
"cable.pro.subscription.trialThenRenews" = "Free trial, then renews %@.";
|
||||
"cable.pro.duration.day.singular" = "every day";
|
||||
"cable.pro.duration.day.plural" = "every %@ days";
|
||||
"cable.pro.duration.week.singular" = "every week";
|
||||
"cable.pro.duration.week.plural" = "every %@ weeks";
|
||||
"cable.pro.duration.month.singular" = "every month";
|
||||
"cable.pro.duration.month.plural" = "every %@ months";
|
||||
"cable.pro.duration.year.singular" = "every year";
|
||||
"cable.pro.duration.year.plural" = "every %@ years";
|
||||
"cable.pro.trial.duration.day.singular" = "%@-day";
|
||||
"cable.pro.trial.duration.day.plural" = "%@-day";
|
||||
"cable.pro.trial.duration.week.singular" = "%@-week";
|
||||
"cable.pro.trial.duration.week.plural" = "%@-week";
|
||||
"cable.pro.trial.duration.month.singular" = "%@-month";
|
||||
"cable.pro.trial.duration.month.plural" = "%@-month";
|
||||
"cable.pro.trial.duration.year.singular" = "%@-year";
|
||||
"cable.pro.trial.duration.year.plural" = "%@-year";
|
||||
|
||||
// MARK: - PDF Overview Export
|
||||
"overview.pdf.loads.section" = "Loads";
|
||||
@@ -388,3 +344,10 @@
|
||||
"overview.share.diagram" = "Wiring Diagram";
|
||||
"overview.share.pdf" = "Full Report (PDF)";
|
||||
"overview.share.diagram.error" = "Could not generate diagram. Check your internet connection.";
|
||||
|
||||
"editor.system.voltage_drop.label" = "Voltage drop budget";
|
||||
"editor.system.voltage_drop.hint.critical" = "3 % — for critical circuits like navigation, bilge pumps and electronics.";
|
||||
"editor.system.voltage_drop.hint.standard" = "5 % — balanced default for mixed installations.";
|
||||
"editor.system.voltage_drop.hint.noncritical" = "10 % — for non-critical loads such as cabin lighting.";
|
||||
"settings.voltage_drop.label" = "Voltage drop budget";
|
||||
"settings.voltage_drop.footnote" = "Used by the calculator and for new systems. Existing systems keep their own budget.";
|
||||
|
||||
@@ -16,6 +16,7 @@ class CableCalculator: ObservableObject {
|
||||
@Published var loadName: String = String(localized: "default.load.name", comment: "Default placeholder name for a load")
|
||||
@Published var dutyCyclePercent: Double = 100.0
|
||||
@Published var dailyUsageHours: Double = 24.0
|
||||
@Published var maxVoltageDropPercent: Double = ElectricalCalculations.defaultMaxVoltageDropPercent
|
||||
|
||||
var calculatedPower: Double {
|
||||
voltage * current
|
||||
@@ -38,38 +39,42 @@ class CableCalculator: ObservableObject {
|
||||
length: length,
|
||||
current: current,
|
||||
voltage: voltage,
|
||||
unitSystem: unitSystem
|
||||
unitSystem: unitSystem,
|
||||
maxVoltageDropPercent: maxVoltageDropPercent
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
func crossSection(for unitSystem: UnitSystem) -> Double {
|
||||
recommendedCrossSection(for: unitSystem)
|
||||
}
|
||||
|
||||
|
||||
func voltageDrop(for unitSystem: UnitSystem) -> Double {
|
||||
ElectricalCalculations.voltageDrop(
|
||||
length: length,
|
||||
current: current,
|
||||
voltage: voltage,
|
||||
unitSystem: unitSystem
|
||||
unitSystem: unitSystem,
|
||||
maxVoltageDropPercent: maxVoltageDropPercent
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
func voltageDropPercentage(for unitSystem: UnitSystem) -> Double {
|
||||
ElectricalCalculations.voltageDropPercentage(
|
||||
length: length,
|
||||
current: current,
|
||||
voltage: voltage,
|
||||
unitSystem: unitSystem
|
||||
unitSystem: unitSystem,
|
||||
maxVoltageDropPercent: maxVoltageDropPercent
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
func powerLoss(for unitSystem: UnitSystem) -> Double {
|
||||
ElectricalCalculations.powerLoss(
|
||||
length: length,
|
||||
current: current,
|
||||
voltage: voltage,
|
||||
unitSystem: unitSystem
|
||||
unitSystem: unitSystem,
|
||||
maxVoltageDropPercent: maxVoltageDropPercent
|
||||
)
|
||||
}
|
||||
|
||||
@@ -92,6 +97,9 @@ class ElectricalSystem {
|
||||
var colorName: String = "blue"
|
||||
var targetRuntimeHours: Double?
|
||||
var targetChargeTimeHours: Double?
|
||||
/// Share of system voltage that may be lost in a cable. Stored per system so a critical
|
||||
/// 12 V circuit can be planned at 3 % while a cabin light stays at 10 %.
|
||||
var maxVoltageDropPercent: Double = ElectricalCalculations.defaultMaxVoltageDropPercent
|
||||
|
||||
init(
|
||||
name: String,
|
||||
@@ -99,7 +107,8 @@ class ElectricalSystem {
|
||||
iconName: String = "building.2",
|
||||
colorName: String = "blue",
|
||||
targetRuntimeHours: Double? = nil,
|
||||
targetChargeTimeHours: Double? = nil
|
||||
targetChargeTimeHours: Double? = nil,
|
||||
maxVoltageDropPercent: Double = ElectricalCalculations.defaultMaxVoltageDropPercent
|
||||
) {
|
||||
self.name = name
|
||||
self.location = location
|
||||
@@ -108,6 +117,7 @@ class ElectricalSystem {
|
||||
self.colorName = colorName
|
||||
self.targetRuntimeHours = targetRuntimeHours
|
||||
self.targetChargeTimeHours = targetChargeTimeHours
|
||||
self.maxVoltageDropPercent = maxVoltageDropPercent
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -345,10 +345,14 @@ struct CalculatorView: View {
|
||||
.sheet(isPresented: $showingLoadEditor, content: loadEditorSheet)
|
||||
.sheet(item: $presentedAffiliateLink, content: billOfMaterialsSheet(info:))
|
||||
.onAppear {
|
||||
calculator.maxVoltageDropPercent = unitSettings.voltageDropTargetPercent
|
||||
if let savedLoad = savedLoad {
|
||||
loadConfiguration(from: savedLoad)
|
||||
}
|
||||
}
|
||||
.onChange(of: unitSettings.voltageDropTargetPercent) { _, newValue in
|
||||
calculator.maxVoltageDropPercent = newValue
|
||||
}
|
||||
.onChange(of: completedItemIDs) { _, _ in
|
||||
persistCompletedItems()
|
||||
}
|
||||
@@ -701,7 +705,7 @@ struct CalculatorView: View {
|
||||
Button(action: {}) {
|
||||
Text(String(format: "%.1fV (%.1f%%)", calculator.voltageDrop(for: unitSettings.unitSystem), calculator.voltageDropPercentage(for: unitSettings.unitSystem)))
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(calculator.voltageDropPercentage(for: unitSettings.unitSystem) > 5 ? .orange : .primary)
|
||||
.foregroundColor(calculator.voltageDropPercentage(for: unitSettings.unitSystem) > calculator.maxVoltageDropPercent ? .orange : .primary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
import Foundation
|
||||
|
||||
struct ElectricalCalculations {
|
||||
private static let maxVoltageDropFraction = 0.05
|
||||
/// Default share of system voltage that may be lost in the cable. ABYC E-11 works with
|
||||
/// 3 % for critical circuits and 10 % for non-critical ones; 5 % is the middle ground.
|
||||
static let defaultMaxVoltageDropPercent = 5.0
|
||||
private static let copperResistivity = 0.017 // Ω⋅mm²/m
|
||||
private static let feetToMeters = 0.3048
|
||||
|
||||
@@ -29,27 +31,87 @@ struct ElectricalCalculations {
|
||||
300, 350, 400, 450, 500, 600, 700, 800,
|
||||
]
|
||||
|
||||
/// Ampacity of the AWG ladder in amps. Source: ABYC E-11 Table 6A, 105 °C insulation
|
||||
/// rating, outside engine spaces, single conductors not bundled. Index-aligned with
|
||||
/// `standardAWG`. AWG 20 is not covered by the table, so it is never selected on
|
||||
/// ampacity grounds.
|
||||
private static let awgAmpacity: [Double] = [
|
||||
0, 20, 25, 35, 45, 60, 80, 120, 160, 210, 245, 285, 330, 385, 445,
|
||||
]
|
||||
|
||||
/// Ampacity of the metric ladder in amps. Source: ISO 13297 Table A1, 105 °C insulation
|
||||
/// rating, bundles up to three conductors. The standard tabulates 0.75–150 mm²; larger
|
||||
/// cross-sections inherit the 150 mm² value as a conservative lower bound. Index-aligned
|
||||
/// with `standardMetricCrossSections`.
|
||||
private static let metricAmpacity: [Double] = [
|
||||
16, 20, 25, 35, 45, 60, 90, 130, 170, 210, 270, 330, 390, 450, 475,
|
||||
475, 475, 475, 475, 475, 475,
|
||||
]
|
||||
|
||||
/// Current-carrying capacity of a cable in amps. `crossSection` is mm² for `.metric`
|
||||
/// and an AWG value (see `standardAWG`) for `.imperial`. Cross-sections between two
|
||||
/// standard sizes are rated with the smaller neighbour.
|
||||
static func ampacity(forCrossSection crossSection: Double, unitSystem: UnitSystem) -> Double {
|
||||
if unitSystem == .imperial {
|
||||
guard let index = standardAWG.firstIndex(of: Int(crossSection)) else { return 0 }
|
||||
return awgAmpacity[index]
|
||||
}
|
||||
var rating = 0.0
|
||||
for (index, area) in standardMetricCrossSections.enumerated() where area <= crossSection {
|
||||
rating = metricAmpacity[index]
|
||||
}
|
||||
return rating
|
||||
}
|
||||
|
||||
/// Nearest AWG size for a stored metric cross-section. Used to display mm² values in
|
||||
/// AWG; picks the closest ladder entry, not the next larger one.
|
||||
static func nearestAWG(forCrossSectionMM2 crossSection: Double) -> Double {
|
||||
guard crossSection > 0 else { return 0 }
|
||||
var bestAWG = standardAWG.first ?? 20
|
||||
var bestDelta = Double.greatestFiniteMagnitude
|
||||
for (index, area) in awgCrossSections.enumerated() {
|
||||
let delta = abs(area - crossSection)
|
||||
if delta < bestDelta {
|
||||
bestDelta = delta
|
||||
bestAWG = standardAWG[index]
|
||||
}
|
||||
}
|
||||
return Double(bestAWG)
|
||||
}
|
||||
|
||||
/// Length must always be in meters. unitSystem controls the output format (mm² vs AWG).
|
||||
///
|
||||
/// The recommendation satisfies two independent criteria, whichever one demands more
|
||||
/// copper: the voltage-drop budget, and an ampacity at least as high as the fuse that
|
||||
/// protects the circuit. The latter follows ABYC E-11, which requires the rating of the
|
||||
/// overcurrent protection device to not exceed the ampacity of the conductor it protects.
|
||||
static func recommendedCrossSection(
|
||||
length: Double,
|
||||
current: Double,
|
||||
voltage: Double,
|
||||
unitSystem: UnitSystem
|
||||
unitSystem: UnitSystem,
|
||||
maxVoltageDropPercent: Double = defaultMaxVoltageDropPercent
|
||||
) -> Double {
|
||||
let lengthInMeters = length
|
||||
let maxVoltageDrop = voltage * maxVoltageDropFraction
|
||||
let maxVoltageDrop = voltage * (maxVoltageDropPercent / 100)
|
||||
let minimumCrossSection = guardAgainstZero(maxVoltageDrop) {
|
||||
(2 * current * lengthInMeters * copperResistivity) / maxVoltageDrop
|
||||
}
|
||||
let requiredAmpacity = recommendedFuse(forCurrent: current)
|
||||
|
||||
if unitSystem == .imperial {
|
||||
for (index, crossSection) in awgCrossSections.enumerated() where crossSection >= minimumCrossSection {
|
||||
for (index, crossSection) in awgCrossSections.enumerated()
|
||||
where crossSection >= minimumCrossSection && awgAmpacity[index] >= requiredAmpacity {
|
||||
return Double(standardAWG[index])
|
||||
}
|
||||
return Double(standardAWG.last ?? 0)
|
||||
} else {
|
||||
return standardMetricCrossSections.first { $0 >= max(standardMetricCrossSections.first ?? 0.75, minimumCrossSection) }
|
||||
?? standardMetricCrossSections.last ?? 0.75
|
||||
let minimumArea = max(standardMetricCrossSections.first ?? 0.75, minimumCrossSection)
|
||||
for (index, crossSection) in standardMetricCrossSections.enumerated()
|
||||
where crossSection >= minimumArea && metricAmpacity[index] >= requiredAmpacity {
|
||||
return crossSection
|
||||
}
|
||||
return standardMetricCrossSections.last ?? 0.75
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,13 +120,15 @@ struct ElectricalCalculations {
|
||||
current: Double,
|
||||
voltage: Double,
|
||||
unitSystem: UnitSystem,
|
||||
crossSection: Double? = nil
|
||||
crossSection: Double? = nil,
|
||||
maxVoltageDropPercent: Double = defaultMaxVoltageDropPercent
|
||||
) -> Double {
|
||||
let selectedCrossSection = crossSection ?? recommendedCrossSection(
|
||||
length: length,
|
||||
current: current,
|
||||
voltage: voltage,
|
||||
unitSystem: unitSystem
|
||||
unitSystem: unitSystem,
|
||||
maxVoltageDropPercent: maxVoltageDropPercent
|
||||
)
|
||||
|
||||
let lengthInMeters = length
|
||||
@@ -84,7 +148,8 @@ struct ElectricalCalculations {
|
||||
current: Double,
|
||||
voltage: Double,
|
||||
unitSystem: UnitSystem,
|
||||
crossSection: Double? = nil
|
||||
crossSection: Double? = nil,
|
||||
maxVoltageDropPercent: Double = defaultMaxVoltageDropPercent
|
||||
) -> Double {
|
||||
guard voltage != 0 else { return 0 }
|
||||
let drop = voltageDrop(
|
||||
@@ -92,7 +157,8 @@ struct ElectricalCalculations {
|
||||
current: current,
|
||||
voltage: voltage,
|
||||
unitSystem: unitSystem,
|
||||
crossSection: crossSection
|
||||
crossSection: crossSection,
|
||||
maxVoltageDropPercent: maxVoltageDropPercent
|
||||
)
|
||||
return (drop / voltage) * 100
|
||||
}
|
||||
@@ -102,14 +168,16 @@ struct ElectricalCalculations {
|
||||
current: Double,
|
||||
voltage: Double,
|
||||
unitSystem: UnitSystem,
|
||||
crossSection: Double? = nil
|
||||
crossSection: Double? = nil,
|
||||
maxVoltageDropPercent: Double = defaultMaxVoltageDropPercent
|
||||
) -> Double {
|
||||
let drop = voltageDrop(
|
||||
length: length,
|
||||
current: current,
|
||||
voltage: voltage,
|
||||
unitSystem: unitSystem,
|
||||
crossSection: crossSection
|
||||
crossSection: crossSection,
|
||||
maxVoltageDropPercent: maxVoltageDropPercent
|
||||
)
|
||||
return current * drop
|
||||
}
|
||||
|
||||
32
Cable/Loads/LoadCableSync.swift
Normal file
32
Cable/Loads/LoadCableSync.swift
Normal file
@@ -0,0 +1,32 @@
|
||||
import Foundation
|
||||
|
||||
/// The cross-section of a load is derived, never entered by the user: it follows from length,
|
||||
/// current, voltage and the system's voltage-drop budget. Whenever one of those inputs changes,
|
||||
/// the stored value has to follow — otherwise the app displays a size it no longer stands behind
|
||||
/// and would have to ask the user to fix something only the app can compute.
|
||||
enum LoadCableSync {
|
||||
/// Sets every load's cross-section to the size the app would recommend right now.
|
||||
/// Returns how many loads changed, so callers can log or react without diffing themselves.
|
||||
@discardableResult
|
||||
static func synchronize(loads: [SavedLoad], maxVoltageDropPercent: Double) -> Int {
|
||||
var changed = 0
|
||||
for load in loads where load.length > 0 && load.current > 0 {
|
||||
let recommended = ElectricalCalculations.recommendedCrossSection(
|
||||
length: load.length,
|
||||
current: load.current,
|
||||
voltage: load.voltage,
|
||||
unitSystem: .metric,
|
||||
maxVoltageDropPercent: maxVoltageDropPercent
|
||||
)
|
||||
guard abs(load.crossSection - recommended) > 0.0001 else { continue }
|
||||
load.crossSection = recommended
|
||||
changed += 1
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func synchronize(loads: [SavedLoad], for system: ElectricalSystem) -> Int {
|
||||
synchronize(loads: loads, maxVoltageDropPercent: system.maxVoltageDropPercent)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,21 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The only load problem the app cannot solve on its own: missing length or current.
|
||||
/// Cross-sections are derived and kept in sync by `LoadCableSync`, so an undersized cable
|
||||
/// can no longer occur — there is nothing to warn about that the user would have to fix.
|
||||
enum LoadConfigurationStatus: Identifiable, Equatable {
|
||||
case missingDetails(count: Int)
|
||||
|
||||
static func evaluate(loads: [SavedLoad]) -> LoadConfigurationStatus? {
|
||||
guard !loads.isEmpty else { return nil }
|
||||
|
||||
let incomplete = loads.filter { load in
|
||||
load.length <= 0 || load.current <= 0 || load.crossSection <= 0
|
||||
}
|
||||
guard !incomplete.isEmpty else { return nil }
|
||||
return .missingDetails(count: incomplete.count)
|
||||
}
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .missingDetails(let count):
|
||||
@@ -43,7 +56,7 @@ enum LoadConfigurationStatus: Identifiable, Equatable {
|
||||
)
|
||||
let format = String(
|
||||
localized: "loads.overview.status.missing_details.message",
|
||||
defaultValue: "Enter cable length and wire size for %d %@ to see accurate recommendations."
|
||||
defaultValue: "Enter cable length and current draw for %d %@ so Cable can size the wiring."
|
||||
)
|
||||
let loadWord = count == 1
|
||||
? String(
|
||||
@@ -54,8 +67,7 @@ enum LoadConfigurationStatus: Identifiable, Equatable {
|
||||
localized: "loads.overview.status.missing_details.plural",
|
||||
defaultValue: "loads"
|
||||
)
|
||||
let message = String(format: format, count, loadWord)
|
||||
return LoadStatusDetail(title: title, message: message)
|
||||
return LoadStatusDetail(title: title, message: String(format: format, count, loadWord))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +145,7 @@ struct LoadsView: View {
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
}
|
||||
.accessibilityIdentifier("system-title-button")
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
@@ -302,6 +303,18 @@ struct LoadsView: View {
|
||||
colorName: Binding(
|
||||
get: { system.colorName },
|
||||
set: { system.colorName = $0 }
|
||||
),
|
||||
maxVoltageDropPercent: Binding(
|
||||
get: { system.maxVoltageDropPercent },
|
||||
set: { newValue in
|
||||
system.maxVoltageDropPercent = newValue
|
||||
// The budget is an input to every cable size, so the stored sizes follow.
|
||||
let resized = LoadCableSync.synchronize(loads: savedLoads, for: system)
|
||||
AnalyticsTracker.log("Voltage Drop Budget Changed", properties: [
|
||||
"percent": newValue,
|
||||
"resized_loads": resized,
|
||||
])
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -318,6 +331,8 @@ struct LoadsView: View {
|
||||
)
|
||||
}
|
||||
.onAppear {
|
||||
LoadCableSync.synchronize(loads: savedLoads, for: system)
|
||||
|
||||
if presentSystemEditorOnAppear && !hasPresentedSystemEditorOnAppear {
|
||||
hasPresentedSystemEditorOnAppear = true
|
||||
DispatchQueue.main.async {
|
||||
@@ -761,14 +776,7 @@ struct LoadsView: View {
|
||||
}
|
||||
|
||||
private var loadStatus: LoadConfigurationStatus? {
|
||||
guard !savedLoads.isEmpty else { return nil }
|
||||
let incompleteLoads = savedLoads.filter { load in
|
||||
load.length <= 0 || load.crossSection <= 0
|
||||
}
|
||||
if !incompleteLoads.isEmpty {
|
||||
return .missingDetails(count: incompleteLoads.count)
|
||||
}
|
||||
return nil
|
||||
LoadConfigurationStatus.evaluate(loads: savedLoads)
|
||||
}
|
||||
|
||||
private func summaryMetric(icon: String, label: String, value: String, tint: Color) -> some View {
|
||||
@@ -1102,13 +1110,7 @@ struct LoadsView: View {
|
||||
}
|
||||
|
||||
private func awgFromCrossSection(_ crossSectionMM2: Double) -> Double {
|
||||
let awgSizes: [(Int, Double)] = [(20, 0.519), (18, 0.823), (16, 1.31), (14, 2.08), (12, 3.31),
|
||||
(10, 5.26), (8, 8.37), (6, 13.3), (4, 21.2), (2, 33.6),
|
||||
(1, 42.4), (-1, 53.5), (-2, 67.4), (-3, 85.0), (-4, 107.0)]
|
||||
|
||||
// Find the closest AWG size
|
||||
let closest = awgSizes.min { abs($0.1 - crossSectionMM2) < abs($1.1 - crossSectionMM2) }
|
||||
return Double(closest?.0 ?? 20)
|
||||
ElectricalCalculations.nearestAWG(forCrossSectionMM2: crossSectionMM2)
|
||||
}
|
||||
|
||||
private func recommendedFuse(for load: SavedLoad) -> String {
|
||||
@@ -1218,16 +1220,20 @@ struct LoadsView: View {
|
||||
dutyCyclePercent: load.dutyCyclePercent,
|
||||
dailyUsageHours: load.dailyUsageHours,
|
||||
recommendedCrossSection: ElectricalCalculations.recommendedCrossSection(
|
||||
length: load.length, current: load.current, voltage: load.voltage, unitSystem: currentUnitSystem
|
||||
length: load.length, current: load.current, voltage: load.voltage,
|
||||
unitSystem: currentUnitSystem, maxVoltageDropPercent: system.maxVoltageDropPercent
|
||||
),
|
||||
voltageDrop: ElectricalCalculations.voltageDrop(
|
||||
length: load.length, current: load.current, voltage: load.voltage, unitSystem: currentUnitSystem
|
||||
length: load.length, current: load.current, voltage: load.voltage,
|
||||
unitSystem: currentUnitSystem, maxVoltageDropPercent: system.maxVoltageDropPercent
|
||||
),
|
||||
voltageDropPercent: ElectricalCalculations.voltageDropPercentage(
|
||||
length: load.length, current: load.current, voltage: load.voltage, unitSystem: currentUnitSystem
|
||||
length: load.length, current: load.current, voltage: load.voltage,
|
||||
unitSystem: currentUnitSystem, maxVoltageDropPercent: system.maxVoltageDropPercent
|
||||
),
|
||||
powerLoss: ElectricalCalculations.powerLoss(
|
||||
length: load.length, current: load.current, voltage: load.voltage, unitSystem: currentUnitSystem
|
||||
length: load.length, current: load.current, voltage: load.voltage,
|
||||
unitSystem: currentUnitSystem, maxVoltageDropPercent: system.maxVoltageDropPercent
|
||||
),
|
||||
recommendedFuse: ElectricalCalculations.recommendedFuse(forCurrent: load.current),
|
||||
iconUrl: load.remoteIconURLString
|
||||
|
||||
@@ -4,6 +4,7 @@ import SwiftData
|
||||
struct SystemOverviewView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
|
||||
@State private var activeStatus: LoadConfigurationStatus?
|
||||
@State private var suppressLoadNavigation = false
|
||||
@State private var showingRuntimeGoalEditor = false
|
||||
@@ -390,7 +391,6 @@ struct SystemOverviewView: View {
|
||||
.fill(Color(.tertiarySystemBackground))
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.alert(item: $activeStatus) { status in
|
||||
let detail = status.detailInfo()
|
||||
return Alert(
|
||||
@@ -588,14 +588,7 @@ struct SystemOverviewView: View {
|
||||
}
|
||||
|
||||
private var loadStatus: LoadConfigurationStatus? {
|
||||
guard !loads.isEmpty else { return nil }
|
||||
let incomplete = loads.filter { load in
|
||||
load.length <= 0 || load.crossSection <= 0
|
||||
}
|
||||
if !incomplete.isEmpty {
|
||||
return .missingDetails(count: incomplete.count)
|
||||
}
|
||||
return nil
|
||||
LoadConfigurationStatus.evaluate(loads: loads)
|
||||
}
|
||||
|
||||
private var totalCurrent: Double {
|
||||
|
||||
@@ -24,6 +24,28 @@ struct SettingsView: View {
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
Section {
|
||||
Picker(
|
||||
String(
|
||||
localized: "settings.voltage_drop.label",
|
||||
defaultValue: "Voltage drop budget"
|
||||
),
|
||||
selection: $unitSettings.voltageDropTargetPercent
|
||||
) {
|
||||
ForEach([3.0, 5.0, 10.0], id: \.self) { target in
|
||||
Text(String(format: "%.0f %%", target)).tag(target)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
Text(
|
||||
String(
|
||||
localized: "settings.voltage_drop.footnote",
|
||||
defaultValue: "Used by the calculator and for new systems. Existing systems keep their own budget."
|
||||
)
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 8) {
|
||||
|
||||
@@ -852,19 +852,7 @@ struct SystemBillOfMaterialsView: View {
|
||||
}
|
||||
|
||||
private func awgFromCrossSection(_ crossSectionMM2: Double) -> Double {
|
||||
let mapping: [(awg: Double, area: Double)] = [
|
||||
(20, 0.519), (18, 0.823), (16, 1.31), (14, 2.08), (12, 3.31), (10, 5.26),
|
||||
(8, 8.37), (6, 13.3), (4, 21.2), (2, 33.6), (1, 42.4), (-1, 53.5),
|
||||
(-2, 67.4), (-3, 85.0), (-4, 107.0)
|
||||
]
|
||||
|
||||
guard crossSectionMM2 > 0 else { return 0 }
|
||||
|
||||
let closest = mapping.min { lhs, rhs in
|
||||
abs(lhs.area - crossSectionMM2) < abs(rhs.area - crossSectionMM2)
|
||||
}
|
||||
|
||||
return closest?.awg ?? 0
|
||||
ElectricalCalculations.nearestAWG(forCrossSectionMM2: crossSectionMM2)
|
||||
}
|
||||
|
||||
private var footerMessage: String {
|
||||
|
||||
@@ -20,13 +20,22 @@ struct SystemComponentsPersistence {
|
||||
batteries: existingBatteries,
|
||||
chargers: existingChargers
|
||||
)
|
||||
let defaultLength = 10.0
|
||||
let defaultVoltage = 12.0
|
||||
let defaultCurrent = 5.0
|
||||
let newLoad = SavedLoad(
|
||||
name: loadName,
|
||||
voltage: 12.0,
|
||||
current: 5.0,
|
||||
power: 60.0,
|
||||
length: 10.0,
|
||||
crossSection: 1.0,
|
||||
voltage: defaultVoltage,
|
||||
current: defaultCurrent,
|
||||
power: defaultVoltage * defaultCurrent,
|
||||
length: defaultLength,
|
||||
crossSection: ElectricalCalculations.recommendedCrossSection(
|
||||
length: defaultLength,
|
||||
current: defaultCurrent,
|
||||
voltage: defaultVoltage,
|
||||
unitSystem: .metric,
|
||||
maxVoltageDropPercent: system.maxVoltageDropPercent
|
||||
),
|
||||
iconName: "lightbulb",
|
||||
colorName: "blue",
|
||||
isWattMode: false,
|
||||
@@ -69,13 +78,20 @@ struct SystemComponentsPersistence {
|
||||
let dutyCyclePercent = item.normalizedDutyCyclePercent ?? 100
|
||||
let dailyUsageHours = item.defaultDailyUsageHours ?? 1
|
||||
|
||||
let length = 10.0
|
||||
let newLoad = SavedLoad(
|
||||
name: loadName,
|
||||
voltage: voltage,
|
||||
current: current,
|
||||
power: power,
|
||||
length: 10.0,
|
||||
crossSection: 1.0,
|
||||
length: length,
|
||||
crossSection: ElectricalCalculations.recommendedCrossSection(
|
||||
length: length,
|
||||
current: current,
|
||||
voltage: voltage,
|
||||
unitSystem: .metric,
|
||||
maxVoltageDropPercent: system.maxVoltageDropPercent
|
||||
),
|
||||
iconName: "lightbulb",
|
||||
colorName: "blue",
|
||||
isWattMode: item.watt != nil,
|
||||
|
||||
@@ -12,6 +12,7 @@ struct SystemEditorView: View {
|
||||
@Binding var location: String
|
||||
@Binding var iconName: String
|
||||
@Binding var colorName: String
|
||||
@Binding var maxVoltageDropPercent: Double
|
||||
|
||||
@State private var tempLocation: String
|
||||
|
||||
@@ -23,11 +24,22 @@ struct SystemEditorView: View {
|
||||
"engine.combustion", "fuelpump", "drop", "flame", "snowflake", "thermometer"
|
||||
]
|
||||
|
||||
init(systemName: Binding<String>, location: Binding<String>, iconName: Binding<String>, colorName: Binding<String>) {
|
||||
/// Targets offered as presets: ABYC E-11 uses 3 % for critical circuits and 10 % for
|
||||
/// non-critical ones, 5 % is the default middle ground.
|
||||
private let voltageDropTargets: [Double] = [3, 5, 10]
|
||||
|
||||
init(
|
||||
systemName: Binding<String>,
|
||||
location: Binding<String>,
|
||||
iconName: Binding<String>,
|
||||
colorName: Binding<String>,
|
||||
maxVoltageDropPercent: Binding<Double>
|
||||
) {
|
||||
self._systemName = systemName
|
||||
self._location = location
|
||||
self._iconName = iconName
|
||||
self._colorName = colorName
|
||||
self._maxVoltageDropPercent = maxVoltageDropPercent
|
||||
self._tempLocation = State(initialValue: location.wrappedValue)
|
||||
}
|
||||
|
||||
@@ -46,11 +58,41 @@ struct SystemEditorView: View {
|
||||
colorName: $colorName,
|
||||
additionalFields: {
|
||||
AnyView(
|
||||
TextField(locationPlaceholder, text: $tempLocation)
|
||||
.autocapitalization(.words)
|
||||
.onChange(of: tempLocation) { _, newValue in
|
||||
location = newValue
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
TextField(locationPlaceholder, text: $tempLocation)
|
||||
.autocapitalization(.words)
|
||||
.onChange(of: tempLocation) { _, newValue in
|
||||
location = newValue
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(
|
||||
String(
|
||||
localized: "editor.system.voltage_drop.label",
|
||||
defaultValue: "Voltage drop budget"
|
||||
)
|
||||
)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
|
||||
Picker(
|
||||
String(
|
||||
localized: "editor.system.voltage_drop.label",
|
||||
defaultValue: "Voltage drop budget"
|
||||
),
|
||||
selection: $maxVoltageDropPercent
|
||||
) {
|
||||
ForEach(voltageDropTargets, id: \.self) { target in
|
||||
Text(String(format: "%.0f %%", target)).tag(target)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.accessibilityIdentifier("system-voltage-drop-picker")
|
||||
|
||||
Text(voltageDropHint)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -58,6 +100,26 @@ struct SystemEditorView: View {
|
||||
tempLocation = location
|
||||
}
|
||||
}
|
||||
|
||||
private var voltageDropHint: String {
|
||||
switch maxVoltageDropPercent {
|
||||
case ..<4:
|
||||
return String(
|
||||
localized: "editor.system.voltage_drop.hint.critical",
|
||||
defaultValue: "3 % — for critical circuits like navigation, bilge pumps and electronics."
|
||||
)
|
||||
case ..<6:
|
||||
return String(
|
||||
localized: "editor.system.voltage_drop.hint.standard",
|
||||
defaultValue: "5 % — balanced default for mixed installations."
|
||||
)
|
||||
default:
|
||||
return String(
|
||||
localized: "editor.system.voltage_drop.hint.noncritical",
|
||||
defaultValue: "10 % — for non-critical loads such as cabin lighting."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
@@ -65,6 +127,13 @@ struct SystemEditorView: View {
|
||||
@Previewable @State var location = "Main Building"
|
||||
@Previewable @State var icon = "building.2"
|
||||
@Previewable @State var color = "blue"
|
||||
|
||||
return SystemEditorView(systemName: $name, location: $location, iconName: $icon, colorName: $color)
|
||||
@Previewable @State var dropTarget = 5.0
|
||||
|
||||
return SystemEditorView(
|
||||
systemName: $name,
|
||||
location: $location,
|
||||
iconName: $icon,
|
||||
colorName: $color,
|
||||
maxVoltageDropPercent: $dropTarget
|
||||
)
|
||||
}
|
||||
|
||||
@@ -324,7 +324,8 @@ struct SystemsView: View {
|
||||
name: systemName,
|
||||
location: "",
|
||||
iconName: resolvedIconName,
|
||||
colorName: resolvedColorName
|
||||
colorName: resolvedColorName,
|
||||
maxVoltageDropPercent: unitSettings.voltageDropTargetPercent
|
||||
)
|
||||
modelContext.insert(newSystem)
|
||||
return newSystem
|
||||
|
||||
@@ -58,6 +58,13 @@ class UnitSystemSettings: ObservableObject {
|
||||
AnalyticsTracker.log("Unit System Changed", properties: ["system": unitSystem.rawValue])
|
||||
}
|
||||
}
|
||||
/// Applies to the standalone calculator and seeds newly created systems.
|
||||
@Published var voltageDropTargetPercent: Double {
|
||||
didSet {
|
||||
UserDefaults.standard.set(voltageDropTargetPercent, forKey: "voltageDropTargetPercent")
|
||||
AnalyticsTracker.log("Voltage Drop Target Changed", properties: ["percent": voltageDropTargetPercent])
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
if let saved = UserDefaults.standard.string(forKey: "unitSystem"),
|
||||
@@ -66,5 +73,9 @@ class UnitSystemSettings: ObservableObject {
|
||||
} else {
|
||||
self.unitSystem = Locale.current.measurementSystem == .us ? .imperial : .metric
|
||||
}
|
||||
let savedTarget = UserDefaults.standard.double(forKey: "voltageDropTargetPercent")
|
||||
self.voltageDropTargetPercent = savedTarget > 0
|
||||
? savedTarget
|
||||
: ElectricalCalculations.defaultMaxVoltageDropPercent
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,10 +177,6 @@
|
||||
"bom.quantity.terminal.badge" = "%1$d× · %2$@";
|
||||
"bom.quantity.cable.badge" = "%1$.1f %2$@ · %3$@";
|
||||
"bom.quantity.single.badge" = "1× • %@";
|
||||
"cable.pro.privacy.label" = "Datenschutz";
|
||||
"cable.pro.privacy.url" = "https://voltplan.app/de/datenschutz";
|
||||
"cable.pro.terms.label" = "Nutzungsbedingungen";
|
||||
"cable.pro.terms.url" = "https://voltplan.app/de/agb";
|
||||
"calculator.advanced.duty_cycle.helper" = "Prozentsatz der aktiven Zeit, in der die Last tatsächlich Leistung aufnimmt.";
|
||||
"calculator.advanced.duty_cycle.title" = "Einschaltdauer";
|
||||
"calculator.advanced.section.title" = "Erweitert";
|
||||
@@ -259,7 +255,7 @@
|
||||
"loads.overview.metric.current" = "Strom";
|
||||
"loads.overview.metric.power" = "Leistung";
|
||||
"loads.overview.status.missing_details.banner" = "Konfiguration deiner Verbraucher abschließen";
|
||||
"loads.overview.status.missing_details.message" = "Gib Kabellänge und Leitungsquerschnitt für %d %@ ein, um genaue Empfehlungen zu erhalten.";
|
||||
"loads.overview.status.missing_details.message" = "Gib Kabellänge und Stromaufnahme für %d %@ ein, damit Cable die Leitung dimensionieren kann.";
|
||||
"loads.overview.status.missing_details.plural" = "Verbraucher";
|
||||
"loads.overview.status.missing_details.singular" = "Verbraucher";
|
||||
"loads.overview.status.missing_details.title" = "Fehlende Verbraucherdetails";
|
||||
@@ -349,47 +345,7 @@
|
||||
"settings.pro.manage.url" = "https://apps.apple.com/account/subscriptions";
|
||||
"settings.pro.day.one" = "%@ Tag";
|
||||
"settings.pro.day.other" = "%@ Tage";
|
||||
"cable.pro.terms.label" = "AGB";
|
||||
"cable.pro.privacy.label" = "Datenschutz";
|
||||
"cable.pro.terms.url" = "https://voltplan.app/terms";
|
||||
"cable.pro.privacy.url" = "https://voltplan.app/privacy";
|
||||
"cable.pro.paywall.title" = "Cable PRO";
|
||||
"cable.pro.paywall.subtitle" = "Cable PRO bietet mehr Konfigurationsoptionen für Verbraucher, Batterien und Ladegeräte.";
|
||||
"cable.pro.feature.dutyCycle" = "Kabelberechnungen mit Einschaltdauer";
|
||||
"cable.pro.feature.batteryCapacity" = "Verfügbare Batteriekapazität konfigurieren";
|
||||
"cable.pro.feature.usageBased" = "Nutzungsbasierte Berechnungen";
|
||||
"cable.pro.button.unlock" = "Jetzt freischalten";
|
||||
"cable.pro.button.freeTrial" = "Kostenlose Testphase starten";
|
||||
"cable.pro.button.unlocked" = "Bereits aktiviert";
|
||||
"cable.pro.restore.button" = "Käufe wiederherstellen";
|
||||
"cable.pro.alert.success.title" = "Cable PRO aktiviert";
|
||||
"cable.pro.alert.success.body" = "Danke für deine Unterstützung!";
|
||||
"cable.pro.alert.pending.title" = "Kauf ausstehend";
|
||||
"cable.pro.alert.pending.body" = "Dein Kauf wartet auf Bestätigung.";
|
||||
"cable.pro.alert.restored.title" = "Käufe wiederhergestellt";
|
||||
"cable.pro.alert.restored.body" = "Deine bisherigen Käufe sind wieder verfügbar.";
|
||||
"cable.pro.alert.error.title" = "Kauf fehlgeschlagen";
|
||||
"cable.pro.alert.error.generic" = "Etwas ist schiefgelaufen. Bitte versuche es erneut.";
|
||||
"generic.ok" = "OK";
|
||||
"cable.pro.trial.badge" = "Enthält eine %@ Testphase";
|
||||
"cable.pro.subscription.renews" = "Verlängert sich %@.";
|
||||
"cable.pro.subscription.trialThenRenews" = "Testphase, danach Verlängerung %@.";
|
||||
"cable.pro.duration.day.singular" = "jeden Tag";
|
||||
"cable.pro.duration.day.plural" = "alle %@ Tage";
|
||||
"cable.pro.duration.week.singular" = "jede Woche";
|
||||
"cable.pro.duration.week.plural" = "alle %@ Wochen";
|
||||
"cable.pro.duration.month.singular" = "monatlich";
|
||||
"cable.pro.duration.month.plural" = "alle %@ Monate";
|
||||
"cable.pro.duration.year.singular" = "jährlich";
|
||||
"cable.pro.duration.year.plural" = "alle %@ Jahre";
|
||||
"cable.pro.trial.duration.day.singular" = "%@-tägige";
|
||||
"cable.pro.trial.duration.day.plural" = "%@-tägige";
|
||||
"cable.pro.trial.duration.week.singular" = "%@-wöchige";
|
||||
"cable.pro.trial.duration.week.plural" = "%@-wöchige";
|
||||
"cable.pro.trial.duration.month.singular" = "%@-monatige";
|
||||
"cable.pro.trial.duration.month.plural" = "%@-monatige";
|
||||
"cable.pro.trial.duration.year.singular" = "%@-jährige";
|
||||
"cable.pro.trial.duration.year.plural" = "%@-jährige";
|
||||
"• Always consult qualified electricians for actual installations" = "• Ziehe für tatsächliche Installationen stets qualifizierte Elektriker hinzu";
|
||||
"• Electrical work should only be performed by licensed professionals" = "• Elektroarbeiten sollten nur von zertifizierten Fachkräften ausgeführt werden";
|
||||
"• Follow all local electrical codes and regulations" = "• Beachte alle örtlichen Vorschriften und Normen";
|
||||
@@ -453,3 +409,10 @@
|
||||
"overview.share.diagram" = "Schaltplan";
|
||||
"overview.share.pdf" = "Vollständiger Bericht (PDF)";
|
||||
"overview.share.diagram.error" = "Schaltplan konnte nicht erstellt werden. Überprüfe deine Internetverbindung.";
|
||||
|
||||
"editor.system.voltage_drop.label" = "Spannungsfall-Budget";
|
||||
"editor.system.voltage_drop.hint.critical" = "3 % — für kritische Stromkreise wie Navigation, Bilgepumpe und Elektronik.";
|
||||
"editor.system.voltage_drop.hint.standard" = "5 % — ausgewogener Standard für gemischte Installationen.";
|
||||
"editor.system.voltage_drop.hint.noncritical" = "10 % — für unkritische Verbraucher wie Innenbeleuchtung.";
|
||||
"settings.voltage_drop.label" = "Spannungsfall-Budget";
|
||||
"settings.voltage_drop.footnote" = "Gilt für den Rechner und neue Systeme. Bestehende Systeme behalten ihr eigenes Budget.";
|
||||
|
||||
@@ -196,7 +196,7 @@
|
||||
"loads.onboarding.title" = "Añade tu primer consumidor";
|
||||
"loads.onboarding.subtitle" = "Completa tu sistema con consumidores y deja que **Cable by VoltPlan** calcule cables y fusibles por ti.";
|
||||
"loads.overview.status.missing_details.title" = "Faltan detalles de la carga";
|
||||
"loads.overview.status.missing_details.message" = "Introduce la longitud del cable y el calibre del conductor para %d %@ para obtener recomendaciones precisas.";
|
||||
"loads.overview.status.missing_details.message" = "Introduce la longitud del cable y la corriente de %d %@ para que Cable dimensione el cableado.";
|
||||
"loads.overview.status.missing_details.singular" = "carga";
|
||||
"loads.overview.status.missing_details.plural" = "cargas";
|
||||
"loads.overview.status.missing_details.banner" = "Completa la configuración de tus cargas";
|
||||
@@ -351,11 +351,6 @@
|
||||
"chargers.title" = "Cargadores para %@";
|
||||
"chargers.subtitle" = "Los componentes de carga estarán disponibles pronto.";
|
||||
|
||||
"cable.pro.paywall.title" = "Cable PRO";
|
||||
"cable.pro.paywall.subtitle" = "Cable PRO permite más opciones de configuración para cargas, baterías y cargadores.";
|
||||
"cable.pro.feature.dutyCycle" = "Calculadoras de cables conscientes del ciclo de trabajo";
|
||||
"cable.pro.feature.batteryCapacity" = "Configura la capacidad utilizable de la batería";
|
||||
"cable.pro.feature.usageBased" = "Cálculos basados en el uso";
|
||||
"generic.ok" = "Aceptar";
|
||||
|
||||
// MARK: - PDF Overview Export
|
||||
@@ -415,3 +410,10 @@
|
||||
"overview.share.diagram" = "Diagrama de cableado";
|
||||
"overview.share.pdf" = "Informe completo (PDF)";
|
||||
"overview.share.diagram.error" = "No se pudo generar el diagrama. Comprueba tu conexión a Internet.";
|
||||
|
||||
"editor.system.voltage_drop.label" = "Caída de tensión admisible";
|
||||
"editor.system.voltage_drop.hint.critical" = "3 % — para circuitos críticos como navegación, bombas de sentina y electrónica.";
|
||||
"editor.system.voltage_drop.hint.standard" = "5 % — valor equilibrado para instalaciones mixtas.";
|
||||
"editor.system.voltage_drop.hint.noncritical" = "10 % — para consumos no críticos como la iluminación interior.";
|
||||
"settings.voltage_drop.label" = "Caída de tensión admisible";
|
||||
"settings.voltage_drop.footnote" = "Se aplica a la calculadora y a los sistemas nuevos. Los sistemas existentes conservan su valor.";
|
||||
|
||||
@@ -196,7 +196,7 @@
|
||||
"loads.onboarding.title" = "Ajoutez votre premier consommateur";
|
||||
"loads.onboarding.subtitle" = "Complétez votre système avec des équipements et laissez **Cable by VoltPlan** proposer les câbles et fusibles adaptés.";
|
||||
"loads.overview.status.missing_details.title" = "Détails de charge manquants";
|
||||
"loads.overview.status.missing_details.message" = "Saisissez la longueur de câble et la section du conducteur pour %d %@ afin d'obtenir des recommandations précises.";
|
||||
"loads.overview.status.missing_details.message" = "Indiquez la longueur de câble et le courant de %d %@ pour que Cable dimensionne le câblage.";
|
||||
"loads.overview.status.missing_details.singular" = "charge";
|
||||
"loads.overview.status.missing_details.plural" = "charges";
|
||||
"loads.overview.status.missing_details.banner" = "Terminez la configuration de vos charges";
|
||||
@@ -351,11 +351,6 @@
|
||||
"chargers.title" = "Chargeurs pour %@";
|
||||
"chargers.subtitle" = "Les chargeurs seront bientôt disponibles ici.";
|
||||
|
||||
"cable.pro.paywall.title" = "Cable PRO";
|
||||
"cable.pro.paywall.subtitle" = "Cable PRO offre davantage d'options de configuration pour les charges, les batteries et les chargeurs.";
|
||||
"cable.pro.feature.dutyCycle" = "Calculs de câbles tenant compte du cycle d'utilisation";
|
||||
"cable.pro.feature.batteryCapacity" = "Configurez la capacité utilisable de la batterie";
|
||||
"cable.pro.feature.usageBased" = "Calculs basés sur l’utilisation";
|
||||
"generic.ok" = "OK";
|
||||
|
||||
// MARK: - PDF Overview Export
|
||||
@@ -415,3 +410,10 @@
|
||||
"overview.share.diagram" = "Schéma de câblage";
|
||||
"overview.share.pdf" = "Rapport complet (PDF)";
|
||||
"overview.share.diagram.error" = "Impossible de générer le schéma. Vérifiez votre connexion Internet.";
|
||||
|
||||
"editor.system.voltage_drop.label" = "Chute de tension admissible";
|
||||
"editor.system.voltage_drop.hint.critical" = "3 % — pour les circuits critiques : navigation, pompe de cale, électronique.";
|
||||
"editor.system.voltage_drop.hint.standard" = "5 % — valeur équilibrée pour les installations mixtes.";
|
||||
"editor.system.voltage_drop.hint.noncritical" = "10 % — pour les charges non critiques comme l’éclairage intérieur.";
|
||||
"settings.voltage_drop.label" = "Chute de tension admissible";
|
||||
"settings.voltage_drop.footnote" = "S’applique au calculateur et aux nouvelles installations. Les installations existantes gardent leur valeur.";
|
||||
|
||||
@@ -196,7 +196,7 @@
|
||||
"loads.onboarding.title" = "Voeg je eerste verbruiker toe";
|
||||
"loads.onboarding.subtitle" = "Bouw je systeem uit met verbruikers en laat **Cable by VoltPlan** de kabel- en zekeringadviezen verzorgen.";
|
||||
"loads.overview.status.missing_details.title" = "Ontbrekende lastdetails";
|
||||
"loads.overview.status.missing_details.message" = "Voer kabellengte en kabeldoorsnede in voor %d %@ om nauwkeurige aanbevelingen te krijgen.";
|
||||
"loads.overview.status.missing_details.message" = "Vul kabellengte en stroom van %d %@ in zodat Cable de kabel kan dimensioneren.";
|
||||
"loads.overview.status.missing_details.singular" = "last";
|
||||
"loads.overview.status.missing_details.plural" = "lasten";
|
||||
"loads.overview.status.missing_details.banner" = "Rond de configuratie van je lasten af";
|
||||
@@ -351,11 +351,6 @@
|
||||
"chargers.title" = "Laders voor %@";
|
||||
"chargers.subtitle" = "Ladercomponenten zijn binnenkort beschikbaar.";
|
||||
|
||||
"cable.pro.paywall.title" = "Cable PRO";
|
||||
"cable.pro.paywall.subtitle" = "Cable PRO biedt meer configuratie-opties voor verbruikers, batterijen en laders.";
|
||||
"cable.pro.feature.dutyCycle" = "Kabelberekeningen die rekening houden met de inschakelduur";
|
||||
"cable.pro.feature.batteryCapacity" = "Configureer bruikbare batterijcapaciteit";
|
||||
"cable.pro.feature.usageBased" = "Gebruiksgestuurde berekeningen";
|
||||
"generic.ok" = "OK";
|
||||
|
||||
// MARK: - PDF Overview Export
|
||||
@@ -415,3 +410,10 @@
|
||||
"overview.share.diagram" = "Bedradingsschema";
|
||||
"overview.share.pdf" = "Volledig rapport (PDF)";
|
||||
"overview.share.diagram.error" = "Diagram kon niet worden gegenereerd. Controleer je internetverbinding.";
|
||||
|
||||
"editor.system.voltage_drop.label" = "Toegestane spanningsval";
|
||||
"editor.system.voltage_drop.hint.critical" = "3 % — voor kritische circuits zoals navigatie, bilgepomp en elektronica.";
|
||||
"editor.system.voltage_drop.hint.standard" = "5 % — evenwichtige standaard voor gemengde installaties.";
|
||||
"editor.system.voltage_drop.hint.noncritical" = "10 % — voor niet-kritische verbruikers zoals binnenverlichting.";
|
||||
"settings.voltage_drop.label" = "Toegestane spanningsval";
|
||||
"settings.voltage_drop.footnote" = "Geldt voor de rekenhulp en nieuwe systemen. Bestaande systemen houden hun eigen waarde.";
|
||||
|
||||
@@ -354,8 +354,9 @@ struct CableTests {
|
||||
)
|
||||
|
||||
// Minimum raw cross-section: (2×15×7.62×0.017)/(120×0.05) = 0.648mm²
|
||||
// Metric: rounds to 0.75mm² (smallest standard ≥ 0.648)
|
||||
#expect(metricMinCS == 0.75)
|
||||
// Metric: 0.75mm² covers the voltage drop but only carries 16A (ISO 13297, 105 °C),
|
||||
// which is below the 20A fuse for a 15A load, so ampacity forces 1.0mm² (20A).
|
||||
#expect(metricMinCS == 1.0)
|
||||
// Imperial: AWG 18 (0.823mm² ≥ 0.648)
|
||||
#expect(imperialAWG == 18.0)
|
||||
}
|
||||
@@ -412,4 +413,208 @@ struct CableTests {
|
||||
#expect(abs(actualDrop - expectedDrop) < 0.001)
|
||||
#expect(abs(actualLoss - expectedPowerLoss) < 0.001)
|
||||
}
|
||||
|
||||
// MARK: - Ampacity
|
||||
|
||||
/// A short run makes voltage drop irrelevant, so before ampacity was considered the
|
||||
/// recommendation was 1.5mm² / AWG 16 next to a 50A fuse — a cable the fuse cannot protect.
|
||||
@Test func shortHighCurrentRunIsSizedForAmpacityNotVoltageDrop() async throws {
|
||||
// 0.5m, 40A, 12V → voltage-drop minimum is only 1.13mm², fuse is 50A
|
||||
#expect(ElectricalCalculations.recommendedFuse(forCurrent: 40) == 50.0)
|
||||
|
||||
let metric = ElectricalCalculations.recommendedCrossSection(
|
||||
length: 0.5, current: 40, voltage: 12, unitSystem: .metric
|
||||
)
|
||||
// 4.0mm² carries 45A < 50A, so 6.0mm² (60A) is the smallest protected size
|
||||
#expect(metric == 6.0)
|
||||
|
||||
let imperial = ElectricalCalculations.recommendedCrossSection(
|
||||
length: 0.5, current: 40, voltage: 12, unitSystem: .imperial
|
||||
)
|
||||
// AWG 12 carries 45A < 50A, so AWG 10 (60A) is the smallest protected size
|
||||
#expect(imperial == 10.0)
|
||||
}
|
||||
|
||||
@Test func fuseNeverExceedsCableAmpacity() async throws {
|
||||
let scenarios: [(length: Double, current: Double, voltage: Double)] = [
|
||||
(0.3, 15, 12), (0.5, 40, 12), (1, 30, 12), (1, 60, 12), (2, 25, 12),
|
||||
(3, 10, 12), (5, 5, 12), (8, 20, 24), (12, 50, 24), (10, 100, 48),
|
||||
]
|
||||
|
||||
for scenario in scenarios {
|
||||
let fuse = ElectricalCalculations.recommendedFuse(forCurrent: scenario.current)
|
||||
|
||||
for unitSystem in [UnitSystem.metric, UnitSystem.imperial] {
|
||||
let crossSection = ElectricalCalculations.recommendedCrossSection(
|
||||
length: scenario.length,
|
||||
current: scenario.current,
|
||||
voltage: scenario.voltage,
|
||||
unitSystem: unitSystem
|
||||
)
|
||||
let ampacity = ElectricalCalculations.ampacity(
|
||||
forCrossSection: crossSection, unitSystem: unitSystem
|
||||
)
|
||||
#expect(
|
||||
ampacity >= fuse,
|
||||
"\(scenario.current)A over \(scenario.length)m: \(fuse)A fuse on a cable rated \(ampacity)A"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test func longRunsStayVoltageDropDriven() async throws {
|
||||
// 3m, 10A, 12V → voltage drop needs 1.7mm², fuse is only 15A, so the drop still wins
|
||||
let metric = ElectricalCalculations.recommendedCrossSection(
|
||||
length: 3, current: 10, voltage: 12, unitSystem: .metric
|
||||
)
|
||||
#expect(metric == 2.5)
|
||||
#expect(ElectricalCalculations.ampacity(forCrossSection: 2.5, unitSystem: .metric) == 35.0)
|
||||
}
|
||||
|
||||
@Test func ampacityMatchesPublishedTables() async throws {
|
||||
// ISO 13297 Table A1, 105 °C
|
||||
#expect(ElectricalCalculations.ampacity(forCrossSection: 0.75, unitSystem: .metric) == 16.0)
|
||||
#expect(ElectricalCalculations.ampacity(forCrossSection: 2.5, unitSystem: .metric) == 35.0)
|
||||
#expect(ElectricalCalculations.ampacity(forCrossSection: 16.0, unitSystem: .metric) == 130.0)
|
||||
// Between two standard sizes the smaller neighbour rates the cable
|
||||
#expect(ElectricalCalculations.ampacity(forCrossSection: 3.0, unitSystem: .metric) == 35.0)
|
||||
// ABYC E-11 Table 6A, 105 °C, outside engine spaces
|
||||
#expect(ElectricalCalculations.ampacity(forCrossSection: 14, unitSystem: .imperial) == 35.0)
|
||||
#expect(ElectricalCalculations.ampacity(forCrossSection: 6, unitSystem: .imperial) == 120.0)
|
||||
#expect(ElectricalCalculations.ampacity(forCrossSection: -4, unitSystem: .imperial) == 445.0)
|
||||
// AWG 20 is not covered by the table
|
||||
#expect(ElectricalCalculations.ampacity(forCrossSection: 20, unitSystem: .imperial) == 0.0)
|
||||
}
|
||||
|
||||
// MARK: - Voltage Drop Target
|
||||
|
||||
@Test func tighterTargetDemandsMoreCopper() async throws {
|
||||
// 5m, 10A, 12V: 5% needs 2.833mm² → 4.0mm², 3% needs 4.722mm² → 6.0mm²
|
||||
let standard = ElectricalCalculations.recommendedCrossSection(
|
||||
length: 5, current: 10, voltage: 12, unitSystem: .metric
|
||||
)
|
||||
let critical = ElectricalCalculations.recommendedCrossSection(
|
||||
length: 5, current: 10, voltage: 12, unitSystem: .metric, maxVoltageDropPercent: 3
|
||||
)
|
||||
#expect(standard == 4.0)
|
||||
#expect(critical == 6.0)
|
||||
}
|
||||
|
||||
@Test func looserTargetAllowsThinnerCableUntilAmpacityStops() async throws {
|
||||
// 5m, 10A, 12V at 10%: drop needs only 1.417mm², but the 15A fuse still requires
|
||||
// a cable rated for it, so 1.5mm² (25A per ISO 13297) is the floor.
|
||||
let nonCritical = ElectricalCalculations.recommendedCrossSection(
|
||||
length: 5, current: 10, voltage: 12, unitSystem: .metric, maxVoltageDropPercent: 10
|
||||
)
|
||||
#expect(nonCritical == 1.5)
|
||||
#expect(ElectricalCalculations.ampacity(forCrossSection: 1.5, unitSystem: .metric) >= 15.0)
|
||||
}
|
||||
|
||||
@Test func targetIsRespectedByResultingDrop() async throws {
|
||||
for target in [3.0, 5.0, 10.0] {
|
||||
let crossSection = ElectricalCalculations.recommendedCrossSection(
|
||||
length: 8, current: 20, voltage: 12, unitSystem: .metric, maxVoltageDropPercent: target
|
||||
)
|
||||
let dropPercent = ElectricalCalculations.voltageDropPercentage(
|
||||
length: 8, current: 20, voltage: 12, unitSystem: .metric, crossSection: crossSection
|
||||
)
|
||||
#expect(dropPercent <= target, "target \(target)% produced \(dropPercent)%")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func defaultTargetStaysAtFivePercent() async throws {
|
||||
#expect(ElectricalCalculations.defaultMaxVoltageDropPercent == 5.0)
|
||||
let explicit = ElectricalCalculations.recommendedCrossSection(
|
||||
length: 5, current: 10, voltage: 12, unitSystem: .metric, maxVoltageDropPercent: 5
|
||||
)
|
||||
let implicit = ElectricalCalculations.recommendedCrossSection(
|
||||
length: 5, current: 10, voltage: 12, unitSystem: .metric
|
||||
)
|
||||
#expect(explicit == implicit)
|
||||
}
|
||||
|
||||
@Test func systemStoresItsOwnTarget() async throws {
|
||||
let system = ElectricalSystem(name: "Nav circuit", maxVoltageDropPercent: 3)
|
||||
#expect(system.maxVoltageDropPercent == 3.0)
|
||||
let defaultSystem = ElectricalSystem(name: "Cabin")
|
||||
#expect(defaultSystem.maxVoltageDropPercent == ElectricalCalculations.defaultMaxVoltageDropPercent)
|
||||
}
|
||||
|
||||
// MARK: - Derived Cable Sizes
|
||||
|
||||
/// `SavedLoad.crossSection` is always stored in mm², independent of the display unit.
|
||||
private func makeLoad(current: Double, length: Double, crossSection: Double, voltage: Double = 12) -> SavedLoad {
|
||||
SavedLoad(
|
||||
name: "Load",
|
||||
voltage: voltage,
|
||||
current: current,
|
||||
power: voltage * current,
|
||||
length: length,
|
||||
crossSection: crossSection
|
||||
)
|
||||
}
|
||||
|
||||
@Test func syncRepairsLibraryPlaceholderSizes() async throws {
|
||||
// Library loads used to be stored with a hardcoded 1.0mm² at 10m
|
||||
let load = makeLoad(current: 5, length: 10, crossSection: 1.0)
|
||||
let changed = LoadCableSync.synchronize(loads: [load], maxVoltageDropPercent: 5)
|
||||
// 5 % needs (2×5×10×0.017)/(12×0.05) = 2.833mm² → next standard size is 4.0mm²
|
||||
#expect(changed == 1)
|
||||
#expect(load.crossSection == 4.0)
|
||||
}
|
||||
|
||||
@Test func syncRepairsCablesThatCannotCarryTheirFuse() async throws {
|
||||
// 40A load → 50A fuse; 1.5mm² carries 25A (ISO 13297), so it has to grow to 6.0mm² (60A)
|
||||
let load = makeLoad(current: 40, length: 0.5, crossSection: 1.5)
|
||||
LoadCableSync.synchronize(loads: [load], maxVoltageDropPercent: 5)
|
||||
#expect(load.crossSection == 6.0)
|
||||
#expect(
|
||||
ElectricalCalculations.ampacity(forCrossSection: load.crossSection, unitSystem: .metric)
|
||||
>= ElectricalCalculations.recommendedFuse(forCurrent: load.current)
|
||||
)
|
||||
}
|
||||
|
||||
@Test func syncFollowsTheBudgetInBothDirections() async throws {
|
||||
let load = makeLoad(current: 5, length: 10, crossSection: 1.0)
|
||||
|
||||
LoadCableSync.synchronize(loads: [load], maxVoltageDropPercent: 3)
|
||||
// 3 % needs 4.72mm² → 6.0mm²
|
||||
#expect(load.crossSection == 6.0)
|
||||
|
||||
LoadCableSync.synchronize(loads: [load], maxVoltageDropPercent: 10)
|
||||
// 10 % needs 1.42mm², but the 7.5A fuse still needs a cable rated for it → 1.5mm²
|
||||
#expect(load.crossSection == 1.5)
|
||||
}
|
||||
|
||||
@Test func syncIsIdempotent() async throws {
|
||||
let load = makeLoad(current: 10, length: 5, crossSection: 1.0)
|
||||
#expect(LoadCableSync.synchronize(loads: [load], maxVoltageDropPercent: 5) == 1)
|
||||
#expect(LoadCableSync.synchronize(loads: [load], maxVoltageDropPercent: 5) == 0)
|
||||
}
|
||||
|
||||
@Test func syncLeavesIncompleteLoadsAlone() async throws {
|
||||
let noLength = makeLoad(current: 5, length: 0, crossSection: 0)
|
||||
let noCurrent = makeLoad(current: 0, length: 5, crossSection: 0)
|
||||
#expect(LoadCableSync.synchronize(loads: [noLength, noCurrent], maxVoltageDropPercent: 5) == 0)
|
||||
#expect(noLength.crossSection == 0)
|
||||
#expect(noCurrent.crossSection == 0)
|
||||
}
|
||||
|
||||
@Test func syncedSystemHasNothingLeftToReport() async throws {
|
||||
let loads = [
|
||||
makeLoad(current: 5, length: 10, crossSection: 1.0),
|
||||
makeLoad(current: 40, length: 0.5, crossSection: 1.5),
|
||||
]
|
||||
LoadCableSync.synchronize(loads: loads, maxVoltageDropPercent: 5)
|
||||
#expect(LoadConfigurationStatus.evaluate(loads: loads) == nil)
|
||||
}
|
||||
|
||||
@Test func onlyMissingInputsAreStillReported() async throws {
|
||||
let loads = [
|
||||
makeLoad(current: 0, length: 0, crossSection: 0),
|
||||
makeLoad(current: 5, length: 2, crossSection: 2.5),
|
||||
]
|
||||
#expect(LoadConfigurationStatus.evaluate(loads: loads) == .missingDetails(count: 1))
|
||||
#expect(LoadConfigurationStatus.evaluate(loads: []) == nil)
|
||||
}
|
||||
}
|
||||
|
||||
75
CableTests/LocalizationIntegrityTests.swift
Normal file
75
CableTests/LocalizationIntegrityTests.swift
Normal file
@@ -0,0 +1,75 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Cable
|
||||
|
||||
/// Guards the string tables themselves. A malformed `.strings` file still parses — a value
|
||||
/// spanning several lines silently swallows the entries behind it — and the app then renders
|
||||
/// raw keys like "tab.overview". These tests fail instead.
|
||||
struct LocalizationIntegrityTests {
|
||||
|
||||
private static let locales = ["Base", "de", "es", "fr", "nl"]
|
||||
|
||||
/// Keys rendered without a `defaultValue:`, so a missing entry is visible to users.
|
||||
private static let keysWithoutFallback = [
|
||||
"tab.overview",
|
||||
"tab.components",
|
||||
"tab.batteries",
|
||||
"tab.chargers",
|
||||
]
|
||||
|
||||
private func table(for locale: String) throws -> [String: String] {
|
||||
let bundle = Bundle.main
|
||||
guard let url = bundle.url(
|
||||
forResource: "Localizable",
|
||||
withExtension: "strings",
|
||||
subdirectory: nil,
|
||||
localization: locale
|
||||
) else {
|
||||
throw LocalizationTestError.tableMissing(locale)
|
||||
}
|
||||
let data = try Data(contentsOf: url)
|
||||
let parsed = try PropertyListSerialization.propertyList(from: data, format: nil)
|
||||
guard let table = parsed as? [String: String] else {
|
||||
throw LocalizationTestError.tableMalformed(locale)
|
||||
}
|
||||
return table
|
||||
}
|
||||
|
||||
@Test func everyLocaleProvidesTheKeysThatHaveNoFallback() async throws {
|
||||
for locale in Self.locales {
|
||||
let table = try table(for: locale)
|
||||
for key in Self.keysWithoutFallback {
|
||||
#expect(table[key] != nil, "\(locale): missing \(key), the UI would show the raw key")
|
||||
#expect(
|
||||
table[key] != key,
|
||||
"\(locale): \(key) is not translated"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test func noValueSpansMultipleLines() async throws {
|
||||
for locale in Self.locales {
|
||||
let table = try table(for: locale)
|
||||
for (key, value) in table {
|
||||
#expect(
|
||||
!value.contains("\n"),
|
||||
"\(locale): \(key) contains a real newline; removing such an entry line by line corrupts the table"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test func retiredProKeysAreGone() async throws {
|
||||
for locale in Self.locales {
|
||||
let table = try table(for: locale)
|
||||
let leftovers = table.keys.filter { $0.hasPrefix("cable.pro.") }
|
||||
#expect(leftovers.isEmpty, "\(locale): dead PRO keys \(leftovers.sorted())")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum LocalizationTestError: Error {
|
||||
case tableMissing(String)
|
||||
case tableMalformed(String)
|
||||
}
|
||||
62
CableUITestsScreenshot/LocalizedTabBarUITests.swift
Normal file
62
CableUITestsScreenshot/LocalizedTabBarUITests.swift
Normal file
@@ -0,0 +1,62 @@
|
||||
import XCTest
|
||||
|
||||
/// Reproduces the regression where the tab bar rendered raw keys ("tab.overview") because a
|
||||
/// malformed entry in Localizable.strings hid every key behind it. The tab titles are looked up
|
||||
/// without a `defaultValue:`, so a broken table is immediately visible here.
|
||||
final class LocalizedTabBarUITests: XCTestCase {
|
||||
|
||||
override func setUpWithError() throws {
|
||||
try super.setUpWithError()
|
||||
continueAfterFailure = false
|
||||
XCUIDevice.shared.orientation = .portrait
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testTabBarIsLocalizedInGerman() throws {
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments = [
|
||||
"--uitest-reset-data",
|
||||
"--uitest-sample-data",
|
||||
"-AppleLanguages", "(de)",
|
||||
"-AppleLocale", "de_DE",
|
||||
]
|
||||
app.launch()
|
||||
|
||||
openFirstSystem(in: app)
|
||||
|
||||
let expected = ["Übersicht", "Verbraucher", "Batterien", "Ladegeräte"]
|
||||
for title in expected {
|
||||
XCTAssertTrue(
|
||||
app.buttons[title].waitForExistence(timeout: 15),
|
||||
"Tab \"\(title)\" is missing — the German string table is not being read"
|
||||
)
|
||||
}
|
||||
|
||||
for rawKey in ["tab.overview", "tab.components", "tab.batteries", "tab.chargers"] {
|
||||
XCTAssertFalse(
|
||||
app.buttons[rawKey].exists,
|
||||
"Tab bar shows the raw key \(rawKey) instead of a translation"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func openFirstSystem(in app: XCUIApplication) {
|
||||
let list: XCUIElement
|
||||
if app.collectionViews["systems-list"].waitForExistence(timeout: 15) {
|
||||
list = app.collectionViews["systems-list"]
|
||||
} else {
|
||||
list = app.collectionViews.firstMatch
|
||||
}
|
||||
XCTAssertTrue(list.waitForExistence(timeout: 15))
|
||||
|
||||
let firstCell = list.cells.element(boundBy: 0)
|
||||
XCTAssertTrue(firstCell.waitForExistence(timeout: 10))
|
||||
|
||||
let cellButton = firstCell.buttons.firstMatch
|
||||
if cellButton.exists {
|
||||
cellButton.tap()
|
||||
} else {
|
||||
firstCell.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
||||
}
|
||||
}
|
||||
}
|
||||
64
CableUITestsScreenshot/SystemVoltageDropTargetUITests.swift
Normal file
64
CableUITestsScreenshot/SystemVoltageDropTargetUITests.swift
Normal file
@@ -0,0 +1,64 @@
|
||||
import XCTest
|
||||
|
||||
/// Verifies that a system exposes its voltage-drop budget and that picking a tighter
|
||||
/// target sticks, which is what drives cable sizing for that system.
|
||||
final class SystemVoltageDropTargetUITests: XCTestCase {
|
||||
|
||||
override func setUpWithError() throws {
|
||||
try super.setUpWithError()
|
||||
continueAfterFailure = false
|
||||
XCUIDevice.shared.orientation = .portrait
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testVoltageDropBudgetIsSelectableInSystemEditor() throws {
|
||||
let app = launch(arguments: ["--uitest-reset-data", "--uitest-sample-data"])
|
||||
|
||||
openFirstSystem(in: app)
|
||||
|
||||
let systemTitle = app.buttons["system-title-button"]
|
||||
XCTAssertTrue(systemTitle.waitForExistence(timeout: 15))
|
||||
systemTitle.tap()
|
||||
|
||||
let picker = app.segmentedControls["system-voltage-drop-picker"]
|
||||
XCTAssertTrue(picker.waitForExistence(timeout: 15), "System editor must offer a voltage drop budget")
|
||||
|
||||
let standard = picker.buttons["5 %"]
|
||||
XCTAssertTrue(standard.waitForExistence(timeout: 5))
|
||||
XCTAssertTrue(standard.isSelected, "New systems start at the 5 % default")
|
||||
|
||||
let critical = picker.buttons["3 %"]
|
||||
XCTAssertTrue(critical.exists)
|
||||
critical.tap()
|
||||
XCTAssertTrue(critical.isSelected, "Picking 3 % must stick")
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func launch(arguments: [String]) -> XCUIApplication {
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments = arguments
|
||||
app.launch()
|
||||
return app
|
||||
}
|
||||
|
||||
private func openFirstSystem(in app: XCUIApplication) {
|
||||
let list: XCUIElement
|
||||
if app.collectionViews["systems-list"].waitForExistence(timeout: 15) {
|
||||
list = app.collectionViews["systems-list"]
|
||||
} else {
|
||||
list = app.collectionViews.firstMatch
|
||||
}
|
||||
XCTAssertTrue(list.waitForExistence(timeout: 15))
|
||||
|
||||
let firstCell = list.cells.element(boundBy: 0)
|
||||
XCTAssertTrue(firstCell.waitForExistence(timeout: 10))
|
||||
|
||||
let cellButton = firstCell.buttons.firstMatch
|
||||
if cellButton.exists {
|
||||
cellButton.tap()
|
||||
} else {
|
||||
firstCell.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user