Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
},
Expand Down
15 changes: 13 additions & 2 deletions .claude/skills/shared/api-catalog/FOSTesting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -30,7 +33,9 @@ struct UserViewModelTests: LocalizableTestCase {
let locStore: LocalizationStore
var locales: Set<Locale> { [Self.en, Self.es] }
init() throws {
self.locStore = try Self.loadLocalizationStore(bundle: .module)
self.locStore = try Self.loadLocalizationStore(
bundles: [.module, AppServerResources.bundle]
)
}
}
```
Expand Down Expand Up @@ -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<MyDisplayViewModel>, @unchecked Sendable {
Expand Down
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Error?>` (cleared on each start — the binding holds the
Expand Down
8 changes: 8 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
19 changes: 17 additions & 2 deletions Sources/FOSMVVM/Localization/LocalizationStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand Down
31 changes: 31 additions & 0 deletions Sources/FOSTesting/LocalizableTestCase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
63 changes: 63 additions & 0 deletions Sources/FOSTestingUI/KeyEchoLocalizationStore.swift
Original file line number Diff line number Diff line change
@@ -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
20 changes: 17 additions & 3 deletions Sources/FOSTestingUI/ViewModelViewTestCase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: "")
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Locale> {
[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"
)
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading
Loading