diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 34eb59e0..70f39ee8 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "fosmvvm-generators", "description": "FOSMVVM architecture generators for ViewModels, Fields, DataModels, ServerRequests, Leaf Views, and ViewModel Tests", - "version": "2.28.0", + "version": "2.29.0", "author": { "name": "FOS Computer Services" }, diff --git a/.claude/skills/shared/api-catalog/FOSTesting.md b/.claude/skills/shared/api-catalog/FOSTesting.md index af1f58ab..725f3dbd 100644 --- a/.claude/skills/shared/api-catalog/FOSTesting.md +++ b/.claude/skills/shared/api-catalog/FOSTesting.md @@ -21,7 +21,10 @@ typed errors the expectations throw. Reach for this when: a Swift Testing suite touches anything Localizable — conform, load the store once in `init`, and declare the locales under test; `encoder(locale:)` then hands out localizing encoders and every expectation -below becomes available on the suite. +below becomes available on the suite. When the YAML lives in more than one +bundle — the test bundle's client-hosted localizations plus a server target's +resources — `loadLocalizationStore(bundles:)` merges them into one store, so +server-based ViewModels verify against the same YAML the server serves. Don't load YAML stores or build localizing encoders per-test by hand. ```swift @@ -30,7 +33,9 @@ struct UserViewModelTests: LocalizableTestCase { let locStore: LocalizationStore var locales: Set { [Self.en, Self.es] } init() throws { - self.locStore = try Self.loadLocalizationStore(bundle: .module) + self.locStore = try Self.loadLocalizationStore( + bundles: [.module, AppServerResources.bundle] + ) } } ``` @@ -138,8 +143,14 @@ suite's `localizationStore` and locale shorthands are available) and asserts on the returned XCUIApplication. `presentView(testConfiguration:)` names a configuration the app's `testHost` closure can decorate the view with; `localizedViewModel()` localizes a ViewModel without launching. +View tests never require YAML: a localized string with no translation in the +harness bundle resolves to placeholder text derived from its key (an empty +string would collapse the element to zero surface area and make it unreachable +by XCUI), so server-hosted ViewModels' views stay drivable. Don't locate elements by display text, or by XCUITest element type — tag them with `uiTestingIdentifier()` (FOSMVVM) and find them with `uiTestingElement()`. +Don't assert on placeholder content — localization completeness belongs in +`LocalizableTestCase.expectTranslations()`. ```swift final class MyDisplayViewUITests: AppDisplayTestCase, @unchecked Sendable { diff --git a/CHANGELOG.md b/CHANGELOG.md index c8c04283..20f7309a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`LocalizableTestCase.loadLocalizationStore(bundles:)`** (FOSTesting) — loads one + merged `LocalizationStore` from several bundles, so a suite can verify server-based + ViewModels against the same YAML the server serves alongside the test bundle's own + client-hosted localizations. + +### Changed + +- **View tests no longer require YAML** (FOSTestingUI) — `ViewModelDisplayTestCase` / + `ViewModelViewTestCase` setUp now resolves any localized string that has no translation + in the harness bundle to visible placeholder text derived from its key, instead of the + empty string. SwiftUI collapses empty-labeled elements to zero surface area, which made + such elements unreachable by XCUI even with a `uiTestingIdentifier()`; with the + placeholder they stay tappable. Don't assert on placeholder content — localization + completeness belongs in `LocalizableTestCase.expectTranslations()`. + +### Fixed + +- **`LocalizationStore` convenience dispatch** (FOSMVVM) — the index-less + `keyExists(_:locale:)` and `t()` conveniences now dispatch to the store's `keyExists` / + `translate` implementations; previously both always derived their answer from `value()`, + silently bypassing stores that customize those requirements. + +### Added + - **`task(error:)` / `task(id:error:)`** (FOSMVVM) — async twins of SwiftUI's `task` modifiers for view-lifetime loads: the action becomes throwing, and a thrown error lands in the required `error: Binding` (cleared on each start — the binding holds the diff --git a/Package.swift b/Package.swift index 3615698b..cf4a71fe 100644 --- a/Package.swift +++ b/Package.swift @@ -169,6 +169,14 @@ let package = Package( // 21-Dec-24 - Macros can only be tested with XCTest - https://forums.swift.org/t/swift-testing-support-for-macros/72720/6 ] ), + .testTarget( + name: "FOSTestingUITests", + dependencies: [ + .byName(name: "FOSFoundation"), + .byName(name: "FOSMVVM"), + .byName(name: "FOSTestingUI") + ] + ), .testTarget( name: "FOSMVVMTests", dependencies: [ diff --git a/Sources/FOSMVVM/Localization/LocalizationStore.swift b/Sources/FOSMVVM/Localization/LocalizationStore.swift index b492e094..cbf65d1f 100644 --- a/Sources/FOSMVVM/Localization/LocalizationStore.swift +++ b/Sources/FOSMVVM/Localization/LocalizationStore.swift @@ -70,16 +70,31 @@ public protocol LocalizationStore: Sendable { } public extension LocalizationStore { - func keyExists(_ key: String, locale: Locale, index: Int? = nil) -> Bool { + func keyExists(_ key: String, locale: Locale, index: Int?) -> Bool { value(key, locale: locale, default: nil, index: index) != nil } + /// Provides information on whether a translation is available for given key in a given locale + /// + /// - Parameters: + /// - key: The translation key to look up + /// - locale: The **Locale** context in which to resolve *key* + /// - Returns: **true** if the key is known in the given *locale* + func keyExists(_ key: String, locale: Locale) -> Bool { + // Dispatches the keyExists requirement so stores that customize it + // (rather than value()) are honored; a same-signature default argument + // here would statically bind to the value()-based implementation above + keyExists(key, locale: locale, index: nil) + } + func translate(_ key: String, locale: Locale, default: String?, index: Int?) -> String? { value(key, locale: locale, default: `default`, index: index) as? String } func t(_ key: String, locale: Locale, default: String? = nil, index: Int? = nil) -> String? { - value(key, locale: locale, default: `default`, index: index) as? String + // Dispatches the translate requirement so stores that customize it + // (rather than value()) are honored + translate(key, locale: locale, default: `default`, index: index) } func v(_ key: String, locale: Locale, default: Any? = nil, index: Int? = nil) -> Any? { diff --git a/Sources/FOSTesting/LocalizableTestCase.swift b/Sources/FOSTesting/LocalizableTestCase.swift index 11550670..47dde5ba 100644 --- a/Sources/FOSTesting/LocalizableTestCase.swift +++ b/Sources/FOSTesting/LocalizableTestCase.swift @@ -57,6 +57,37 @@ public extension LocalizableTestCase { ) } + /// Loads a single **LocalizationStore** merging the localizations of every bundle in *bundles* + /// + /// Use this form when the YAML under test lives in more than one bundle — typically the + /// test bundle's own client-hosted localizations plus a server target's resources — so + /// that server-based *ViewModel*s verify against the same YAML the server serves. + /// + /// ## Example + /// + /// ```swift + /// @Suite("My Test Suite", .serialized) + /// struct MyTestSuite: LocalizableTestCase { + /// + /// let locStore: LocalizationStore + /// init() throws { + /// self.locStore = try Self.loadLocalizationStore( + /// bundles: [Bundle.module, MyAppServerResources.bundle] + /// ) + /// } + /// } + /// ``` + /// + /// - Parameters: + /// - bundles: The *Bundle*s whose YAML localizations are merged into the store + /// - resourceDirectoryName: The name of a resource directory searched in each + /// bundle (default: Resources) + static func loadLocalizationStore(bundles: [Bundle], resourceDirectoryName: String = "Resources") throws -> LocalizationStore { + try bundles.yamlLocalization( + resourceDirectoryName: resourceDirectoryName + ) + } + /// Returns **JSONEncoder** that is configured to perform localization during encoding func encoder(locale: Locale = Self.en) -> JSONEncoder { JSONEncoder.localizingEncoder( diff --git a/Sources/FOSTestingUI/KeyEchoLocalizationStore.swift b/Sources/FOSTestingUI/KeyEchoLocalizationStore.swift new file mode 100644 index 00000000..42d0a86e --- /dev/null +++ b/Sources/FOSTestingUI/KeyEchoLocalizationStore.swift @@ -0,0 +1,63 @@ +// KeyEchoLocalizationStore.swift +// +// Copyright 2026 FOS Computer Services, LLC +// +// Licensed under the Apache License, Version 2.0 (the License); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#if os(iOS) || os(tvOS) || os(watchOS) || os(macOS) || os(visionOS) +import FOSMVVM +import Foundation + +/// A ``LocalizationStore`` decorator that answers missed string lookups with +/// placeholder text derived from the lookup key +/// +/// View tests exercise view behavior, not localization completeness (that is +/// ``LocalizableTestCase``'s job). A key that has no YAML backing — a server-hosted +/// *ViewModel*'s key, for example — would otherwise resolve to an empty string, and +/// SwiftUI collapses empty-labeled elements to zero surface area, making them +/// unreachable by XCUI even when a *uiTestingIdentifier* is present. Echoing the key +/// keeps every element tappable and makes any placeholder that leaks into a screenshot +/// self-identifying. +struct KeyEchoLocalizationStore: LocalizationStore { + private let wrapped: LocalizationStore? + + /// - Parameter wrapped: The real store to consult first; `nil` when the test + /// harness has no YAML at all (every string lookup echoes) + init(wrapping wrapped: LocalizationStore?) { + self.wrapped = wrapped + } + + /// Honest by design: the echo must never make a key look translated. + func keyExists(_ key: String, locale: Locale, index: Int?) -> Bool { + wrapped?.keyExists(key, locale: locale, index: index) ?? false + } + + func translate(_ key: String, locale: Locale, default: String?, index: Int?) -> String? { + value(key, locale: locale, default: `default`, index: index) as? String + } + + /// Strings-only fallback falls out of the type system: every string lookup + /// funnels through here via t()/translate(), while typed consumers cast the + /// result (as? Element / as? [String]) and reject the echoed String, so their + /// misses behave exactly as with the wrapped store. + func value(_ key: String, locale: Locale, default: Any?, index: Int?) -> Any? { + if let value = wrapped?.value(key, locale: locale, default: nil, index: index) { + return value + } + if let `default` { + return `default` + } + return "⟪\(key)⟫" + } +} +#endif diff --git a/Sources/FOSTestingUI/ViewModelViewTestCase.swift b/Sources/FOSTestingUI/ViewModelViewTestCase.swift index cc685992..acf6ae26 100644 --- a/Sources/FOSTestingUI/ViewModelViewTestCase.swift +++ b/Sources/FOSTestingUI/ViewModelViewTestCase.swift @@ -195,6 +195,13 @@ import XCTest /// } /// ``` /// + /// > View tests never require YAML to be present: any localized string that has no + /// > translation in *bundle* — a server-hosted *ViewModel*'s strings, for example — + /// > resolves to visible placeholder text derived from its key, so the element keeps + /// > its surface area and stays reachable by XCUI. Do not assert on placeholder + /// > content; localization completeness belongs in **LocalizableTestCase**'s + /// > *expectTranslations()*. + /// /// - Parameters: /// - bundle: The test harness's application bundle /// - resourceDirectoryName: The directory in the bundle to search for localizations (default: "") @@ -208,9 +215,16 @@ import XCTest ) async throws { try await super.setUp() - locStore = try bundle.yamlLocalization( - resourceDirectoryName: resourceDirectoryName - ) + do { + locStore = try KeyEchoLocalizationStore( + wrapping: bundle.yamlLocalization( + resourceDirectoryName: resourceDirectoryName + ) + ) + } catch YamlStoreError.noResourcePaths { + // A harness with no YAML at all is supported: every string echoes its key + locStore = KeyEchoLocalizationStore(wrapping: nil) + } self.locales = locales ?? [Self.en] let app = XCUIApplication(bundleIdentifier: appBundleIdentifier) diff --git a/Tests/FOSMVVMTests/Localization/LocalizableTestCaseBundlesTests.swift b/Tests/FOSMVVMTests/Localization/LocalizableTestCaseBundlesTests.swift new file mode 100644 index 00000000..3e0a4ef6 --- /dev/null +++ b/Tests/FOSMVVMTests/Localization/LocalizableTestCaseBundlesTests.swift @@ -0,0 +1,42 @@ +// LocalizableTestCaseBundlesTests.swift +// +// Copyright 2026 FOS Computer Services, LLC +// +// Licensed under the Apache License, Version 2.0 (the License); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FOSFoundation +import FOSMVVM +import FOSTesting +import Foundation +import Testing + +@Suite("LocalizableTestCase bundles form") +struct LocalizableTestCaseBundlesTests: LocalizableTestCase { + let locStore: LocalizationStore + var locales: Set { + [Self.en] + } + + init() throws { + self.locStore = try Self.loadLocalizationStore( + bundles: [.module], + resourceDirectoryName: "TestYAML" + ) + } + + @Test func mergedStoreResolvesKnownKey() { + #expect( + locStore.t("InnerViewModel.innerString", locale: en) == "Inner String" + ) + } +} diff --git a/Tests/FOSMVVMTests/Localization/LocalizationStoreDispatchTests.swift b/Tests/FOSMVVMTests/Localization/LocalizationStoreDispatchTests.swift new file mode 100644 index 00000000..3abcc205 --- /dev/null +++ b/Tests/FOSMVVMTests/Localization/LocalizationStoreDispatchTests.swift @@ -0,0 +1,61 @@ +// LocalizationStoreDispatchTests.swift +// +// Copyright 2026 FOS Computer Services, LLC +// +// Licensed under the Apache License, Version 2.0 (the License); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FOSMVVM +import Foundation +import Testing + +/// The index-less conveniences (`keyExists(_:locale:)`, `t()`) must dispatch their +/// protocol requirement, not re-derive the answer from `value()` — a store that +/// customizes `keyExists`/`translate` is otherwise silently bypassed. +struct LocalizationStoreDispatchTests { + private let en = Locale(identifier: "en") + + @Test func keyExistsConvenienceDispatchesCustomWitness() { + let store = CustomizingStore() + + // value() answers every key; the custom keyExists denies every key — + // the convenience must report the witness's answer + #expect(store.v("any.key", locale: en) != nil) + #expect(!store.keyExists("any.key", locale: en)) + } + + @Test func tDispatchesCustomTranslateWitness() { + let store = CustomizingStore() + + #expect(store.t("any.key", locale: en) == "from translate") + } + + @Test func vDispatchesValueWitness() { + let store = CustomizingStore() + + #expect(store.v("any.key", locale: en) as? String == "from value") + } +} + +private struct CustomizingStore: LocalizationStore { + func keyExists(_ key: String, locale: Locale, index: Int?) -> Bool { + false + } + + func translate(_ key: String, locale: Locale, default: String?, index: Int?) -> String? { + "from translate" + } + + func value(_ key: String, locale: Locale, default: Any?, index: Int?) -> Any? { + "from value" + } +} diff --git a/Tests/FOSTestingUITests/KeyEchoLocalizationStoreTests.swift b/Tests/FOSTestingUITests/KeyEchoLocalizationStoreTests.swift new file mode 100644 index 00000000..8df51e45 --- /dev/null +++ b/Tests/FOSTestingUITests/KeyEchoLocalizationStoreTests.swift @@ -0,0 +1,118 @@ +// KeyEchoLocalizationStoreTests.swift +// +// Copyright 2026 FOS Computer Services, LLC +// +// Licensed under the Apache License, Version 2.0 (the License); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#if os(iOS) || os(tvOS) || os(watchOS) || os(macOS) || os(visionOS) +import FOSFoundation +import FOSMVVM +@testable import FOSTestingUI +import Foundation +import Testing + +struct KeyEchoLocalizationStoreTests { + private let en = Locale(identifier: "en") + + @Test func missEchoesSelfIdentifyingText() { + let store = KeyEchoLocalizationStore(wrapping: nil) + + let result = store.t("MyViewModel.title", locale: en) + + #expect(result != nil) + #expect(!(result ?? "").isEmpty) + #expect(result?.contains("MyViewModel.title") == true) + } + + @Test func hitPassesThroughWrappedStore() { + let store = KeyEchoLocalizationStore( + wrapping: DictionaryStore(storage: [ + "en": ["MyViewModel.title": "Real Title"] + ]) + ) + + #expect(store.t("MyViewModel.title", locale: en) == "Real Title") + } + + @Test func keyExistsStaysHonestOnMiss() { + let store = KeyEchoLocalizationStore( + wrapping: DictionaryStore(storage: [ + "en": ["Known.key": "Known"] + ]) + ) + + #expect(store.keyExists("Known.key", locale: en)) + #expect(!store.keyExists("Unknown.key", locale: en)) + // ... even though the same missed key still echoes as a string + #expect(store.t("Unknown.key", locale: en)?.isEmpty == false) + } + + @Test func explicitDefaultWinsOverEcho() { + let store = KeyEchoLocalizationStore(wrapping: nil) + + #expect(store.t("Unknown.key", locale: en, default: "Fallback") == "Fallback") + } + + @Test func typedMissRemainsAMiss() { + let store = KeyEchoLocalizationStore(wrapping: nil) + + // Non-string consumers cast the result; the echoed String must not satisfy them + #expect(store.v("Unknown.key", locale: en) as? Int == nil) + #expect(store.v("Unknown.key", locale: en) as? [String] == nil) + } + + @Test func localizingEncoderResolvesMissedKeyNonEmpty() throws { + let store = KeyEchoLocalizationStore(wrapping: nil) + let encoder = JSONEncoder.localizingEncoder( + locale: en, + localizationStore: store + ) + + let localized: LocalizableString = try LocalizableString + .localized(key: "Server.onlyKey") + .toJSON(encoder: encoder) + .fromJSON() + + #expect(!localized.isEmpty) + #expect(try localized.localizedString.contains("Server.onlyKey")) + } + + @Test func localizingEncoderStillResolvesRealTranslations() throws { + let store = KeyEchoLocalizationStore( + wrapping: DictionaryStore(storage: [ + "en": ["Client.key": "Client Value"] + ]) + ) + let encoder = JSONEncoder.localizingEncoder( + locale: en, + localizationStore: store + ) + + let localized: LocalizableString = try LocalizableString + .localized(key: "Client.key") + .toJSON(encoder: encoder) + .fromJSON() + + #expect(try localized.localizedString == "Client Value") + } +} + +private struct DictionaryStore: LocalizationStore { + /// localeIdentifier -> key -> value + let storage: [String: [String: String]] + + func value(_ key: String, locale: Locale, default: Any?, index: Int?) -> Any? { + storage[locale.identifier]?[key] ?? `default` + } +} +#endif