bitchat/bitchatTests/LocalizationCoverageTests.swift
jack eca147191c
Add 26 code-referenced keys to the catalog + a test that closes the gap (#1654)
LocalizationCoverageTests validated the catalog but never the code: a
String(localized:) whose key is missing from every catalog compiles
fine and silently ships its English defaultValue to all 29 non-source
locales. That blind spot let 26 keys go untranslated while CI stayed
green: the entire notices/board composer (10), the private-media
encryption warnings and delivery-failure reasons (9), both
courier-header strings, two delivery states, two media failure
reasons, and the [? people] channel row.

- All 26 keys added with full 30-locale coverage.
- New everyCodeReferencedKeyExistsInACatalog scans the source for
  literal String(localized:) keys and fails on any key absent from
  both catalogs. (The audit's original count of 17 was itself an
  undercount — its scan missed multi-line String( localized: calls;
  the test's regex does not.)

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 20:03:23 +02:00

114 lines
5.7 KiB
Swift

import Testing
import Foundation
/// Guards against locale gaps in the string catalogs: every translatable key
/// must have a localization for every supported locale, so no user ever sees
/// an English fallback (see PR #1391 review).
struct LocalizationCoverageTests {
private static let repoRoot = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent() // bitchatTests
.deletingLastPathComponent() // repo root
private struct Catalog {
/// key -> set of locales with a localization entry
let coverage: [String: Set<String>]
/// all locales appearing anywhere in the catalog
var allLocales: Set<String> { coverage.values.reduce(into: []) { $0.formUnion($1) } }
}
private static func loadCatalog(_ relativePath: String) throws -> Catalog {
let url = repoRoot.appendingPathComponent(relativePath)
let data = try Data(contentsOf: url)
let root = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any])
let strings = try #require(root["strings"] as? [String: Any])
var coverage: [String: Set<String>] = [:]
for (key, value) in strings {
guard let entry = value as? [String: Any] else { continue }
if entry["shouldTranslate"] as? Bool == false { continue }
let localizations = entry["localizations"] as? [String: Any] ?? [:]
var locales: Set<String> = []
for (locale, loc) in localizations {
guard let loc = loc as? [String: Any] else { continue }
// A localization counts if it has a non-empty stringUnit value
// or uses variations/substitutions (plural forms).
if let unit = loc["stringUnit"] as? [String: Any],
let unitValue = unit["value"] as? String, !unitValue.isEmpty {
locales.insert(locale)
} else if loc["variations"] != nil || loc["substitutions"] != nil {
locales.insert(locale)
}
}
coverage[key] = locales
}
return Catalog(coverage: coverage)
}
@Test func mainCatalogCoversAllLocalesForEveryKey() throws {
let catalog = try Self.loadCatalog("bitchat/Localizable.xcstrings")
let expected = catalog.allLocales
#expect(expected.count > 1, "catalog should declare more locales than the source language")
for (key, locales) in catalog.coverage.sorted(by: { $0.key < $1.key }) {
let missing = expected.subtracting(locales).sorted()
#expect(missing.isEmpty, "\(key) is missing locales: \(missing.joined(separator: ", "))")
}
}
@Test func shareExtensionCatalogCoversAllLocalesForEveryKey() throws {
let catalog = try Self.loadCatalog("bitchatShareExtension/Localization/Localizable.xcstrings")
let expected = catalog.allLocales
for (key, locales) in catalog.coverage.sorted(by: { $0.key < $1.key }) {
let missing = expected.subtracting(locales).sorted()
#expect(missing.isEmpty, "\(key) is missing locales: \(missing.joined(separator: ", "))")
}
}
@Test func shareExtensionSupportsSameLocalesAsMainApp() throws {
let main = try Self.loadCatalog("bitchat/Localizable.xcstrings")
let shareExt = try Self.loadCatalog("bitchatShareExtension/Localization/Localizable.xcstrings")
let missing = main.allLocales.subtracting(shareExt.allLocales).sorted()
#expect(missing.isEmpty, "share extension is missing locales: \(missing.joined(separator: ", "))")
}
/// The catalog tests above validate the CATALOG; this one validates the
/// CODE. A `String(localized:)` whose key is absent from every catalog
/// compiles and runs fine it just silently ships its English
/// `defaultValue` to all 29 non-source locales. That blind spot let the
/// entire notices/board composer (10 keys), two delivery states, and two
/// media-failure reasons go untranslated while the coverage tests stayed
/// green. Interpolated keys can't be checked statically and are skipped;
/// every literal key must resolve.
@Test func everyCodeReferencedKeyExistsInACatalog() throws {
let main = try Self.loadCatalog("bitchat/Localizable.xcstrings")
let shareExt = try Self.loadCatalog("bitchatShareExtension/Localization/Localizable.xcstrings")
let knownKeys = Set(main.coverage.keys).union(shareExt.coverage.keys)
let pattern = try NSRegularExpression(pattern: #"String\(\s*localized:\s*"([^"\\]+)""#)
var missing: [String] = []
for sourceRoot in ["bitchat", "bitchatShareExtension"] {
let rootURL = Self.repoRoot.appendingPathComponent(sourceRoot)
let enumerator = try #require(FileManager.default.enumerator(
at: rootURL,
includingPropertiesForKeys: nil
))
for case let fileURL as URL in enumerator where fileURL.pathExtension == "swift" {
let source = try String(contentsOf: fileURL, encoding: .utf8)
let range = NSRange(source.startIndex..., in: source)
pattern.enumerateMatches(in: source, range: range) { match, _, _ in
guard let match, let keyRange = Range(match.range(at: 1), in: source) else { return }
let key = String(source[keyRange])
if !knownKeys.contains(key) {
missing.append("\(key) (\(fileURL.lastPathComponent))")
}
}
}
}
#expect(
missing.isEmpty,
"keys referenced in code but absent from every catalog these ship English to all non-source locales: \(missing.sorted().joined(separator: ", "))"
)
}
}