From eb9efee9af80b382ddea3dc1f87e3feef5a81661 Mon Sep 17 00:00:00 2001 From: Stefan Lange-Hegermann Date: Tue, 18 Aug 2026 10:59:38 +0200 Subject: [PATCH] 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. --- CableTests/LocalizationIntegrityTests.swift | 75 +++++++++++++++++++ .../LocalizedTabBarUITests.swift | 62 +++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 CableTests/LocalizationIntegrityTests.swift create mode 100644 CableUITestsScreenshot/LocalizedTabBarUITests.swift diff --git a/CableTests/LocalizationIntegrityTests.swift b/CableTests/LocalizationIntegrityTests.swift new file mode 100644 index 0000000..d9c48f3 --- /dev/null +++ b/CableTests/LocalizationIntegrityTests.swift @@ -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) +} diff --git a/CableUITestsScreenshot/LocalizedTabBarUITests.swift b/CableUITestsScreenshot/LocalizedTabBarUITests.swift new file mode 100644 index 0000000..ab90356 --- /dev/null +++ b/CableUITestsScreenshot/LocalizedTabBarUITests.swift @@ -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() + } + } +}