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.
This commit is contained in:
2026-08-18 10:59:38 +02:00
parent 38183f5282
commit eb9efee9af
2 changed files with 137 additions and 0 deletions

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