From c6572b5fa6f69f60772bacf171ce8492c8b54c74 Mon Sep 17 00:00:00 2001 From: David Hunt Date: Fri, 21 Aug 2026 12:31:41 +0200 Subject: [PATCH 1/4] fix(FOSMVVM): LocalizationStore conveniences dispatch their requirements keyExists(_:locale:) and t() derived their answers from value() directly, bypassing stores that customize the keyExists/translate witnesses. The index-less keyExists is now a separate convenience that dispatches the requirement, and t() forwards to translate(). Dispatch is pinned by LocalizationStoreDispatchTests. --- .../Localization/LocalizationStore.swift | 19 +++++- .../LocalizationStoreDispatchTests.swift | 61 +++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 Tests/FOSMVVMTests/Localization/LocalizationStoreDispatchTests.swift 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/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" + } +} From b2ac776750a06886af4984f47672b13a3a071285 Mon Sep 17 00:00:00 2001 From: David Hunt Date: Fri, 21 Aug 2026 12:31:41 +0200 Subject: [PATCH 2/4] feat(FOSTesting): loadLocalizationStore(bundles:) merges multi-bundle YAML MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LocalizableTestCase suites can now build one LocalizationStore from several bundles — the test bundle's client-hosted localizations plus a server target's resources — so server-based ViewModels verify against the same YAML the server serves. --- Sources/FOSTesting/LocalizableTestCase.swift | 31 ++++++++++++++ .../LocalizableTestCaseBundlesTests.swift | 42 +++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 Tests/FOSMVVMTests/Localization/LocalizableTestCaseBundlesTests.swift 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/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" + ) + } +} From 72ab51c605c08cfcdeac0965dc9295e1982ce2ef Mon Sep 17 00:00:00 2001 From: David Hunt Date: Fri, 21 Aug 2026 12:31:41 +0200 Subject: [PATCH 3/4] feat(FOSTestingUI): view tests key-echo missing translations ViewModelDisplayTestCase/ViewModelViewTestCase setUp wraps the harness store in KeyEchoLocalizationStore: a localized string with no translation resolves to placeholder text derived from its key instead of the empty string, which SwiftUI collapses to zero surface area, leaving elements unreachable by XCUI despite a uiTestingIdentifier. keyExists stays honest, explicit defaults win, typed (non-string) misses remain misses, and a harness with no YAML at all is now supported. Localization completeness remains LocalizableTestCase's job. New FOSTestingUITests target covers the store. --- Package.swift | 8 ++ .../KeyEchoLocalizationStore.swift | 63 ++++++++++ .../FOSTestingUI/ViewModelViewTestCase.swift | 20 ++- .../KeyEchoLocalizationStoreTests.swift | 118 ++++++++++++++++++ 4 files changed, 206 insertions(+), 3 deletions(-) create mode 100644 Sources/FOSTestingUI/KeyEchoLocalizationStore.swift create mode 100644 Tests/FOSTestingUITests/KeyEchoLocalizationStoreTests.swift 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/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/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 From 1cead580ab6d199b17545e7e8a11deddcdd04764 Mon Sep 17 00:00:00 2001 From: David Hunt Date: Fri, 21 Aug 2026 12:31:41 +0200 Subject: [PATCH 4/4] docs: CHANGELOG, api-catalog entries for key-echo + bundles form, plugin 2.29.0 --- .claude-plugin/plugin.json | 2 +- .../skills/shared/api-catalog/FOSTesting.md | 15 ++++++++++-- CHANGELOG.md | 24 +++++++++++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) 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