Files
bitchat/bitchatTests/Localization/LocalizationCatalogTests.swift
T
0f7bcf17ff Refactor: Migrate to Swift String Catalogs (.xcstrings) (#691)
* Automated update of relay data - Sun Sep 21 06:26:33 UTC 2025

* chore(l10n): add empty string catalogs

* chore(l10n): populate string catalogs from legacy resources

* test(l10n): add catalog guardrail suite

* chore(l10n): remove legacy localization files

* fix: Add localization resources to Package.swift targets

- Add .process("Localization") to bitchat target resources
- Add .process("Localization") to bitchatTests target resources
- Resolves Bundle.module resource loading for localization files
- Enables proper localization testing in Swift Package Manager builds

* feat: Add Korean localization and convert to UTF-8 format

Korean Language Support:
- Add complete Korean (ko) localization with 191 strings from PR #686
- Include all app strings: UI, features, system messages, alerts
- Include all share extension strings: status messages, errors
- Verified 100% translation coverage for Korean locale

UTF-8 Format Conversion:
- Convert 23,047 Unicode escape sequences to readable UTF-8 characters
- Transform \u sequences (e.g. \u0625\u063a\u0644\u0627\u0642) to native text (إغلاق)
- Improve maintainability across all 15 supported locales
- Preserve all existing translations while enhancing readability

Locales supported: en, ar, de, es, fr, he, id, it, ja, ko, ne, pt-BR, ru, uk, zh-Hans

* test: Enhance dynamic localization test framework

Dynamic Test Framework:
- Replace hardcoded locale tests with data-driven approach
- Add testLocalizationExpectedValues() for dynamic locale validation
- Add testConfiguredLocalesCompleteness() for coverage verification
- Tests now read configuration from PrimaryLocalizationKeys.json

Expanded Test Coverage:
- Increase from 14 to 33 key validations (135% increase)
- Add critical UI strings: common actions, app info, security, sharing
- Cover 364 total string validations across 15 locales
- Include Korean validation with native Korean expected values

Test Categories Added:
- Common UI: cancel, close, copy actions
- App Info: encryption, offline features, app name
- Bluetooth: permission and settings alerts
- Security: verification badges and actions
- Share Extension: all status and error messages
- Content Actions: accessibility and user actions

Maintains 100% test success rate across all supported locales.

* fix: Convert plural strings to correct xcstrings format

Three plural strings (content.accessibility.people_count,
location_channels.row_title, location_notes.header) were using
incorrect format causing runtime String(format:) errors.

Migrated from stringUnit.variations structure to proper
substitutions.variations format across all 14 languages.

* refactor(l10n): migrate to native String(localized:) APIs

Remove L10n.string wrapper in favor of Swift's native localization APIs.
Migrate 100+ localization call sites to use String(localized:) and String(localized:defaultValue:) with string interpolation.

- Update catalog to use interpolation syntax (\(var)) instead of format specifiers (%@)
- Migrate simple strings to String(localized:)
- Migrate strings with arguments to String(localized:defaultValue:) with interpolation
- Keep format strings for plural substitutions (String(format:locale:))
- Remove bitchat/Utils/Localization.swift

Net result: -407 insertions, +130 deletions across 15 files

* fix(l10n): correct interpolation to use format strings

Interpolation in String(localized:defaultValue:) doesn't work as expected -
the interpolation happens at the call site before localization lookup.

Convert dynamic strings to use String(format:String(localized:),args) pattern:
- Update catalog entries from \(var) syntax to %@ placeholders
- Wrap String(localized:) calls with String(format:locale:) for dynamic values
- Affects 17 strings across 6 files

This fixes UI showing literal "\(geohash)" text instead of actual values.

* chore(l10n): remove invalid catalog entries

Remove auto-extracted literal strings (@, #, ✔︎, @%@, bitchat/) that were
generated without proper localization structure. These caused test
decoding failures.

* fix(l10n): remove unused auto-extracted format string

Remove '%@/%@' key that was auto-extracted by Xcode but never used.
This key only existed in English causing locale parity test failures
across all 13 other languages.

Fixes locale parity tests - all 8 localization tests now pass with
only expected failures (incomplete translations in some locales).

* fix(l10n): copy format string to all locales for 100% completion

Add %@/%@ format string to all 14 non-English locales. Format strings
are locale-independent so using the same value everywhere is correct.

This brings all locales to 100% completion (189/189 strings) to prevent
Xcode from reporting incomplete translations when building.

* fix(l10n): prevent auto-extraction of UI literals

Use Text(verbatim:) for non-localizable UI elements:
- App branding ("bitchat/")
- Symbols (@, #, ✔︎)
- Dynamic usernames (@username)
- Count ratios (reached/total)

This prevents Xcode from auto-extracting these literals into the
String Catalog when building through Xcode GUI, which was causing
locales to show 96% completion instead of 100%.

* chore(l10n): remove auto-extracted UI literal entries

Delete 5 auto-extracted keys from catalog that are now using Text(verbatim:):
- @, #, ✔︎, %@, %@/%@

These were showing as stale/incomplete in Xcode causing 97% completion.
All locales now at 100% (188/188 strings).

* fix(l10n): prevent AttributedString from extracting @ symbol

Use string interpolation "\\(at)" instead of literal "@" in
AttributedString to prevent Xcode from auto-extracting it to the
String Catalog during build.

This was the last string causing locales to show 99% instead of 100%.

* fix(l10n): add %@ as non-translatable key in all locales

Mark %@ as non-translatable and add to all 15 locales with same value.
This prevents Xcode from showing incomplete translations when it
auto-extracts this format specifier during GUI builds.

All locales remain at 100% (189/189 strings).

* refactor: move Localizable.xcstrings to bitchat root

Move bitchat/Localization/Localizable.xcstrings to bitchat/ (after LaunchScreen)
and remove empty Localization directory.

* fix(test): update catalog path in localization tests

Update test paths from bitchat/Localization/Localizable.xcstrings to
bitchat/Localizable.xcstrings after moving the file.

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-09-29 22:31:59 +02:00

368 lines
15 KiB
Swift

import Foundation
import XCTest
private let localizationTestsDirectoryURL = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
private let testsRootURL = localizationTestsDirectoryURL.deletingLastPathComponent()
private let repoRootURL = testsRootURL.deletingLastPathComponent()
final class LocalizationCatalogTests: XCTestCase {
// Ensures every app locale includes exactly the same keys as Base.
func testAppCatalogLocaleParity() throws {
let context = try loadContext(relativePath: "bitchat/Localizable.xcstrings")
assertLocaleParity(context: context, catalogName: "App")
}
// Verifies format placeholders stay consistent across app locales.
func testAppCatalogPlaceholderConsistency() throws {
let context = try loadContext(relativePath: "bitchat/Localizable.xcstrings")
assertPlaceholderConsistency(context: context, catalogName: "App")
}
// Guards a core set of app strings from going empty per locale.
func testAppPrimaryKeysNonEmpty() throws {
let context = try loadContext(relativePath: "bitchat/Localizable.xcstrings")
let primaryKeys = try loadPrimaryKeys().app
assertPrimaryKeysPresent(context: context, keys: primaryKeys, catalogName: "App")
}
// Ensures every share extension locale matches Base key coverage.
func testShareExtensionCatalogLocaleParity() throws {
let context = try loadContext(relativePath: "bitchatShareExtension/Localization/Localizable.xcstrings")
assertLocaleParity(context: context, catalogName: "ShareExtension")
}
// Verifies share extension placeholders align across locales.
func testShareExtensionCatalogPlaceholderConsistency() throws {
let context = try loadContext(relativePath: "bitchatShareExtension/Localization/Localizable.xcstrings")
assertPlaceholderConsistency(context: context, catalogName: "ShareExtension")
}
// Confirms critical share extension strings remain non-empty per locale.
func testShareExtensionPrimaryKeysNonEmpty() throws {
let context = try loadContext(relativePath: "bitchatShareExtension/Localization/Localizable.xcstrings")
let primaryKeys = try loadPrimaryKeys().shareExtension
assertPrimaryKeysPresent(context: context, keys: primaryKeys, catalogName: "ShareExtension")
}
// Validates that configured locales contain expected string values.
func testLocalizationExpectedValues() throws {
let appContext = try loadContext(relativePath: "bitchat/Localizable.xcstrings")
let shareContext = try loadContext(relativePath: "bitchatShareExtension/Localization/Localizable.xcstrings")
let config = try loadPrimaryKeys()
guard let testLocales = config.testLocales else {
// If no testLocales specified, skip this test
return
}
guard let expectedValues = config.expectedValues else {
XCTFail("No expectedValues configured in PrimaryLocalizationKeys.json")
return
}
// Loop through each locale to test
for locale in testLocales {
guard let localeExpectedValues = expectedValues[locale] else {
XCTFail("No expected values configured for locale '\(locale)' in PrimaryLocalizationKeys.json")
continue
}
// Test each expected key/value pair for this locale
for (key, expectedValue) in localeExpectedValues {
if config.app.contains(key) {
assertLocaleStringValue(context: appContext, locale: locale, key: key, expectedValue: expectedValue, catalogName: "App")
} else if config.shareExtension.contains(key) {
assertLocaleStringValue(context: shareContext, locale: locale, key: key, expectedValue: expectedValue, catalogName: "ShareExtension")
}
}
}
}
// Ensures configured test locales are present and complete.
func testConfiguredLocalesCompleteness() throws {
let appContext = try loadContext(relativePath: "bitchat/Localizable.xcstrings")
let shareContext = try loadContext(relativePath: "bitchatShareExtension/Localization/Localizable.xcstrings")
let config = try loadPrimaryKeys()
guard let testLocales = config.testLocales else {
// If no testLocales specified, skip this test
return
}
let baseLocale = appContext.baseLocale
let baseAppKeys = appContext.keysByLocale[baseLocale] ?? Set()
let baseShareKeys = shareContext.keysByLocale[baseLocale] ?? Set()
for locale in testLocales {
// Skip base locale comparison with itself
if locale == baseLocale { continue }
// Verify locale is present in both catalogs
XCTAssertTrue(appContext.locales.contains(locale), "Locale '\(locale)' missing from app catalog")
XCTAssertTrue(shareContext.locales.contains(locale), "Locale '\(locale)' missing from share extension catalog")
// Verify locale has same number of keys as base locale
let appLocaleKeys = appContext.keysByLocale[locale] ?? Set()
XCTAssertEqual(appLocaleKeys.count, baseAppKeys.count, "Locale '\(locale)' app catalog missing keys compared to \(baseLocale)")
let shareLocaleKeys = shareContext.keysByLocale[locale] ?? Set()
XCTAssertEqual(shareLocaleKeys.count, baseShareKeys.count, "Locale '\(locale)' share extension catalog missing keys compared to \(baseLocale)")
}
}
// MARK: - Assertions
private func assertLocaleParity(context: CatalogContext, catalogName: String, file: StaticString = #filePath, line: UInt = #line) {
let baseLocale = context.baseLocale
guard let baseKeys = context.keysByLocale[baseLocale] else {
return XCTFail("Missing base locale \(baseLocale) in \(catalogName) catalog", file: file, line: line)
}
for (locale, keys) in context.keysByLocale.sorted(by: { $0.key < $1.key }) {
XCTAssertEqual(keys, baseKeys, "Locale \(locale) has key mismatch in \(catalogName) catalog", file: file, line: line)
}
}
private func assertPlaceholderConsistency(context: CatalogContext, catalogName: String, file: StaticString = #filePath, line: UInt = #line) {
let baseLocale = context.baseLocale
guard let baseSignatures = context.placeholderSignature[baseLocale] else {
return XCTFail("Missing base placeholder signature for \(catalogName)", file: file, line: line)
}
for (locale, localeSignatures) in context.placeholderSignature.sorted(by: { $0.key < $1.key }) {
guard locale != baseLocale else { continue }
for key in baseSignatures.keys.sorted() {
guard let baseMap = baseSignatures[key] else {
continue
}
guard let localeMap = localeSignatures[key] else {
return XCTFail("Key \(key) missing for locale \(locale) in \(catalogName) catalog", file: file, line: line)
}
for path in baseMap.keys.sorted() {
let expected = normalizedPlaceholders(baseMap[path, default: []])
let actual = normalizedPlaceholders(localeMap[path, default: []])
XCTAssertEqual(actual, expected, "Placeholder mismatch for key \(key) at \(path) in locale \(locale) (\(catalogName))", file: file, line: line)
}
for (localePath, localeTokens) in localeMap {
guard baseMap[localePath] == nil else { continue }
guard let fallback = fallbackPath(for: localePath, baseMap: baseMap) else {
XCTFail("Unexpected variation \(localePath) for key \(key) in locale \(locale) (\(catalogName))", file: file, line: line)
continue
}
let expected = normalizedPlaceholders(baseMap[fallback, default: []])
let actual = normalizedPlaceholders(localeTokens)
XCTAssertEqual(actual, expected, "Placeholder mismatch for key \(key) at \(localePath) (fallback \(fallback)) in locale \(locale) (\(catalogName))", file: file, line: line)
}
}
}
}
private func assertPrimaryKeysPresent(context: CatalogContext, keys: [String], catalogName: String, file: StaticString = #filePath, line: UInt = #line) {
for key in keys {
guard let entry = context.catalog.strings[key] else {
XCTFail("Missing primary key \(key) in \(catalogName) catalog", file: file, line: line)
continue
}
for locale in context.locales.sorted() {
guard let localization = entry.localizations[locale], let unit = localization.stringUnit else {
XCTFail("Missing localization for key \(key) in locale \(locale) (\(catalogName))", file: file, line: line)
continue
}
let segments = gatherSegments(from: unit)
XCTAssertFalse(segments.isEmpty, "No content for key \(key) in locale \(locale) (\(catalogName))", file: file, line: line)
for segment in segments {
let trimmed = segment.value.trimmingCharacters(in: .whitespacesAndNewlines)
XCTAssertFalse(trimmed.isEmpty, "Empty translation for key \(key) at \(segment.path) in locale \(locale) (\(catalogName))", file: file, line: line)
}
}
}
}
private func assertLocaleStringValue(context: CatalogContext, locale: String, key: String, expectedValue: String, catalogName: String, file: StaticString = #filePath, line: UInt = #line) {
guard let entry = context.catalog.strings[key] else {
XCTFail("Missing key \(key) in \(catalogName) catalog", file: file, line: line)
return
}
guard let localization = entry.localizations[locale], let unit = localization.stringUnit else {
XCTFail("Missing \(locale) localization for key \(key) in \(catalogName) catalog", file: file, line: line)
return
}
// For simple strings (non-pluralized)
if let actualValue = unit.value {
XCTAssertEqual(actualValue, expectedValue, "\(locale) translation mismatch for key \(key) in \(catalogName) catalog. Expected: '\(expectedValue)', Actual: '\(actualValue)'", file: file, line: line)
} else {
XCTFail("Key \(key) has no value in \(locale) localization for \(catalogName) catalog", file: file, line: line)
}
}
// MARK: - Loading
private func loadContext(relativePath: String) throws -> CatalogContext {
let catalog = try loadCatalog(relativePath: relativePath)
let locales = catalog.locales
let baseLocale = catalog.sourceLanguage
var keysByLocale: [String: Set<String>] = [:]
var placeholderSignature: [String: [String: [String: [String]]]] = [:]
for locale in locales {
var localeKeys: Set<String> = []
var localePlaceholders: [String: [String: [String]]] = [:]
for (key, entry) in catalog.strings {
guard let localization = entry.localizations[locale], let unit = localization.stringUnit else {
continue
}
localeKeys.insert(key)
let segments = gatherSegments(from: unit)
var pathMap: [String: [String]] = [:]
for segment in segments {
pathMap[segment.path] = placeholders(in: segment.value)
}
localePlaceholders[key] = pathMap
}
keysByLocale[locale] = localeKeys
placeholderSignature[locale] = localePlaceholders
}
return CatalogContext(catalog: catalog, locales: locales, baseLocale: baseLocale, keysByLocale: keysByLocale, placeholderSignature: placeholderSignature)
}
private func loadCatalog(relativePath: String) throws -> StringCatalog {
let url = repoRootURL.appendingPathComponent(relativePath)
let data = try Data(contentsOf: url)
return try JSONDecoder().decode(StringCatalog.self, from: data)
}
private func loadPrimaryKeys() throws -> PrimaryKeyConfig {
let url = localizationTestsDirectoryURL.appendingPathComponent("PrimaryLocalizationKeys.json")
let data = try Data(contentsOf: url)
return try JSONDecoder().decode(PrimaryKeyConfig.self, from: data)
}
}
// MARK: - Helpers
private struct CatalogContext {
let catalog: StringCatalog
let locales: [String]
let baseLocale: String
let keysByLocale: [String: Set<String>]
let placeholderSignature: [String: [String: [String: [String]]]]
}
private struct StringCatalog: Decodable {
let sourceLanguage: String
let strings: [String: CatalogEntry]
var locales: [String] {
var localeSet: Set<String> = []
for entry in strings.values {
localeSet.formUnion(entry.localizations.keys)
}
return localeSet.sorted()
}
}
private struct CatalogEntry: Decodable {
let localizations: [String: CatalogLocalization]
}
private struct CatalogLocalization: Decodable {
let stringUnit: CatalogStringUnit?
}
private struct CatalogStringUnit: Decodable {
let state: String
let value: String?
let variations: CatalogVariations?
let comment: String?
}
private struct CatalogVariations: Decodable {
let plural: [String: [String: CatalogVariationValue]]?
}
private struct CatalogVariationValue: Decodable {
let stringUnit: CatalogStringUnit?
}
private struct Segment {
let components: [String]
let value: String
var path: String {
components.isEmpty ? "base" : components.joined(separator: ".")
}
}
private func gatherSegments(from unit: CatalogStringUnit, prefix: [String] = []) -> [Segment] {
var segments: [Segment] = []
if let value = unit.value {
segments.append(Segment(components: prefix, value: value))
} else if prefix.isEmpty {
segments.append(Segment(components: [], value: ""))
}
if let plural = unit.variations?.plural {
for (variable, categories) in plural.sorted(by: { $0.key < $1.key }) {
for (category, variation) in categories.sorted(by: { $0.key < $1.key }) {
if let nested = variation.stringUnit {
var nextPrefix = prefix
nextPrefix.append("plural")
nextPrefix.append(variable)
nextPrefix.append(category)
segments.append(contentsOf: gatherSegments(from: nested, prefix: nextPrefix))
}
}
}
}
return segments
}
private func normalizedPlaceholders(_ tokens: [String]) -> [String] {
tokens.sorted()
}
private func fallbackPath(for localePath: String, baseMap: [String: [String]]) -> String? {
let parts = localePath.split(separator: ".")
guard parts.count == 3, parts.first == "plural" else {
return nil
}
let variable = parts[1]
let otherKey = "plural.\(variable).other"
if baseMap[otherKey] != nil {
return otherKey
}
let oneKey = "plural.\(variable).one"
if baseMap[oneKey] != nil {
return oneKey
}
return nil
}
private let placeholderRegex: NSRegularExpression = {
let pattern = "%(?:\\d+\\$)?#@[A-Za-z0-9_]+@|%(?:\\d+\\$)?[#0\\- +'\"]*(?:\\d+|\\*)?(?:\\.\\d+)?(?:hh|h|ll|l|z|t|L)?[a-zA-Z@]"
return try! NSRegularExpression(pattern: pattern, options: [])
}()
private func placeholders(in string: String) -> [String] {
let range = NSRange(location: 0, length: (string as NSString).length)
let matches = placeholderRegex.matches(in: string, options: [], range: range)
var tokens: [String] = []
for match in matches {
if let range = Range(match.range, in: string) {
let token = String(string[range])
if token == "%%" { continue }
tokens.append(token)
}
}
return tokens
}
private struct PrimaryKeyConfig: Decodable {
let app: [String]
let shareExtension: [String]
let expectedValues: [String: [String: String]]?
let testLocales: [String]?
}