Skip to content

Commit 2d2a4fd

Browse files
authored
Merge pull request #41 from kipp-ing/review-pr40-followups
Post-merge review fixes for #40: unreachable German strings, weakened tests, tier-collapse doc drift
2 parents 9a797f2 + 49f11c9 commit 2d2a4fd

20 files changed

Lines changed: 132 additions & 54 deletions

‎CLAUDE.md‎

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -141,9 +141,11 @@ Active feature: `1100-purchase-gate` (branch `1100-purchase-gate`, cut from `mai
141141
spec at `specs/1100-purchase-gate/spec.md`, current plan at `specs/1100-purchase-gate/plan.md`
142142
(+ research/data-model/contracts/quickstart, 2026-07-19). Purchase gate /
143143
one-time unlocks: the free core stays whole (all sources + full core playback, basic
144-
transitions); paid tiers **Pro** (ambience — launch composition **Ken Burns motion + clock
145-
overlay**, amended 2026-07-19; never-publicly-shipped features only) and **Automation** (HA/MQTT
146-
+ App Intents) plus an optional everything-bundle; one-time purchases only, no subscriptions ever,
144+
transitions); one paid **Supporter Unlock** grants everything gated — ambience (**Ken Burns
145+
motion + clock overlay**, amended 2026-07-19; never-publicly-shipped features only) *and*
146+
automation (HA/MQTT + App Intents). The former Pro/Automation tiers and the everything-bundle
147+
were **collapsed into that single unlock on 2026-07-23** (PR #40): `Entitlement` has one case,
148+
`ProductCatalog.unlocks` is `[.supporter]`. One-time purchases only, no subscriptions ever,
147149
never the word "lifetime"; Family Sharing + universal purchase (incl. tvOS); on-device
148150
entitlement caching so unattended frames work offline indefinitely; never-claw-back
149151
(FR-1100-13); **sequencing is release-blocking: the gated build must be the first version the
@@ -154,19 +156,23 @@ anywhere in this public repo — pricing is decided in App Store Connect at subm
154156
**real `StoreKitClient` StoreKit 2 adapter**, `LockedRow`/`UnlockScreenView`/`TipJarView`), gates
155157
at the point of effect in both apps, Unlocks settings section (Restore + tip jar), US5 broker
156158
degradation (masked config behind a locked banner), and launch `refresh()` + `listenForUpdates()`
157-
now wired on both apps' production entry points. PurchaseKit 110 host tests + full iOS suite
158-
**153/0/9** green on iOS 18.6. **T030 fully done — the old "caveat" was a misdiagnosis, corrected
159+
now wired on both apps' production entry points. **Current measured gate (2026-07-25, iPad Pro
160+
11-inch (M4) sim): PurchaseKit 106 host tests green, full iOS suite 163/0/5** — the 5 skips are
161+
the ASC-screenshot, live-smoke, and 3 device-rig items. (The older "110 host / 153/0/9" figures
162+
predate the tier collapse and the post-PR-#40 review; re-measure before quoting any count.)
163+
**T030 fully done — the old "caveat" was a misdiagnosis, corrected
159164
2026-07-21.** It held that `SKTestSession` serves 0 products under headless `xcodebuild` ("the
160165
runner, not the runtime") so its 7 cases needed the Xcode IDE or a device. Actually two setup bugs
161166
in the test: `configurationFileNamed:` resolves against `Bundle.main` (the *host app* bundle, which
162167
lacks `Configuration.storekit`) and fails **silently**; and `resetToDefaultState()` clears
163168
`disableDialogs`, so setting it first left Ask-to-Buy blocking on a dialog. Both fixed, skip-guard
164169
replaced by a hard assertion, all 7 passing on the iOS 18.6 sim + Framepad (17.7.10) + FramePhone
165170
(26.0.1). Runs headlessly in CI; nothing folds into T042. See `docs/testing.md`; issue #16 closed.
166-
**Also note:** a 2026-07-21 full-suite run found two failures **pre-existing on `main`** and
167-
unrelated to 1100 — `BrokerSetupUITests` (#21) and `ShareSheetIncomingUITests` (#22, order-dependent)
168-
— so the "153/0/9 green" line above no longer reproduces as stated. **T033 done (2026-07-20):** the tvOS unlock surface — new `TVSettingsView` (gear
169-
destination) with the Ambience/Pro locked row, an Automation-gated Home-Assistant row
171+
**Resolved:** the two failures a 2026-07-21 run found pre-existing on `main` — `BrokerSetupUITests`
172+
(#21) and `ShareSheetIncomingUITests` (#22, order-dependent) — were fixed in PR #36; both issues are
173+
closed and all six of their cases pass in the 2026-07-25 run. **T033 done (2026-07-20):** the tvOS unlock surface — new `TVSettingsView` (gear
174+
destination) with the ambience locked row, a Home-Assistant row (both Supporter-gated since the
175+
2026-07-23 collapse; they were Pro- and Automation-gated when T033 landed)
170176
(`TVLockedBrokerView` masked-config banner when unentitled), and an Unlocks section (Restore +
171177
tip), reusing PurchaseKit UI via `fullScreenCover`; Apple-TV-simulator screenshot-verified under
172178
the `--uitest-entitlements` seams; the shared unlock/tip screens gained a tvOS-only opaque

‎OwnFrame/Intents/FrameIntents.swift‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,12 @@
1717
import AppIntents
1818
import AppIntentsKit
1919

20-
/// The contract's user-facing error copy (English-only, FR-300-30), mapped 1:1
21-
/// from the package's closed taxonomy. Parameter details (like the rejected
22-
/// percent) stay out of the copy by design — the message names the rule.
20+
/// The contract's user-facing error copy, mapped 1:1 from the package's closed
21+
/// taxonomy. The wording ships localized through the app's String Catalog
22+
/// (FR-300-30; German since 2026-07-23) — the glue tests pin the locale to English
23+
/// so they assert the contract wording rather than the runner's language.
24+
/// Parameter details (like the rejected percent) stay out of the copy by design —
25+
/// the message names the rule.
2326
enum FrameIntentError: Error, Equatable, CustomLocalizedStringResourceConvertible {
2427
case notConfigured
2528
case frameNotOpen

‎OwnFrame/Localizable.xcstrings‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2552,12 +2552,12 @@
25522552
}
25532553
}
25542554
},
2555-
"Where your money goes: the unlocks cover the project's running costs — developer account, AI tools, test hardware — and everything beyond that goes back to open-source projects that serve the community. The free frame stays whole, forever." : {
2555+
"Where your money goes: the Supporter Unlock covers the project's running costs — developer account, AI tools, test hardware — and everything beyond that goes back to open-source projects that serve the community. The free frame stays whole, forever." : {
25562556
"localizations" : {
25572557
"de" : {
25582558
"stringUnit" : {
25592559
"state" : "translated",
2560-
"value" : "Wohin dein Geld fließt: Die Freischaltungen decken die laufenden Kosten des Projekts — Entwicklerkonto, KI-Tools, Testgeräte — und alles darüber hinaus geht zurück an Open-Source-Projekte, die der Gemeinschaft dienen. Die kostenlose App bleibt für immer voll funktionsfähig."
2560+
"value" : "Wohin dein Geld fließt: Die Supporter-Freischaltung deckt die laufenden Kosten des Projekts — Entwicklerkonto, KI-Tools, Testgeräte — und alles darüber hinaus geht zurück an Open-Source-Projekte, die der Gemeinschaft dienen. Die kostenlose App bleibt für immer voll funktionsfähig."
25612561
}
25622562
}
25632563
}

‎OwnFrame/Onboarding/AlbumPickerView.swift‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,11 @@ struct AlbumPickerView: View {
105105
static func subtitle(for album: Album) -> String? {
106106
var parts: [String] = []
107107
if let dateText = dateText(album.startDate, album.endDate) { parts.append(dateText) }
108-
if let count = album.assetCount { parts.append(count == 1 ? "1 photo" : "\(count) photos") }
108+
// Built as a String and joined, so the count needs an explicit lookup — a bare literal
109+
// here would ship the English text into an otherwise localized subtitle.
110+
if let count = album.assetCount {
111+
parts.append(count == 1 ? String(localized: "1 photo") : String(localized: "\(count) photos"))
112+
}
109113
return parts.isEmpty ? nil : parts.joined(separator: " · ")
110114
}
111115

‎OwnFrame/Onboarding/PhotoAlbumPickerView.swift‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,14 @@ struct PhotoAlbumPickerView: View {
117117

118118
@ViewBuilder
119119
private func limitedContent(_ pool: SourceCollection?) -> some View {
120-
let label = pool?.title ?? String(localized: "Selected Photos")
121-
let isAdded = sourceLibrary.sources.contains { $0.label == label }
120+
// The pool's model-side title is a fixed English identifier (PhotoLibraryKit ships no
121+
// catalog); the row shows — and persists — the localized name instead. Identity is the
122+
// sentinel collection ID, not the label, so neither a rename nor a language switch can
123+
// make an already-added pool look un-added.
124+
let label = String(localized: "Selected Photos")
125+
let isAdded = sourceLibrary.sources.contains {
126+
$0.kind == .photoLibrary(collectionID: PhotoLibrarySource.selectedPhotosID)
127+
}
122128
List {
123129
Section {
124130
Button {

‎OwnFrame/Slideshow/SlideshowSettingsView.swift‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -374,7 +374,9 @@ struct SlideshowSettingsView: View {
374374
} footer: {
375375
VStack(alignment: .leading, spacing: 10) {
376376
Text("Restore purchases you already own. Tips are optional and unlock nothing — they just say thanks.")
377-
Text("Where your money goes: the unlocks cover the project's running costs — developer account, AI tools, test hardware — and everything beyond that goes back to open-source projects that serve the community. The free frame stays whole, forever.")
377+
// Transparency statement (docs/where-the-money-goes.md) — word-for-word the
378+
// tvOS copy in TVSettingsView, so both platforms share one catalog entry.
379+
Text("Where your money goes: the Supporter Unlock covers the project's running costs — developer account, AI tools, test hardware — and everything beyond that goes back to open-source projects that serve the community. The free frame stays whole, forever.")
378380
.accessibilityIdentifier("settings.unlocks.moneyPledge")
379381
}
380382
}

‎OwnFrameTests/FrameIntentGlueTests.swift‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,18 @@ struct FrameIntentGlueTests {
6868

6969
// MARK: - Error mapping (contract copy, state untouched)
7070

71+
/// The contract copy resolved in English, whatever the runner's locale is.
72+
///
73+
/// This copy ships translated (spec 300), so a bare `String(localized:)` renders German on a
74+
/// German device and the comparison against the contract text goes red for the wrong reason.
75+
/// Pinning the locale keeps these assertions about the *contract wording*, not the tester's
76+
/// language — the translations themselves are covered by the catalogs.
77+
private func contractCopy(_ error: FrameIntentError) -> String {
78+
var resource = error.localizedStringResource
79+
resource.locale = Locale(identifier: "en")
80+
return String(localized: resource)
81+
}
82+
7183
@Test func outOfRangeBrightnessThrowsTheContractCopyAndRecordsNothing() async throws {
7284
let fixture = try Fixture()
7385
defer { fixture.restore() }
@@ -79,7 +91,7 @@ struct FrameIntentGlueTests {
7991
}
8092
#expect(fixture.surface.calls.isEmpty)
8193
#expect(
82-
String(localized: FrameIntentError.brightnessOutOfRange.localizedStringResource)
94+
contractCopy(.brightnessOutOfRange)
8395
== "Brightness must be between 0 and 100 percent."
8496
)
8597
}
@@ -92,18 +104,18 @@ struct FrameIntentGlueTests {
92104
_ = try await PauseSlideshowIntent().perform()
93105
}
94106
#expect(
95-
String(localized: FrameIntentError.notConfigured.localizedStringResource)
107+
contractCopy(.notConfigured)
96108
== "Set up the frame first — open OwnFrame and add a source."
97109
)
98110
}
99111

100112
@Test func remainingContractCopyMatches() {
101113
#expect(
102-
String(localized: FrameIntentError.frameNotOpen.localizedStringResource)
114+
contractCopy(.frameNotOpen)
103115
== "OwnFrame must be open on the frame device for this."
104116
)
105117
#expect(
106-
String(localized: FrameIntentError.sourceMissing.localizedStringResource)
118+
contractCopy(.sourceMissing)
107119
== "This source no longer exists in the frame's library."
108120
)
109121
}

‎Packages/PurchaseKit/Sources/PurchaseKit/Localizable.xcstrings‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,17 @@
342342
}
343343
}
344344
},
345+
"The purchase could not be completed." : {
346+
"extractionState" : "manual",
347+
"localizations" : {
348+
"de" : {
349+
"stringUnit" : {
350+
"state" : "translated",
351+
"value" : "Der Kauf konnte nicht abgeschlossen werden."
352+
}
353+
}
354+
}
355+
},
345356
"Tip" : {
346357
"extractionState" : "manual",
347358
"localizations" : {

‎Packages/PurchaseKit/Sources/PurchaseKit/PurchaseViewModel.swift‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -194,9 +194,12 @@ public final class PurchaseViewModel {
194194
///
195195
/// The store's own description is appended when it has one, because "why" is the entire point
196196
/// of the `.failed` case; the leading sentence guarantees the message is never empty or raw.
197-
/// Not localized — the app ships English-only by design (CLAUDE.md).
197+
///
198+
/// The lead resolves against `.module` — a package's strings do not live in the app bundle,
199+
/// and this one renders under an already-translated title. The appended store description
200+
/// comes from StoreKit and is localized by the system.
198201
private static func failureMessage(for error: any Error) -> String {
199-
let lead = "The purchase could not be completed."
202+
let lead = String(localized: "The purchase could not be completed.", bundle: .module)
200203
guard let description = (error as? any LocalizedError)?.errorDescription,
201204
!description.isEmpty
202205
else { return lead }

‎Packages/PurchaseKit/Sources/PurchaseKit/UI/LockedRow.swift‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ extension Entitlement {
88
/// The user-facing unlock name, as it appears on locked badges and unlock screens.
99
///
1010
/// Presentation only, which is why it lives beside the view rather than in the model: the
11-
/// entitlement itself is a capability, not a marketing name. Not localized — the repo ships
12-
/// English-only by design (CLAUDE.md).
11+
/// entitlement itself is a capability, not a marketing name. Deliberately *not* localized:
12+
/// "Supporter" is the product's proper name, the fixed morpheme every locale keeps — the
13+
/// German copy builds on it too ("Supporter-Freischaltung").
1314
public var displayName: String {
1415
switch self {
1516
case .supporter: "Supporter"

0 commit comments

Comments
 (0)