Files
Cable/CableTests/CableTests.swift
Stefan Lange-Hegermann ea3b60d75c Fix AWG notation, add alternator type, migrate to String(localized:)
- Fix AWG 0/00/000/0000 bug (all resolved to 0 in Swift) using negative
  int convention (-1 through -4) with formatAWG() for 1/0–4/0 display
- Add 7.5A fuse size and change fuse type from Int to Double
- Add alternator power source type with distinct bolt.car.fill icon
- Migrate all NSLocalizedString calls to String(localized:defaultValue:)
- Update translations for runtime subtitle (ES/FR/NL: current→maximum),
  usable capacity footer text, and NL override wording
- Store length always in meters, convert at display time in CalculatorView
- Add preview-friendly inits for ComponentLibraryView and LoadsView
- Expand test coverage for calculations, fuses, AWG, and edge cases

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 10:37:53 +01:00

416 lines
16 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// CableTests.swift
// CableTests
//
// Created by Stefan Lange-Hegermann on 11.09.25.
//
import Testing
@testable import Cable
struct CableTests {
// MARK: - Core Formula Verification
/// Formula: A = (2 × I × L × ρ) / V_drop
/// With ρ = 0.017 Ω·mm²/m, max voltage drop = 5%
@Test func metricWireSizingUsesNearestStandardSize() async throws {
// 10m, 5A, 12V minCS = (2×5×10×0.017)/(12×0.05) = 2.833mm² rounds up to 4.0mm²
let crossSection = ElectricalCalculations.recommendedCrossSection(
length: 10,
current: 5,
voltage: 12,
unitSystem: .metric
)
#expect(crossSection == 4.0)
// V_drop = (2×5×10×0.017)/4.0 = 0.425V
let voltageDrop = ElectricalCalculations.voltageDrop(
length: 10,
current: 5,
voltage: 12,
unitSystem: .metric
)
#expect(abs(voltageDrop - 0.425) < 0.001)
// 0.425/12 × 100 = 3.5417%
let dropPercentage = ElectricalCalculations.voltageDropPercentage(
length: 10,
current: 5,
voltage: 12,
unitSystem: .metric
)
#expect(abs(dropPercentage - 3.5417) < 0.001)
// P_loss = I × V_drop = 5 × 0.425 = 2.125W
let powerLoss = ElectricalCalculations.powerLoss(
length: 10,
current: 5,
voltage: 12,
unitSystem: .metric
)
#expect(abs(powerLoss - 2.125) < 0.001)
}
/// Imperial test: length is always in meters (25 ft = 7.62 m), unitSystem controls AWG output
@Test func imperialWireSizingMatchesExpectedGauge() async throws {
let lengthInMeters = 25.0 * 0.3048 // 25 ft = 7.62 m
let awg = ElectricalCalculations.recommendedCrossSection(
length: lengthInMeters,
current: 15,
voltage: 120,
unitSystem: .imperial
)
// minCS = (2×15×7.62×0.017)/(120×0.05) = 0.648mm² AWG 18 (0.823mm²)
#expect(awg == 18.0)
let voltageDrop = ElectricalCalculations.voltageDrop(
length: lengthInMeters,
current: 15,
voltage: 120,
unitSystem: .imperial
)
// (2×15×7.62×0.017)/0.823 = 4.722V
#expect(abs(voltageDrop - 4.722) < 0.01)
let dropPercentage = ElectricalCalculations.voltageDropPercentage(
length: lengthInMeters,
current: 15,
voltage: 120,
unitSystem: .imperial
)
// 4.722/120 × 100 = 3.935%
#expect(abs(dropPercentage - 3.935) < 0.01)
let powerLoss = ElectricalCalculations.powerLoss(
length: lengthInMeters,
current: 15,
voltage: 120,
unitSystem: .imperial
)
// 15 × 4.722 = 70.83W
#expect(abs(powerLoss - 70.83) < 0.05)
}
// MARK: - Fuse Sizing
@Test func recommendedFuseRoundsUpToNearestStandardSize() async throws {
// 7.2A × 1.25 = 9.0 next fuse 9 = 10A
#expect(ElectricalCalculations.recommendedFuse(forCurrent: 7.2) == 10.0)
// 59A × 1.25 = 73.75 ceil = 74 next fuse 74 = 80A
#expect(ElectricalCalculations.recommendedFuse(forCurrent: 59.0) == 80.0)
}
@Test func fuseAt125PercentOfCurrent() async throws {
// 10A × 1.25 = 12.5 ceil = 13 next fuse = 15A
#expect(ElectricalCalculations.recommendedFuse(forCurrent: 10.0) == 15.0)
// 20A × 1.25 = 25 next fuse = 25A
#expect(ElectricalCalculations.recommendedFuse(forCurrent: 20.0) == 25.0)
// 1A × 1.25 = 1.25 ceil = 2 next fuse = 2A
#expect(ElectricalCalculations.recommendedFuse(forCurrent: 1.0) == 2.0)
// 4A × 1.25 = 5 next fuse = 5A
#expect(ElectricalCalculations.recommendedFuse(forCurrent: 4.0) == 5.0)
// 100A × 1.25 = 125 next fuse = 125A
#expect(ElectricalCalculations.recommendedFuse(forCurrent: 100.0) == 125.0)
}
@Test func fuseSelectsHalfAmpereSizes() async throws {
// 5A × 1.25 = 6.25 ceil = 7 next fuse 7 = 7.5A
#expect(ElectricalCalculations.recommendedFuse(forCurrent: 5.0) == 7.5)
// 6A × 1.25 = 7.5 ceil = 8 next fuse 8 = 10A
#expect(ElectricalCalculations.recommendedFuse(forCurrent: 6.0) == 10.0)
}
@Test func fuseForZeroCurrent() async throws {
#expect(ElectricalCalculations.recommendedFuse(forCurrent: 0) == 1.0)
}
// MARK: - Metric Cross-Section Boundaries
@Test func metricCrossSectionSelectsSmallestAdequateSize() async throws {
// Very small load: 1m, 0.5A, 12V
// minCS = (2×0.5×1×0.017)/(12×0.05) = 0.0283mm² rounds up to 0.75mm² (smallest standard)
let cs = ElectricalCalculations.recommendedCrossSection(
length: 1, current: 0.5, voltage: 12, unitSystem: .metric
)
#expect(cs == 0.75)
}
@Test func metricCrossSectionForHighCurrent() async throws {
// 15m, 80A, 12V
// minCS = (2×80×15×0.017)/(12×0.05) = 40.8/0.6 = 68mm² rounds up to 70mm²
let cs = ElectricalCalculations.recommendedCrossSection(
length: 15, current: 80, voltage: 12, unitSystem: .metric
)
#expect(cs == 70.0)
}
@Test func metricCrossSectionFor24VSystem() async throws {
// 10m, 20A, 24V
// minCS = (2×20×10×0.017)/(24×0.05) = 6.8/1.2 = 5.667mm² rounds up to 6.0mm²
let cs = ElectricalCalculations.recommendedCrossSection(
length: 10, current: 20, voltage: 24, unitSystem: .metric
)
#expect(cs == 6.0)
}
@Test func metricCrossSectionFor48VSystem() async throws {
// 10m, 20A, 48V
// minCS = (2×20×10×0.017)/(48×0.05) = 6.8/2.4 = 2.833mm² rounds up to 4.0mm²
let cs = ElectricalCalculations.recommendedCrossSection(
length: 10, current: 20, voltage: 48, unitSystem: .metric
)
#expect(cs == 4.0)
}
// MARK: - Imperial AWG Selection (including 1/0 through 4/0)
@Test func imperialAWGLargeGauges() async throws {
// Very high current should select large AWG sizes (represented as negative ints)
// 10m, 100A, 12V minCS = (2×100×10×0.017)/(12×0.05) = 34/0.6 = 56.67mm²
// AWG: first awgCS 56.67 67.4 = 2/0 (represented as -2)
let awg = ElectricalCalculations.recommendedCrossSection(
length: 10, current: 100, voltage: 12, unitSystem: .imperial
)
#expect(awg == -2.0)
// Verify voltage drop uses the correct cross-section (67.4mm² for 2/0)
let drop = ElectricalCalculations.voltageDrop(
length: 10, current: 100, voltage: 12,
unitSystem: .imperial, crossSection: awg
)
// (2×100×10×0.017)/67.4 = 34/67.4 = 0.5045V
#expect(abs(drop - 0.5045) < 0.01)
}
@Test func imperialAWG4over0ForExtremeCurrent() async throws {
// 5m, 200A, 12V minCS = (2×200×5×0.017)/(12×0.05) = 34/0.6 = 56.67mm²
// Wait: (2×200×5×0.017)/0.6 = 34/0.6 = 56.67 67.4 (2/0)
// 10m, 200A, 12V minCS = (2×200×10×0.017)/(12×0.05) = 68/0.6 = 113.33mm²
// AWG: first awgCS 113.33 none! returns last AWG = -4 (4/0, 107mm²)
let awg = ElectricalCalculations.recommendedCrossSection(
length: 10, current: 200, voltage: 12, unitSystem: .imperial
)
#expect(awg == -4.0)
}
@Test func formatAWGDisplaysCorrectNotation() async throws {
#expect(ElectricalCalculations.formatAWG(14) == "14")
#expect(ElectricalCalculations.formatAWG(10) == "10")
#expect(ElectricalCalculations.formatAWG(1) == "1")
#expect(ElectricalCalculations.formatAWG(-1) == "1/0")
#expect(ElectricalCalculations.formatAWG(-2) == "2/0")
#expect(ElectricalCalculations.formatAWG(-3) == "3/0")
#expect(ElectricalCalculations.formatAWG(-4) == "4/0")
}
@Test func imperialAWGForBoatLoad() async throws {
// Typical boat scenario: 5m (16ft), 10A, 12V
// minCS = (2×10×5×0.017)/(12×0.05) = 1.7/0.6 = 2.833mm²
// AWG: first >= 2.833 3.31mm² = 12 AWG
let awg = ElectricalCalculations.recommendedCrossSection(
length: 5, current: 10, voltage: 12, unitSystem: .imperial
)
#expect(awg == 12.0)
}
@Test func imperialAWGForHighCurrentLoad() async throws {
// 3m, 50A, 12V
// minCS = (2×50×3×0.017)/(12×0.05) = 5.1/0.6 = 8.5mm²
// AWG: first >= 8.5 13.3mm² = 6 AWG
let awg = ElectricalCalculations.recommendedCrossSection(
length: 3, current: 50, voltage: 12, unitSystem: .imperial
)
#expect(awg == 6.0)
}
// MARK: - Voltage Drop with Explicit Cross-Section
@Test func voltageDropWithExplicitCrossSection_metric() async throws {
// 5m, 10A, 12V, 2.5mm²
// V_drop = (2×10×5×0.017)/2.5 = 1.7/2.5 = 0.68V
let drop = ElectricalCalculations.voltageDrop(
length: 5, current: 10, voltage: 12,
unitSystem: .metric, crossSection: 2.5
)
#expect(abs(drop - 0.68) < 0.001)
let pct = ElectricalCalculations.voltageDropPercentage(
length: 5, current: 10, voltage: 12,
unitSystem: .metric, crossSection: 2.5
)
// 0.68/12 × 100 = 5.667%
#expect(abs(pct - 5.667) < 0.01)
}
@Test func voltageDropWithExplicitCrossSection_imperial() async throws {
// 3m, 15A, 12V, AWG 10 (= 5.26mm²)
// V_drop = (2×15×3×0.017)/5.26 = 1.53/5.26 = 0.2909V
let drop = ElectricalCalculations.voltageDrop(
length: 3, current: 15, voltage: 12,
unitSystem: .imperial, crossSection: 10 // AWG 10
)
#expect(abs(drop - 0.2909) < 0.01)
}
// MARK: - Power Loss Verification
@Test func powerLossEqualsCurrentTimesVoltageDrop() async throws {
let length = 8.0
let current = 12.0
let voltage = 24.0
let drop = ElectricalCalculations.voltageDrop(
length: length, current: current, voltage: voltage, unitSystem: .metric
)
let loss = ElectricalCalculations.powerLoss(
length: length, current: current, voltage: voltage, unitSystem: .metric
)
// P_loss = I × V_drop
#expect(abs(loss - current * drop) < 0.001)
}
// MARK: - Edge Cases
@Test func zeroLengthProducesZeroDrop() async throws {
let drop = ElectricalCalculations.voltageDrop(
length: 0, current: 10, voltage: 12, unitSystem: .metric
)
#expect(drop == 0)
let loss = ElectricalCalculations.powerLoss(
length: 0, current: 10, voltage: 12, unitSystem: .metric
)
#expect(loss == 0)
}
@Test func zeroCurrentProducesZeroDrop() async throws {
let drop = ElectricalCalculations.voltageDrop(
length: 10, current: 0, voltage: 12, unitSystem: .metric
)
#expect(drop == 0)
}
@Test func zeroVoltageReturnsZeroPercentage() async throws {
let pct = ElectricalCalculations.voltageDropPercentage(
length: 10, current: 5, voltage: 0, unitSystem: .metric
)
#expect(pct == 0)
}
@Test func zeroVoltageReturnsZeroCrossSection() async throws {
// With 0V, maxVoltageDrop = 0, guardAgainstZero returns 0 smallest standard
let cs = ElectricalCalculations.recommendedCrossSection(
length: 10, current: 5, voltage: 0, unitSystem: .metric
)
#expect(cs == 0.75)
}
// MARK: - Metric and Imperial Consistency
@Test func metricAndImperialGiveSamePhysicalVoltageDrop() async throws {
// Same physical setup: 10m cable, 15A, 12V
// Metric: recommended cross-section in mm²
// Imperial: recommended cross-section in AWG
let lengthMeters = 10.0
let metricCS = ElectricalCalculations.recommendedCrossSection(
length: lengthMeters, current: 15, voltage: 12, unitSystem: .metric
)
let imperialAWG = ElectricalCalculations.recommendedCrossSection(
length: lengthMeters, current: 15, voltage: 12, unitSystem: .imperial
)
let metricDrop = ElectricalCalculations.voltageDrop(
length: lengthMeters, current: 15, voltage: 12,
unitSystem: .metric, crossSection: metricCS
)
let imperialDrop = ElectricalCalculations.voltageDrop(
length: lengthMeters, current: 15, voltage: 12,
unitSystem: .imperial, crossSection: imperialAWG
)
// Both should be within 5% voltage drop constraint
#expect(metricDrop / 12 <= 0.05)
#expect(imperialDrop / 12 <= 0.05)
// Both drops should be positive
#expect(metricDrop > 0)
#expect(imperialDrop > 0)
}
// MARK: - Unit Convention: Length Always in Meters
@Test func lengthParameterIsAlwaysMeters() async throws {
// Passing the same meter value with both unit systems should give
// the same underlying calculation, only differing in output format.
let lengthMeters = 7.62 // = 25 feet
let metricMinCS = ElectricalCalculations.recommendedCrossSection(
length: lengthMeters, current: 15, voltage: 120, unitSystem: .metric
)
let imperialAWG = ElectricalCalculations.recommendedCrossSection(
length: lengthMeters, current: 15, voltage: 120, unitSystem: .imperial
)
// 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)
// Imperial: AWG 18 (0.823mm² 0.648)
#expect(imperialAWG == 18.0)
}
// MARK: - Voltage Drop Constraint
@Test func recommendedCrossSectionKeepsDropBelow5Percent() async throws {
// Test several scenarios to verify the 5% constraint
let scenarios: [(length: Double, current: Double, voltage: Double)] = [
(1, 1, 12), // minimal
(5, 10, 12), // moderate boat load
(10, 20, 24), // 24V system
(15, 50, 12), // high current
(3, 100, 48), // very high current, 48V
(20, 5, 12), // long run, low current
]
for s in scenarios {
let cs = ElectricalCalculations.recommendedCrossSection(
length: s.length, current: s.current, voltage: s.voltage, unitSystem: .metric
)
let dropPct = ElectricalCalculations.voltageDropPercentage(
length: s.length, current: s.current, voltage: s.voltage,
unitSystem: .metric, crossSection: cs
)
#expect(dropPct <= 5.0, "Drop \(dropPct)% exceeds 5% for \(s.length)m, \(s.current)A, \(s.voltage)V with \(cs)mm²")
}
}
// MARK: - Formula Cross-Check: V_drop = I²R path
@Test func voltageDropMatchesOhmsLaw() async throws {
let length = 6.0
let current = 8.0
let crossSection = 4.0 // mm²
let resistivity = 0.017
// R = (2 × L × ρ) / A = (2 × 6 × 0.017) / 4 = 0.051 Ω
let resistance = (2 * length * resistivity) / crossSection
// V = I × R = 8 × 0.051 = 0.408V
let expectedDrop = current * resistance
// P = I² × R = 64 × 0.051 = 3.264W
let expectedPowerLoss = current * current * resistance
let actualDrop = ElectricalCalculations.voltageDrop(
length: length, current: current, voltage: 12,
unitSystem: .metric, crossSection: crossSection
)
let actualLoss = ElectricalCalculations.powerLoss(
length: length, current: current, voltage: 12,
unitSystem: .metric, crossSection: crossSection
)
#expect(abs(actualDrop - expectedDrop) < 0.001)
#expect(abs(actualLoss - expectedPowerLoss) < 0.001)
}
}