Skip to content
Open
Changes from all 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
162 changes: 129 additions & 33 deletions bitchatTests/LocalizationCoverageTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []

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))")
}
// 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, 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 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 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"))
"""
)
}
}

#expect(
missing.isEmpty,
"keys referenced in code but absent from every catalog — these ship English to all non-source locales: \(missing.sorted().joined(separator: ", "))"
/// 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