From 711318787c5a99461db63071739c1aefb4607781 Mon Sep 17 00:00:00 2001 From: Taksh Kothari Date: Thu, 24 Sep 2026 13:04:58 +0530 Subject: [PATCH] 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. --- bitchatTests/LocalizationCoverageTests.swift | 162 +++++++++++++++---- 1 file changed, 129 insertions(+), 33 deletions(-) diff --git a/bitchatTests/LocalizationCoverageTests.swift b/bitchatTests/LocalizationCoverageTests.swift index 8748b960..29bea3b8 100644 --- a/bitchatTests/LocalizationCoverageTests.swift +++ b/bitchatTests/LocalizationCoverageTests.swift @@ -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 { + 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.. [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..