mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-11-01 07:53:26 +00:00
test(l10n): per-catalog attribution and file:line output for the coverage guard (#1679)
* test(l10n): report file and line for an untranslated key The catalog guard named only the file a missing key was written in, so finding it in a 900-line view meant grepping. Collect each literal `String(localized:)` reference with its repo-relative path and line, and print one `path:line key` per line instead of a comma-joined blob. * test(l10n): check each target against the catalog it bundles The guard accepted a key found in either catalog. The share extension bundles only its own, so a key it references that lives solely in the app catalog ships English there just as surely as one that exists nowhere - which is the gap that let the share-extension strings drift. Check `bitchat/` against the app catalog and `bitchatShareExtension/` against the extension catalog, and say in the failure which of the two cases it is. * test: check shared sources against the extension catalog too bitchat/Services/SharedContentHandoff.swift and bitchat/Services/ TransportConfig.swift are compiled into bitchatShareExtension as well (project.pbxproj membership exceptions), so a key they reference has to resolve in both catalogs -- each target bundles only its own. The exception list is read from project.pbxproj rather than hardcoded, so the check follows the set when it changes.
This commit is contained in:
parent
d01c02788f
commit
711318787c
@ -71,43 +71,139 @@ 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)
|
||||
|
||||
let pattern = try NSRegularExpression(pattern: #"String\(\s*localized:\s*"([^"\\]+)""#)
|
||||
var missing: [String] = []
|
||||
// Files under `bitchat/` that the share extension also compiles have to
|
||||
// resolve in both catalogs, since each target bundles only its own.
|
||||
let sharedSources = try Self.shareExtensionMembershipExceptions()
|
||||
|
||||
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))")
|
||||
}
|
||||
for (sourceRoot, ownKeys, otherKeys, ownCatalog, otherCatalog) in [
|
||||
("bitchat", mainKeys, shareKeys, Self.mainCatalogPath, Self.shareExtensionCatalogPath),
|
||||
("bitchatShareExtension", shareKeys, mainKeys,
|
||||
Self.shareExtensionCatalogPath, Self.mainCatalogPath)
|
||||
] {
|
||||
let references = try Self.localizedKeyReferences(in: sourceRoot)
|
||||
|
||||
let missing = references
|
||||
.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 in code but absent from every catalog — these ship English to all non-source locales: \(missing.sorted().joined(separator: ", "))"
|
||||
#expect(
|
||||
missing.isEmpty,
|
||||
"""
|
||||
keys referenced under \(sourceRoot)/ but absent from \(ownCatalog) — \
|
||||
these ship English to all non-source locales:
|
||||
\(missing.sorted().joined(separator: "\n"))
|
||||
"""
|
||||
)
|
||||
|
||||
guard sourceRoot == "bitchat" else { continue }
|
||||
let missingInBoth = references
|
||||
.filter { sharedSources.contains($0.location.prefix(while: { $0 != ":" }).description) }
|
||||
.filter { !shareKeys.contains($0.key) }
|
||||
.map { "\($0.location) \($0.key)" }
|
||||
|
||||
#expect(
|
||||
missingInBoth.isEmpty,
|
||||
"""
|
||||
keys referenced from files the share extension also compiles, but absent \
|
||||
from \(Self.shareExtensionCatalogPath) — these ship English inside the \
|
||||
extension:
|
||||
\(missingInBoth.sorted().joined(separator: "\n"))
|
||||
"""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The files under `bitchat/` that the share-extension target compiles, read
|
||||
/// from the project's membership exceptions so this cannot drift when the
|
||||
/// set changes.
|
||||
private static func shareExtensionMembershipExceptions() throws -> Set<String> {
|
||||
let project = try String(
|
||||
contentsOf: repoRoot.appendingPathComponent("bitchat.xcodeproj/project.pbxproj"),
|
||||
encoding: .utf8
|
||||
)
|
||||
let marker = #"Exceptions for "bitchat" folder in "bitchatShareExtension" target"#
|
||||
guard let markerRange = project.range(of: marker),
|
||||
let listStart = project.range(
|
||||
of: "membershipExceptions = (", range: markerRange.upperBound..<project.endIndex
|
||||
),
|
||||
let listEnd = project.range(
|
||||
of: ");", range: listStart.upperBound..<project.endIndex
|
||||
)
|
||||
else { return [] }
|
||||
|
||||
return Set(
|
||||
project[listStart.upperBound..<listEnd.lowerBound]
|
||||
.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { $0.hasSuffix(".swift") }
|
||||
.map { "bitchat/\($0)" }
|
||||
)
|
||||
}
|
||||
|
||||
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*"([^"\\]+)""#)
|
||||
let rootURL = repoRoot.appendingPathComponent(sourceRoot)
|
||||
let enumerator = try #require(FileManager.default.enumerator(
|
||||
at: rootURL,
|
||||
includingPropertiesForKeys: nil
|
||||
))
|
||||
|
||||
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)"
|
||||
))
|
||||
}
|
||||
}
|
||||
return references
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user