diff --git a/Sources/HandySwiftUI/Localizable.xcstrings b/Sources/HandySwiftUI/Localizable.xcstrings index 9afff5f..7153139 100644 --- a/Sources/HandySwiftUI/Localizable.xcstrings +++ b/Sources/HandySwiftUI/Localizable.xcstrings @@ -3624,458 +3624,6 @@ } } }, - "No emojis match '%@'" : { - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "لا توجد رموز تعبيرية مطابقة \"%@\"" - } - }, - "bg" : { - "stringUnit" : { - "state" : "translated", - "value" : "Няма подходящи емотикони за '%@'" - } - }, - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "No hi ha emojis que coincideixin amb \"%@\"" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Žádné emodži neodpovídají '%@'" - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ingen emojis matcher '%@'" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Keine Emojis passen zu '%@'" - } - }, - "el" : { - "stringUnit" : { - "state" : "translated", - "value" : "Δεν υπάρχουν emojis που ταιριάζουν με '%@'" - } - }, - "en-GB" : { - "stringUnit" : { - "state" : "translated", - "value" : "No emojis match '%@'" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ningún emoji coincide con '%@'" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mikään emoji ei vastaa '%@'" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Aucun emoji ne correspond à '%@'" - } - }, - "fr-CA" : { - "stringUnit" : { - "state" : "translated", - "value" : "Aucun emoji ne correspond à '%@'" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "אין אימוג'ים שתואמים '%@'" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "कोई emojis मैच '%@'" - } - }, - "hr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nema podudaranja emojija '%@'" - } - }, - "hu" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nincs olyan emoji, amelyik megfelel a '%@'-nak" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tidak ada emoji yang cocok dengan '%@'" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Non ci sono emoji che corrispondono a '%@'" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@'にマッチする絵文字がない" - } - }, - "kk" : { - "stringUnit" : { - "state" : "translated", - "value" : "'%@' эмоциясы сәйкес келмейді" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "'%@'와 일치하는 이모티콘이 없습니다" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tiada emoji sepadan dengan '%@'" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Er komen geen emoji's overeen met '%@'" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Żadne emoji nie pasują do '%@'" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nenhum emoji corresponde a \"%@\"" - } - }, - "pt-PT" : { - "stringUnit" : { - "state" : "translated", - "value" : "Não há emojis correspondentes a \"%@\"" - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "Niciun emoji nu se potrivește cu \"%@" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ни один эмодзи не соответствует '%@'" - } - }, - "sk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Žiadne emotikony sa nezhodujú s '%@'" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Inga emojis matchar '%@'" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ไม่มีอิโมจิตรงกับ '%@'" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "'%@' ile eşleşen emoji yok" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ніякі емодзі не відповідають '%@'" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Không có biểu tượng cảm xúc nào khớp với '%@'" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "没有与\"%@\"匹配的表情符号" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "沒有與 '%@' 匹配的表情符號" - } - }, - "zh-HK" : { - "stringUnit" : { - "state" : "translated", - "value" : "沒有與'%@'匹配嘅表情符號" - } - } - } - }, - "No matches" : { - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "لا توجد تطابقات" - } - }, - "bg" : { - "stringUnit" : { - "state" : "translated", - "value" : "Няма съвпадения" - } - }, - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cap resultat" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Žádné výsledky" - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ingen resultater" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Keine Treffer" - } - }, - "el" : { - "stringUnit" : { - "state" : "translated", - "value" : "Κανένα αποτέλεσμα" - } - }, - "en-GB" : { - "stringUnit" : { - "state" : "translated", - "value" : "No matches" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ningún resultado" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ei osumia" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Aucun résultat" - } - }, - "fr-CA" : { - "stringUnit" : { - "state" : "translated", - "value" : "Aucun résultat" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "אין תוצאות" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "कोई परिणाम नहीं" - } - }, - "hr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bez rezultata" - } - }, - "hu" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nincs egyezés" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tidak ada kecocokan" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nessun risultato" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "該当なし" - } - }, - "kk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Сәйкестіктер жоқ" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "일치하는 항목 없음" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tiada padanan" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Niets gevonden" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Brak pasujących" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nenhum resultado" - } - }, - "pt-PT" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nenhum resultado" - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "Niciun rezultat" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Нет совпадений" - } - }, - "sk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Žiadne zhody" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Inga träffar" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ไม่มีรายการตรงกัน" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Eşleşen yok" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Немає збігів" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Không có kết quả nào" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "无匹配项" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "找不到符合的項目" - } - }, - "zh-HK" : { - "stringUnit" : { - "state" : "translated", - "value" : "找不到符合的項目" - } - } - } - }, "None" : { "localizations" : { "ar" : { diff --git a/Sources/HandySwiftUI/Types/Views/MultiSelectionView.swift b/Sources/HandySwiftUI/Types/Views/MultiSelectionView.swift index f2878b5..ed74025 100644 --- a/Sources/HandySwiftUI/Types/Views/MultiSelectionView.swift +++ b/Sources/HandySwiftUI/Types/Views/MultiSelectionView.swift @@ -82,8 +82,10 @@ struct MultiSelectionView: View { #if os(macOS) Section { - Button("Done") { + Button { self.dismiss() + } label: { + Self.doneButtonText } .frame(maxWidth: .infinity, alignment: .trailing) } @@ -94,6 +96,11 @@ struct MultiSelectionView: View { #endif } + /// The title of the button that closes the selection sheet on macOS. + static var doneButtonText: Text { + Text("Done", bundle: .module) + } + /// Creates a boolean binding for a given selectable item. /// /// This is used for creating toggles on macOS. diff --git a/Sources/HandySwiftUI/Types/Views/SearchableGridPicker.swift b/Sources/HandySwiftUI/Types/Views/SearchableGridPicker.swift index 701b824..cabd1eb 100644 --- a/Sources/HandySwiftUI/Types/Views/SearchableGridPicker.swift +++ b/Sources/HandySwiftUI/Types/Views/SearchableGridPicker.swift @@ -58,6 +58,11 @@ public struct SearchableGridPicker: View { } } + /// The text shown in place of the selected option while nothing is selected. + static var noSelectionText: Text { + Text("None", bundle: .module) + } + /// Initializes a `SearchableGridPicker` with a title, a list of options, and a binding to the selection. /// /// - Parameters: @@ -85,7 +90,7 @@ public struct SearchableGridPicker: View { if let selection { selection.view.scaleEffect(0.75) } else { - Text("None") + Self.noSelectionText } Image(systemName: "chevron.right") @@ -102,11 +107,7 @@ public struct SearchableGridPicker: View { .navigationDestination(isPresented: self.$showPicker) { Group { if self.filteredOptions.isEmpty { - ContentUnavailableView( - "No matches", - systemImage: "exclamationmark.magnifyingglass", - description: Text("No emojis match '\(self.searchText)'") - ) + ContentUnavailableView.search(text: self.searchText) .frame(maxHeight: .infinity, alignment: .center) } else { ScrollView { diff --git a/Tests/HandySwiftUITests/LocalizationTests.swift b/Tests/HandySwiftUITests/LocalizationTests.swift new file mode 100644 index 0000000..a6dc359 --- /dev/null +++ b/Tests/HandySwiftUITests/LocalizationTests.swift @@ -0,0 +1,70 @@ +import SwiftUI +import Testing + +@testable import HandySwiftUI + +@MainActor +struct LocalizationTests { + nonisolated static let catalogLanguages: [String] = Bundle.module.localizations.filter { $0 != "en" }.sorted() + + nonisolated static func translation(of key: String, in language: String) -> String? { + guard + let path = Bundle.module.path(forResource: language, ofType: "lproj"), + let bundle = Bundle(path: path) + else { return nil } + + let value = bundle.localizedString(forKey: key, value: "⚠️ missing", table: nil) + return value == "⚠️ missing" ? nil : value + } + + /// The languages translated in the String Catalog of the sources, read from the catalog file itself. + static func languagesInSourceCatalog() throws -> Set { + let catalogURL = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + .appending(path: "Sources/HandySwiftUI/Localizable.xcstrings") + let catalog = try JSONSerialization.jsonObject(with: Data(contentsOf: catalogURL)) as? [String: Any] + let strings = try #require(catalog?["strings"] as? [String: [String: Any]]) + return Set(strings.values.flatMap { ($0["localizations"] as? [String: Any])?.keys.map { $0 } ?? [] }) + } + + @Test + func compiledBundleContainsEveryCatalogLanguage() throws { + let sourceLanguages = try Self.languagesInSourceCatalog().subtracting(["en"]) + + #expect(!sourceLanguages.isEmpty) + #expect(Set(Self.catalogLanguages) == sourceLanguages) + } + + @Test(arguments: ["None", "Done"]) + func keyIsTranslatedIntoEveryLanguage(key: String) { + for language in Self.catalogLanguages { + #expect(Self.translation(of: key, in: language) != nil, "\(key) has no \(language) translation") + } + } + + /// Renders a text with the given language as environment locale. + func render(_ text: Text, language: String) -> Data? { + renderedPixels(of: text.environment(\.locale, Locale(identifier: language))) + } + + /// Checks that the text no longer renders the English source in the given language. A pixel-exact comparison with the expected + /// translation is not possible for every language: localized texts pick language-specific glyphs (Chinese, Bulgarian) that a + /// verbatim reference text does not get. + func expectLocalized(_ text: Text, key: String, language: String) throws { + let translation = try #require(Self.translation(of: key, in: language)) + guard translation != key else { return } + + let englishSource = self.render(Text(verbatim: key), language: language) + #expect(self.render(text, language: language) != englishSource, "'\(key)' still renders in English for \(language)") + } + + @Test(arguments: catalogLanguages) + func noSelectionTextIsLocalized(language: String) throws { + try self.expectLocalized(SearchableGridPicker.noSelectionText, key: "None", language: language) + } + + @Test(arguments: catalogLanguages) + func doneButtonTextIsLocalized(language: String) throws { + try self.expectLocalized(MultiSelectionView.doneButtonText, key: "Done", language: language) + } +} diff --git a/Tests/HandySwiftUITests/RenderingHelpers.swift b/Tests/HandySwiftUITests/RenderingHelpers.swift new file mode 100644 index 0000000..0189181 --- /dev/null +++ b/Tests/HandySwiftUITests/RenderingHelpers.swift @@ -0,0 +1,10 @@ +import SwiftUI + +/// Renders a view into raw pixel data so that two renderings can be compared for equality. +@MainActor +func renderedPixels(of view: some View) -> Data? { + let renderer = ImageRenderer(content: view) + renderer.scale = 1 + guard let cgImage = renderer.cgImage else { return nil } + return cgImage.dataProvider?.data as Data? +}