Compare commits

...

4 Commits

Author SHA1 Message Date
165827c4d4 Bump to 1.8.1 (88) 2026-08-18 11:19:25 +02:00
af0687c362 Ignore build products and PocketBase credentials 2026-08-18 11:05:06 +02:00
eb9efee9af Guard string tables against silent corruption
A .strings value may span several physical lines, so deleting such an
entry line by line leaves orphaned lines that the parser folds into the
preceding value. Every key behind that point then disappears at runtime
while plutil -lint still reports OK. Tab titles are looked up without a
defaultValue:, so the tab bar rendered raw keys like "tab.overview"
while the rest of the UI fell back to its English defaults.

LocalizationIntegrityTests parses each table out of the app bundle and
fails on multi-line values, on missing keys that have no fallback, and
on leftover cable.pro.* entries. LocalizedTabBarUITests launches the app
in German and asserts the tab bar is translated.
2026-08-18 10:59:38 +02:00
38183f5282 Calculator: size cables for ampacity and drop budget
recommendedCrossSection now satisfies two criteria and takes whichever
demands more copper: the voltage-drop budget, and an ampacity at least
as high as the fuse protecting the circuit (ABYC E-11: the OCP rating
must not exceed the conductor's ampacity). Ampacity comes from ABYC
E-11 Table 6A for AWG and ISO 13297 Table A1 for mm², both 105 °C.

Before this, a 40 A load over 0.5 m was sized 1.5 mm² / AWG 16 next to
a 50 A fuse: a cable the fuse can never protect. Long runs stay
voltage-drop driven and unchanged.

The drop budget is now stored per system (3 / 5 / 10 %) and editable in
the system editor, with a global default in Settings that the
standalone calculator uses and new systems inherit.

Cross-sections are derived, never entered by the user, so LoadCableSync
re-derives them when a system opens and when its budget changes.
Library loads are computed instead of being stored as a 1.0 mm²
placeholder. LoadConfigurationStatus therefore reports only what the
user can actually fix: missing length or current.

Also removes the dead cable.pro.* strings and folds three copies of the
mm²-to-AWG conversion into ElectricalCalculations.nearestAWG.
2026-08-18 10:59:16 +02:00
24 changed files with 777 additions and 196 deletions

11
.gitignore vendored
View File

@@ -5,3 +5,14 @@ Vendor
Shots/Framed Shots/Framed
Shots/Screenshots Shots/Screenshots
*.xcresult *.xcresult
# Build products
DerivedData*/
build/
*.ipa
*.dSYM.zip
*.xcuserstate
*.xcuserdatad
# Credentials
PocketBase-LLM/pb-credentials.json

View File

@@ -418,7 +418,7 @@
CODE_SIGN_ENTITLEMENTS = Cable/Cable.entitlements; CODE_SIGN_ENTITLEMENTS = Cable/Cable.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 87; CURRENT_PROJECT_VERSION = 88;
DEVELOPMENT_TEAM = RE4FXQ754N; DEVELOPMENT_TEAM = RE4FXQ754N;
ENABLE_APP_SANDBOX = YES; ENABLE_APP_SANDBOX = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@@ -436,7 +436,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.8.0; MARKETING_VERSION = 1.8.1;
PRODUCT_BUNDLE_IDENTIFIER = app.voltplan.CableApp; PRODUCT_BUNDLE_IDENTIFIER = app.voltplan.CableApp;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -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 = 87; CURRENT_PROJECT_VERSION = 88;
DEVELOPMENT_TEAM = RE4FXQ754N; DEVELOPMENT_TEAM = RE4FXQ754N;
ENABLE_APP_SANDBOX = YES; ENABLE_APP_SANDBOX = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@@ -472,7 +472,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.8.0; MARKETING_VERSION = 1.8.1;
PRODUCT_BUNDLE_IDENTIFIER = app.voltplan.CableApp; PRODUCT_BUNDLE_IDENTIFIER = app.voltplan.CableApp;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";

View File

@@ -117,10 +117,6 @@
"bom.quantity.terminal.badge" = "%1$d× · %2$@"; "bom.quantity.terminal.badge" = "%1$d× · %2$@";
"bom.quantity.cable.badge" = "%1$.1f %2$@ · %3$@"; "bom.quantity.cable.badge" = "%1$.1f %2$@ · %3$@";
"bom.quantity.single.badge" = "1× • %@"; "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.helper" = "Percentage of each active session where the load actually draws power.";
"calculator.advanced.duty_cycle.title" = "Duty Cycle"; "calculator.advanced.duty_cycle.title" = "Duty Cycle";
"calculator.advanced.section.title" = "Advanced Settings"; "calculator.advanced.section.title" = "Advanced Settings";
@@ -199,7 +195,7 @@
"loads.overview.metric.current" = "Total Current"; "loads.overview.metric.current" = "Total Current";
"loads.overview.metric.power" = "Total Power"; "loads.overview.metric.power" = "Total Power";
"loads.overview.status.missing_details.banner" = "Finish configuring your loads"; "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.plural" = "loads";
"loads.overview.status.missing_details.singular" = "load"; "loads.overview.status.missing_details.singular" = "load";
"loads.overview.status.missing_details.title" = "Missing load details"; "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.manage.url" = "https://apps.apple.com/account/subscriptions";
"settings.pro.day.one" = "%@ day"; "settings.pro.day.one" = "%@ day";
"settings.pro.day.other" = "%@ days"; "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"; "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 // MARK: - PDF Overview Export
"overview.pdf.loads.section" = "Loads"; "overview.pdf.loads.section" = "Loads";
@@ -388,3 +344,10 @@
"overview.share.diagram" = "Wiring Diagram"; "overview.share.diagram" = "Wiring Diagram";
"overview.share.pdf" = "Full Report (PDF)"; "overview.share.pdf" = "Full Report (PDF)";
"overview.share.diagram.error" = "Could not generate diagram. Check your internet connection."; "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.";

View File

@@ -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 loadName: String = String(localized: "default.load.name", comment: "Default placeholder name for a load")
@Published var dutyCyclePercent: Double = 100.0 @Published var dutyCyclePercent: Double = 100.0
@Published var dailyUsageHours: Double = 24.0 @Published var dailyUsageHours: Double = 24.0
@Published var maxVoltageDropPercent: Double = ElectricalCalculations.defaultMaxVoltageDropPercent
var calculatedPower: Double { var calculatedPower: Double {
voltage * current voltage * current
@@ -38,7 +39,8 @@ class CableCalculator: ObservableObject {
length: length, length: length,
current: current, current: current,
voltage: voltage, voltage: voltage,
unitSystem: unitSystem unitSystem: unitSystem,
maxVoltageDropPercent: maxVoltageDropPercent
) )
} }
@@ -51,7 +53,8 @@ class CableCalculator: ObservableObject {
length: length, length: length,
current: current, current: current,
voltage: voltage, voltage: voltage,
unitSystem: unitSystem unitSystem: unitSystem,
maxVoltageDropPercent: maxVoltageDropPercent
) )
} }
@@ -60,7 +63,8 @@ class CableCalculator: ObservableObject {
length: length, length: length,
current: current, current: current,
voltage: voltage, voltage: voltage,
unitSystem: unitSystem unitSystem: unitSystem,
maxVoltageDropPercent: maxVoltageDropPercent
) )
} }
@@ -69,7 +73,8 @@ class CableCalculator: ObservableObject {
length: length, length: length,
current: current, current: current,
voltage: voltage, voltage: voltage,
unitSystem: unitSystem unitSystem: unitSystem,
maxVoltageDropPercent: maxVoltageDropPercent
) )
} }
@@ -92,6 +97,9 @@ class ElectricalSystem {
var colorName: String = "blue" var colorName: String = "blue"
var targetRuntimeHours: Double? var targetRuntimeHours: Double?
var targetChargeTimeHours: 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( init(
name: String, name: String,
@@ -99,7 +107,8 @@ class ElectricalSystem {
iconName: String = "building.2", iconName: String = "building.2",
colorName: String = "blue", colorName: String = "blue",
targetRuntimeHours: Double? = nil, targetRuntimeHours: Double? = nil,
targetChargeTimeHours: Double? = nil targetChargeTimeHours: Double? = nil,
maxVoltageDropPercent: Double = ElectricalCalculations.defaultMaxVoltageDropPercent
) { ) {
self.name = name self.name = name
self.location = location self.location = location
@@ -108,6 +117,7 @@ class ElectricalSystem {
self.colorName = colorName self.colorName = colorName
self.targetRuntimeHours = targetRuntimeHours self.targetRuntimeHours = targetRuntimeHours
self.targetChargeTimeHours = targetChargeTimeHours self.targetChargeTimeHours = targetChargeTimeHours
self.maxVoltageDropPercent = maxVoltageDropPercent
} }
} }

View File

@@ -345,10 +345,14 @@ struct CalculatorView: View {
.sheet(isPresented: $showingLoadEditor, content: loadEditorSheet) .sheet(isPresented: $showingLoadEditor, content: loadEditorSheet)
.sheet(item: $presentedAffiliateLink, content: billOfMaterialsSheet(info:)) .sheet(item: $presentedAffiliateLink, content: billOfMaterialsSheet(info:))
.onAppear { .onAppear {
calculator.maxVoltageDropPercent = unitSettings.voltageDropTargetPercent
if let savedLoad = savedLoad { if let savedLoad = savedLoad {
loadConfiguration(from: savedLoad) loadConfiguration(from: savedLoad)
} }
} }
.onChange(of: unitSettings.voltageDropTargetPercent) { _, newValue in
calculator.maxVoltageDropPercent = newValue
}
.onChange(of: completedItemIDs) { _, _ in .onChange(of: completedItemIDs) { _, _ in
persistCompletedItems() persistCompletedItems()
} }
@@ -701,7 +705,7 @@ struct CalculatorView: View {
Button(action: {}) { Button(action: {}) {
Text(String(format: "%.1fV (%.1f%%)", calculator.voltageDrop(for: unitSettings.unitSystem), calculator.voltageDropPercentage(for: unitSettings.unitSystem))) Text(String(format: "%.1fV (%.1f%%)", calculator.voltageDrop(for: unitSettings.unitSystem), calculator.voltageDropPercentage(for: unitSettings.unitSystem)))
.fontWeight(.medium) .fontWeight(.medium)
.foregroundColor(calculator.voltageDropPercentage(for: unitSettings.unitSystem) > 5 ? .orange : .primary) .foregroundColor(calculator.voltageDropPercentage(for: unitSettings.unitSystem) > calculator.maxVoltageDropPercent ? .orange : .primary)
} }
.buttonStyle(.plain) .buttonStyle(.plain)

View File

@@ -8,7 +8,9 @@
import Foundation import Foundation
struct ElectricalCalculations { 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 copperResistivity = 0.017 // Ω⋅mm²/m
private static let feetToMeters = 0.3048 private static let feetToMeters = 0.3048
@@ -29,27 +31,87 @@ struct ElectricalCalculations {
300, 350, 400, 450, 500, 600, 700, 800, 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). /// 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( static func recommendedCrossSection(
length: Double, length: Double,
current: Double, current: Double,
voltage: Double, voltage: Double,
unitSystem: UnitSystem unitSystem: UnitSystem,
maxVoltageDropPercent: Double = defaultMaxVoltageDropPercent
) -> Double { ) -> Double {
let lengthInMeters = length let lengthInMeters = length
let maxVoltageDrop = voltage * maxVoltageDropFraction let maxVoltageDrop = voltage * (maxVoltageDropPercent / 100)
let minimumCrossSection = guardAgainstZero(maxVoltageDrop) { let minimumCrossSection = guardAgainstZero(maxVoltageDrop) {
(2 * current * lengthInMeters * copperResistivity) / maxVoltageDrop (2 * current * lengthInMeters * copperResistivity) / maxVoltageDrop
} }
let requiredAmpacity = recommendedFuse(forCurrent: current)
if unitSystem == .imperial { 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[index])
} }
return Double(standardAWG.last ?? 0) return Double(standardAWG.last ?? 0)
} else { } else {
return standardMetricCrossSections.first { $0 >= max(standardMetricCrossSections.first ?? 0.75, minimumCrossSection) } let minimumArea = max(standardMetricCrossSections.first ?? 0.75, minimumCrossSection)
?? standardMetricCrossSections.last ?? 0.75 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, current: Double,
voltage: Double, voltage: Double,
unitSystem: UnitSystem, unitSystem: UnitSystem,
crossSection: Double? = nil crossSection: Double? = nil,
maxVoltageDropPercent: Double = defaultMaxVoltageDropPercent
) -> Double { ) -> Double {
let selectedCrossSection = crossSection ?? recommendedCrossSection( let selectedCrossSection = crossSection ?? recommendedCrossSection(
length: length, length: length,
current: current, current: current,
voltage: voltage, voltage: voltage,
unitSystem: unitSystem unitSystem: unitSystem,
maxVoltageDropPercent: maxVoltageDropPercent
) )
let lengthInMeters = length let lengthInMeters = length
@@ -84,7 +148,8 @@ struct ElectricalCalculations {
current: Double, current: Double,
voltage: Double, voltage: Double,
unitSystem: UnitSystem, unitSystem: UnitSystem,
crossSection: Double? = nil crossSection: Double? = nil,
maxVoltageDropPercent: Double = defaultMaxVoltageDropPercent
) -> Double { ) -> Double {
guard voltage != 0 else { return 0 } guard voltage != 0 else { return 0 }
let drop = voltageDrop( let drop = voltageDrop(
@@ -92,7 +157,8 @@ struct ElectricalCalculations {
current: current, current: current,
voltage: voltage, voltage: voltage,
unitSystem: unitSystem, unitSystem: unitSystem,
crossSection: crossSection crossSection: crossSection,
maxVoltageDropPercent: maxVoltageDropPercent
) )
return (drop / voltage) * 100 return (drop / voltage) * 100
} }
@@ -102,14 +168,16 @@ struct ElectricalCalculations {
current: Double, current: Double,
voltage: Double, voltage: Double,
unitSystem: UnitSystem, unitSystem: UnitSystem,
crossSection: Double? = nil crossSection: Double? = nil,
maxVoltageDropPercent: Double = defaultMaxVoltageDropPercent
) -> Double { ) -> Double {
let drop = voltageDrop( let drop = voltageDrop(
length: length, length: length,
current: current, current: current,
voltage: voltage, voltage: voltage,
unitSystem: unitSystem, unitSystem: unitSystem,
crossSection: crossSection crossSection: crossSection,
maxVoltageDropPercent: maxVoltageDropPercent
) )
return current * drop return current * drop
} }

View 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)
}
}

