Skip to content
Open
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 80 additions & 33 deletions bitchatTests/LocalizationCoverageTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,43 +71,90 @@ struct LocalizationCoverageTests {
}

/// 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)
/// CODE. A `String(localized:)` whose key is absent from the catalog its
/// target actually ships 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.
///
/// The catalogs are checked per target, not as a union: the share
/// extension bundles only its own catalog, so a key it references that
/// lives solely in the app catalog is just as untranslated there as one
/// that exists nowhere.
@Test func everyCodeReferencedKeyExistsInItsOwnCatalog() throws {
let main = try Self.loadCatalog(Self.mainCatalogPath)
let shareExt = try Self.loadCatalog(Self.shareExtensionCatalogPath)
let mainKeys = Set(main.coverage.keys)
let shareKeys = Set(shareExt.coverage.keys)

for (sourceRoot, ownKeys, otherKeys, ownCatalog, otherCatalog) in [
("bitchat", mainKeys, shareKeys, Self.mainCatalogPath, Self.shareExtensionCatalogPath),
("bitchatShareExtension", shareKeys, mainKeys,
Self.shareExtensionCatalogPath, Self.mainCatalogPath)
] {
Comment on lines +96 to +100

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate shared extension sources against extension catalog

The share extension target also has membership for shared files under bitchat/ (bitchat.xcodeproj/project.pbxproj:70-76 lists bitchat/Services/SharedContentHandoff.swift and bitchat/Services/TransportConfig.swift for the extension), but this loop assigns every bitchat/ reference to the app catalog only. If a user-facing String(localized:) is added to one of those shared helpers for the extension path and its key exists only in bitchat/Localizable.xcstrings, this guard will still pass even though the extension does not bundle that catalog, recreating the per-target blind spot this change is meant to close.

Useful? React with 👍 / 👎.

let missing = try Self.localizedKeyReferences(in: sourceRoot)
.filter { !ownKeys.contains($0.key) }
.map { reference -> String in
let note = otherKeys.contains(reference.key)
? " (present in \(otherCatalog), which this target does not bundle)"
: " (present in no catalog)"
return "\(reference.location) \(reference.key)\(note)"
}

#expect(
missing.isEmpty,
"""
keys referenced under \(sourceRoot)/ but absent from \(ownCatalog) — \
these ship English to all non-source locales:
\(missing.sorted().joined(separator: "\n"))
"""
)
}
}

private static let mainCatalogPath = "bitchat/Localizable.xcstrings"
private static let shareExtensionCatalogPath =
"bitchatShareExtension/Localization/Localizable.xcstrings"

/// A literal `String(localized:)` key, with where it was written.
private struct KeyReference {
let key: String
/// `path/to/File.swift:line`, relative to the repository root.
let location: String
}

/// Every literal `String(localized:)` key under `sourceRoot`, with its
/// repo-relative file and line so a failure points at the call site
/// instead of just naming a file.
private static func localizedKeyReferences(in sourceRoot: String) throws -> [KeyReference] {
let pattern = try NSRegularExpression(pattern: #"String\(\s*localized:\s*"([^"\\]+)""#)
var missing: [String] = []
let rootURL = repoRoot.appendingPathComponent(sourceRoot)
let enumerator = try #require(FileManager.default.enumerator(
at: rootURL,
includingPropertiesForKeys: nil
))

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))")
}
}
var references: [KeyReference] = []
for case let fileURL as URL in enumerator where fileURL.pathExtension == "swift" {
let source = try String(contentsOf: fileURL, encoding: .utf8)
let relativePath = fileURL.path.replacingOccurrences(
of: repoRoot.path + "/", with: ""
)
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),
let matchStart = Range(match.range, in: source)?.lowerBound
else { return }
let line = source[source.startIndex..<matchStart].count(where: { $0 == "\n" }) + 1
references.append(KeyReference(
key: String(source[keyRange]),
location: "\(relativePath):\(line)"
))
}
}

#expect(
missing.isEmpty,
"keys referenced in code but absent from every catalog — these ship English to all non-source locales: \(missing.sorted().joined(separator: ", "))"
)
return references
}
}
Loading