diff --git a/bitchatTests/LocalizationCoverageTests.swift b/bitchatTests/LocalizationCoverageTests.swift index 8748b960a5..29bea3b84b 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] = [] - - 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) + ] { + 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 { + 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..