View File

@@ -1,8 +1,21 @@
import SwiftUI 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 { enum LoadConfigurationStatus: Identifiable, Equatable {
case missingDetails(count: Int) 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 { var id: String {
switch self { switch self {
case .missingDetails(let count): case .missingDetails(let count):
@@ -43,7 +56,7 @@ enum LoadConfigurationStatus: Identifiable, Equatable {
) )
let format = String( let format = String(
localized: "loads.overview.status.missing_details.message", 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 let loadWord = count == 1
? String( ? String(
@@ -54,8 +67,7 @@ enum LoadConfigurationStatus: Identifiable, Equatable {
localized: "loads.overview.status.missing_details.plural", localized: "loads.overview.status.missing_details.plural",
defaultValue: "loads" defaultValue: "loads"
) )
let message = String(format: format, count, loadWord) return LoadStatusDetail(title: title, message: String(format: format, count, loadWord))
return LoadStatusDetail(title: title, message: message)
} }
} }
} }

View File

@@ -145,6 +145,7 @@ struct LoadsView: View {
.foregroundColor(.primary) .foregroundColor(.primary)
} }
} }
.accessibilityIdentifier("system-title-button")
} }
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
@@ -302,6 +303,18 @@ struct LoadsView: View {
colorName: Binding( colorName: Binding(
get: { system.colorName }, get: { system.colorName },
set: { system.colorName = $0 } 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 { .onAppear {
LoadCableSync.synchronize(loads: savedLoads, for: system)
if presentSystemEditorOnAppear && !hasPresentedSystemEditorOnAppear { if presentSystemEditorOnAppear && !hasPresentedSystemEditorOnAppear {
hasPresentedSystemEditorOnAppear = true hasPresentedSystemEditorOnAppear = true
DispatchQueue.main.async { DispatchQueue.main.async {
@@ -761,14 +776,7 @@ struct LoadsView: View {
} }
private var loadStatus: LoadConfigurationStatus? { private var loadStatus: LoadConfigurationStatus? {
guard !savedLoads.isEmpty else { return nil } LoadConfigurationStatus.evaluate(loads: savedLoads)
let incompleteLoads = savedLoads.filter { load in
load.length <= 0 || load.crossSection <= 0
}
if !incompleteLoads.isEmpty {
return .missingDetails(count: incompleteLoads.count)
}
return nil
} }
private func summaryMetric(icon: String, label: String, value: String, tint: Color) -> some View { 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 { 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), ElectricalCalculations.nearestAWG(forCrossSectionMM2: crossSectionMM2)
(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)
} }
private func recommendedFuse(for load: SavedLoad) -> String { private func recommendedFuse(for load: SavedLoad) -> String {
@@ -1218,16 +1220,20 @@ struct LoadsView: View {
dutyCyclePercent: load.dutyCyclePercent, dutyCyclePercent: load.dutyCyclePercent,
dailyUsageHours: load.dailyUsageHours, dailyUsageHours: load.dailyUsageHours,
recommendedCrossSection: ElectricalCalculations.recommendedCrossSection( 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( 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( 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( 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), recommendedFuse: ElectricalCalculations.recommendedFuse(forCurrent: load.current),
iconUrl: load.remoteIconURLString iconUrl: load.remoteIconURLString

View File

@@ -4,6 +4,7 @@ import SwiftData
struct SystemOverviewView: View { struct SystemOverviewView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@State private var activeStatus: LoadConfigurationStatus? @State private var activeStatus: LoadConfigurationStatus?
@State private var suppressLoadNavigation = false @State private var suppressLoadNavigation = false
@State private var showingRuntimeGoalEditor = false @State private var showingRuntimeGoalEditor = false
@@ -390,7 +391,6 @@ struct SystemOverviewView: View {
.fill(Color(.tertiarySystemBackground)) .fill(Color(.tertiarySystemBackground))
) )
} }
.buttonStyle(.plain)
.alert(item: $activeStatus) { status in .alert(item: $activeStatus) { status in
let detail = status.detailInfo() let detail = status.detailInfo()
return Alert( return Alert(
@@ -588,14 +588,7 @@ struct SystemOverviewView: View {
} }
private var loadStatus: LoadConfigurationStatus? { private var loadStatus: LoadConfigurationStatus? {
guard !loads.isEmpty else { return nil } LoadConfigurationStatus.evaluate(loads: loads)
let incomplete = loads.filter { load in
load.length <= 0 || load.crossSection <= 0
}
if !incomplete.isEmpty {
return .missingDetails(count: incomplete.count)
}
return nil
} }
private var totalCurrent: Double { private var totalCurrent: Double {

View File

@@ -24,6 +24,28 @@ struct SettingsView: View {
} }
.pickerStyle(.segmented) .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 { Section {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 8) { HStack(spacing: 8) {

View File

@@ -852,19 +852,7 @@ struct SystemBillOfMaterialsView: View {
} }
private func awgFromCrossSection(_ crossSectionMM2: Double) -> Double { private func awgFromCrossSection(_ crossSectionMM2: Double) -> Double {
let mapping: [(awg: Double, area: Double)] = [ ElectricalCalculations.nearestAWG(forCrossSectionMM2: crossSectionMM2)
(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
} }
private var footerMessage: String { private var footerMessage: String {

View File

@@ -20,13 +20,22 @@ struct SystemComponentsPersistence {
batteries: existingBatteries, batteries: existingBatteries,
chargers: existingChargers chargers: existingChargers
) )
let defaultLength = 10.0
let defaultVoltage = 12.0
let defaultCurrent = 5.0
let newLoad = SavedLoad( let newLoad = SavedLoad(
name: loadName, name: loadName,
voltage: 12.0, voltage: defaultVoltage,
current: 5.0, current: defaultCurrent,
power: 60.0, power: defaultVoltage * defaultCurrent,
length: 10.0, length: defaultLength,
crossSection: 1.0, crossSection: ElectricalCalculations.recommendedCrossSection(
length: defaultLength,
current: defaultCurrent,
voltage: defaultVoltage,
unitSystem: .metric,
maxVoltageDropPercent: system.maxVoltageDropPercent
),
iconName: "lightbulb", iconName: "lightbulb",
colorName: "blue", colorName: "blue",
isWattMode: false, isWattMode: false,
@@ -69,13 +78,20 @@ struct SystemComponentsPersistence {
let dutyCyclePercent = item.normalizedDutyCyclePercent ?? 100 let dutyCyclePercent = item.normalizedDutyCyclePercent ?? 100
let dailyUsageHours = item.defaultDailyUsageHours ?? 1 let dailyUsageHours = item.defaultDailyUsageHours ?? 1
let length = 10.0
let newLoad = SavedLoad( let newLoad = SavedLoad(
name: loadName, name: loadName,
voltage: voltage, voltage: voltage,
current: current, current: current,
power: power, power: power,
length: 10.0, length: length,
crossSection: 1.0, crossSection: ElectricalCalculations.recommendedCrossSection(
length: length,
current: current,
voltage: voltage,
unitSystem: .metric,
maxVoltageDropPercent: system.maxVoltageDropPercent
),
iconName: "lightbulb", iconName: "lightbulb",
colorName: "blue", colorName: "blue",
isWattMode: item.watt != nil, isWattMode: item.watt != nil,

View File

@@ -12,6 +12,7 @@ struct SystemEditorView: View {
@Binding var location: String @Binding var location: String
@Binding var iconName: String @Binding var iconName: String
@Binding var colorName: String @Binding var colorName: String
@Binding var maxVoltageDropPercent: Double
@State private var tempLocation: String @State private var tempLocation: String
@@ -23,11 +24,22 @@ struct SystemEditorView: View {
"engine.combustion", "fuelpump", "drop", "flame", "snowflake", "thermometer" "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._systemName = systemName
self._location = location self._location = location
self._iconName = iconName self._iconName = iconName
self._colorName = colorName self._colorName = colorName
self._maxVoltageDropPercent = maxVoltageDropPercent
self._tempLocation = State(initialValue: location.wrappedValue) self._tempLocation = State(initialValue: location.wrappedValue)
} }
@@ -46,11 +58,41 @@ struct SystemEditorView: View {
colorName: $colorName, colorName: $colorName,
additionalFields: { additionalFields: {
AnyView( AnyView(
VStack(alignment: .leading, spacing: 12) {
TextField(locationPlaceholder, text: $tempLocation) TextField(locationPlaceholder, text: $tempLocation)
.autocapitalization(.words) .autocapitalization(.words)
.onChange(of: tempLocation) { _, newValue in .onChange(of: tempLocation) { _, newValue in
location = newValue 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 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 { #Preview {
@@ -65,6 +127,13 @@ struct SystemEditorView: View {
@Previewable @State var location = "Main Building" @Previewable @State var location = "Main Building"
@Previewable @State var icon = "building.2" @Previewable @State var icon = "building.2"
@Previewable @State var color = "blue" @Previewable @State var color = "blue"
@Previewable @State var dropTarget = 5.0
return SystemEditorView(systemName: $name, location: $location, iconName: $icon, colorName: $color) return SystemEditorView(
systemName: $name,
location: $location,
iconName: $icon,
colorName: $color,
maxVoltageDropPercent: $dropTarget
)
} }

View File

@@ -324,7 +324,8 @@ struct SystemsView: View {
name: systemName, name: systemName,
location: "", location: "",
iconName: resolvedIconName, iconName: resolvedIconName,
colorName: resolvedColorName colorName: resolvedColorName,
maxVoltageDropPercent: unitSettings.voltageDropTargetPercent
) )
modelContext.insert(newSystem) modelContext.insert(newSystem)
return newSystem return newSystem

View File

@@ -58,6 +58,13 @@ class UnitSystemSettings: ObservableObject {
AnalyticsTracker.log("Unit System Changed", properties: ["system": unitSystem.rawValue]) 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() { init() {
if let saved = UserDefaults.standard.string(forKey: "unitSystem"), if let saved = UserDefaults.standard.string(forKey: "unitSystem"),
@@ -66,5 +73,9 @@ class UnitSystemSettings: ObservableObject {
} else { } else {
self.unitSystem = Locale.current.measurementSystem == .us ? .imperial : .metric self.unitSystem = Locale.current.measurementSystem == .us ? .imperial : .metric
} }
let savedTarget = UserDefaults.standard.double(forKey: "voltageDropTargetPercent")
self.voltageDropTargetPercent = savedTarget > 0
? savedTarget
: ElectricalCalculations.defaultMaxVoltageDropPercent
} }
} }

View File

@@ -177,10 +177,6 @@
"bom.quantity.terminal.badge" = "%1$d× · %2$@"; "bom.quantity.terminal.badge" = "%1$d× · %2$@";
"bom.quantity.cable.badge" = "%1$.1f %2$@ · %3$@"; "bom.quantity.cable.badge" = "%1$.1f %2$@ · %3$@";
"bom.quantity.single.badge" = "1× • %@"; "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.helper" = "Prozentsatz der aktiven Zeit, in der die Last tatsächlich Leistung aufnimmt.";
"calculator.advanced.duty_cycle.title" = "Einschaltdauer"; "calculator.advanced.duty_cycle.title" = "Einschaltdauer";
"calculator.advanced.section.title" = "Erweitert"; "calculator.advanced.section.title" = "Erweitert";
@@ -259,7 +255,7 @@
"loads.overview.metric.current" = "Strom"; "loads.overview.metric.current" = "Strom";
"loads.overview.metric.power" = "Leistung"; "loads.overview.metric.power" = "Leistung";
"loads.overview.status.missing_details.banner" = "Konfiguration deiner Verbraucher abschließen"; "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.plural" = "Verbraucher";
"loads.overview.status.missing_details.singular" = "Verbraucher"; "loads.overview.status.missing_details.singular" = "Verbraucher";
"loads.overview.status.missing_details.title" = "Fehlende Verbraucherdetails"; "loads.overview.status.missing_details.title" = "Fehlende Verbraucherdetails";
@@ -349,47 +345,7 @@
"settings.pro.manage.url" = "https://apps.apple.com/account/subscriptions"; "settings.pro.manage.url" = "https://apps.apple.com/account/subscriptions";
"settings.pro.day.one" = "%@ Tag"; "settings.pro.day.one" = "%@ Tag";
"settings.pro.day.other" = "%@ Tage"; "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"; "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"; "• 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"; "• 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"; "• Follow all local electrical codes and regulations" = "• Beachte alle örtlichen Vorschriften und Normen";
@@ -453,3 +409,10 @@
"overview.share.diagram" = "Schaltplan"; "overview.share.diagram" = "Schaltplan";
"overview.share.pdf" = "Vollständiger Bericht (PDF)"; "overview.share.pdf" = "Vollständiger Bericht (PDF)";
"overview.share.diagram.error" = "Schaltplan konnte nicht erstellt werden. Überprüfe deine Internetverbindung."; "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.";

View File

@@ -196,7 +196,7 @@
"loads.onboarding.title" = "Añade tu primer consumidor"; "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.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.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.singular" = "carga";
"loads.overview.status.missing_details.plural" = "cargas"; "loads.overview.status.missing_details.plural" = "cargas";
"loads.overview.status.missing_details.banner" = "Completa la configuración de tus cargas"; "loads.overview.status.missing_details.banner" = "Completa la configuración de tus cargas";
@@ -351,11 +351,6 @@
"chargers.title" = "Cargadores para %@"; "chargers.title" = "Cargadores para %@";
"chargers.subtitle" = "Los componentes de carga estarán disponibles pronto."; "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"; "generic.ok" = "Aceptar";
// MARK: - PDF Overview Export // MARK: - PDF Overview Export
@@ -415,3 +410,10 @@
"overview.share.diagram" = "Diagrama de cableado"; "overview.share.diagram" = "Diagrama de cableado";
"overview.share.pdf" = "Informe completo (PDF)"; "overview.share.pdf" = "Informe completo (PDF)";
"overview.share.diagram.error" = "No se pudo generar el diagrama. Comprueba tu conexión a Internet."; "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.";

View File

@@ -196,7 +196,7 @@
"loads.onboarding.title" = "Ajoutez votre premier consommateur"; "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.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.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.singular" = "charge";
"loads.overview.status.missing_details.plural" = "charges"; "loads.overview.status.missing_details.plural" = "charges";
"loads.overview.status.missing_details.banner" = "Terminez la configuration de vos charges"; "loads.overview.status.missing_details.banner" = "Terminez la configuration de vos charges";
@@ -351,11 +351,6 @@
"chargers.title" = "Chargeurs pour %@"; "chargers.title" = "Chargeurs pour %@";
"chargers.subtitle" = "Les chargeurs seront bientôt disponibles ici."; "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"; "generic.ok" = "OK";
// MARK: - PDF Overview Export // MARK: - PDF Overview Export
@@ -415,3 +410,10 @@
"overview.share.diagram" = "Schéma de câblage"; "overview.share.diagram" = "Schéma de câblage";
"overview.share.pdf" = "Rapport complet (PDF)"; "overview.share.pdf" = "Rapport complet (PDF)";
"overview.share.diagram.error" = "Impossible de générer le schéma. Vérifiez votre connexion Internet."; "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.";

View File

@@ -196,7 +196,7 @@
"loads.onboarding.title" = "Voeg je eerste verbruiker toe"; "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.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.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.singular" = "last";
"loads.overview.status.missing_details.plural" = "lasten"; "loads.overview.status.missing_details.plural" = "lasten";
"loads.overview.status.missing_details.banner" = "Rond de configuratie van je lasten af"; "loads.overview.status.missing_details.banner" = "Rond de configuratie van je lasten af";
@@ -351,11 +351,6 @@
"chargers.title" = "Laders voor %@"; "chargers.title" = "Laders voor %@";
"chargers.subtitle" = "Ladercomponenten zijn binnenkort beschikbaar."; "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"; "generic.ok" = "OK";
// MARK: - PDF Overview Export // MARK: - PDF Overview Export
@@ -415,3 +410,10 @@
"overview.share.diagram" = "Bedradingsschema"; "overview.share.diagram" = "Bedradingsschema";
"overview.share.pdf" = "Volledig rapport (PDF)"; "overview.share.pdf" = "Volledig rapport (PDF)";
"overview.share.diagram.error" = "Diagram kon niet worden gegenereerd. Controleer je internetverbinding."; "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.";

View File

@@ -354,8 +354,9 @@ struct CableTests {
) )
// Minimum raw cross-section: (2×15×7.62×0.017)/(120×0.05) = 0.648mm² // Minimum raw cross-section: (2×15×7.62×0.017)/(120×0.05) = 0.648mm²
// Metric: rounds to 0.75mm² (smallest standard ≥ 0.648) // Metric: 0.75mm² covers the voltage drop but only carries 16A (ISO 13297, 105 °C),
#expect(metricMinCS == 0.75) // 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) // Imperial: AWG 18 (0.823mm² ≥ 0.648)
#expect(imperialAWG == 18.0) #expect(imperialAWG == 18.0)
} }
@@ -412,4 +413,208 @@ struct CableTests {
#expect(abs(actualDrop - expectedDrop) < 0.001) #expect(abs(actualDrop - expectedDrop) < 0.001)
#expect(abs(actualLoss - expectedPowerLoss) < 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)
}
} }

View 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)
}

View 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()
}
}
}

View 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()
}
}
}