From e76a8e01dd570e047ddd336c61dd8326ed2ba031 Mon Sep 17 00:00:00 2001 From: "Patrick.Park" Date: Fri, 25 Sep 2026 10:52:46 +0900 Subject: [PATCH 01/15] fix(claude): keep forked usage with its original account (#344) --- .../Core/ClaudeAccountUsage.swift | 22 ++++++++++----- .../PokeTokenBar/Core/LocalUsageReader.swift | 28 +++++++++++++++---- .../AdditionalClaudeAccountsTests.swift | 26 +++++++++++++++++ docs/reference/defect-log.md | 3 ++ 4 files changed, 67 insertions(+), 12 deletions(-) diff --git a/Sources/PokeTokenBar/Core/ClaudeAccountUsage.swift b/Sources/PokeTokenBar/Core/ClaudeAccountUsage.swift index 871c182b9..d37149327 100644 --- a/Sources/PokeTokenBar/Core/ClaudeAccountUsage.swift +++ b/Sources/PokeTokenBar/Core/ClaudeAccountUsage.swift @@ -41,15 +41,23 @@ enum ClaudeAccountUsageAttribution { /// Accounts are checked in order; on equal times the first one wins. static func owner(session: String, at date: Date, accounts: [Account]) -> String? { + owner(sessions: [session], at: date, accounts: accounts) + } + + /// A copied turn can appear in both its original and fork sessions. Prefer the latest prompt + /// at or before the turn across all candidates; a fork prompt created later cannot claim it. + static func owner(sessions: [String], at date: Date, accounts: [Account]) -> String? { var latest: (id: String, date: Date)? var earliest: (id: String, date: Date)? for account in accounts { - guard let times = account.prompts[session], let first = times.first else { continue } - if let before = times.last(where: { $0 <= date }), latest.map({ before > $0.date }) ?? true { - latest = (account.id, before) - } - if earliest.map({ first < $0.date }) ?? true { - earliest = (account.id, first) + for session in Set(sessions).sorted() { + guard let times = account.prompts[session], let first = times.first else { continue } + if let before = times.last(where: { $0 <= date }), latest.map({ before > $0.date }) ?? true { + latest = (account.id, before) + } + if earliest.map({ first < $0.date }) ?? true { + earliest = (account.id, first) + } } } // A turn logged before any prompt of its session (clock skew) goes to the login that started it. @@ -75,7 +83,7 @@ enum ClaudeAccountUsageAttribution { // A block started before the month began still counts. let inBlock = entry.date >= blockStart guard inMonth || inBlock else { continue } - let key = entry.sessionID.flatMap { owner(session: $0, at: entry.date, accounts: accounts) } ?? unattributed + let key = owner(sessions: entry.claudeSessionIDs, at: entry.date, accounts: accounts) ?? unattributed if inBlock, key != unattributed { recent[key, default: []].append(entry) } guard inMonth else { continue } month[key, default: LocalUsageReader.Bucket()].add(entry) diff --git a/Sources/PokeTokenBar/Core/LocalUsageReader.swift b/Sources/PokeTokenBar/Core/LocalUsageReader.swift index 5be16c3e6..bcdcbf1b1 100644 --- a/Sources/PokeTokenBar/Core/LocalUsageReader.swift +++ b/Sources/PokeTokenBar/Core/LocalUsageReader.swift @@ -23,8 +23,8 @@ enum LocalUsageReader { struct Entry: Sendable, Codable { let id: String - let date: Date - let localDay: String + var date: Date + var localDay: String let model: String let input, output, cacheWrite, cacheRead: Int /// Prefer a valid source-recorded amount (including zero) to model-table estimates. @@ -34,8 +34,15 @@ enum LocalUsageReader { /// The source cannot reconstruct model/request token buckets for a price-table estimate. var costUnavailable: Bool? = nil /// Claude only: the session the turn belongs to, from the transcript path. Lets usage be split - /// between Claude accounts (`ClaudeAccountUsageAttribution`). + /// between Claude accounts (`ClaudeAccountUsageAttribution`). Kept for old cache blobs. var sessionID: String? = nil + /// Claude only: every transcript session that contained this turn. A branch can replay the + /// same turn under a new session id, so global dedup must retain all provenance for account + /// attribution instead of whichever file happened to be scanned first. + var sessionIDs: [String]? = nil + var claudeSessionIDs: [String] { + Array(Set((sessionIDs ?? []) + (sessionID.map { [$0] } ?? []))).sorted() + } var total: Int { input + output + cacheWrite + cacheRead } } @@ -364,12 +371,23 @@ enum LocalUsageReader { /// 같은 `(message.id, requestId)` 가 스트리밍/재개로 여러 번 로깅될 때 cacheRead/input 은 고정이나 /// output 은 증가하므로, **id 별 total 이 가장 큰(=완성된) 항목**을 남긴다(전역 dedup). + /// 포크가 같은 턴을 더 늦은 시각으로 다시 기록할 수 있으므로, 턴 시각은 중복 중 가장 이른 값을 보존한다. /// (first-occurrence 를 남기면 부분 output 만 잡혀 비용이 크게 과소집계됨.) static func dedupKeepMax(_ entries: [Entry]) -> [Entry] { var byID: [String: Entry] = [:] for e in entries { - if let ex = byID[e.id] { if e.total > ex.total { byID[e.id] = e } } - else { byID[e.id] = e } + guard let existing = byID[e.id] else { + byID[e.id] = e + continue + } + var kept = e.total > existing.total ? e : existing + let earliest = e.date < existing.date ? e : existing + kept.date = earliest.date + kept.localDay = earliest.localDay + let sessions = Array(Set(existing.claudeSessionIDs + e.claudeSessionIDs)).sorted() + kept.sessionID = sessions.first + kept.sessionIDs = sessions.count > 1 ? sessions : nil + byID[e.id] = kept } return Array(byID.values) } diff --git a/Tests/PokeTokenBarTests/AdditionalClaudeAccountsTests.swift b/Tests/PokeTokenBarTests/AdditionalClaudeAccountsTests.swift index 59dc504bb..935ba439c 100644 --- a/Tests/PokeTokenBarTests/AdditionalClaudeAccountsTests.swift +++ b/Tests/PokeTokenBarTests/AdditionalClaudeAccountsTests.swift @@ -1682,6 +1682,32 @@ final class ClaudeAccountUsageAttributionTests: XCTestCase { XCTAssertEqual(owner("pingpong", 45), "default", "back on the first login: its latest prompt wins") } + func testForkedTurnStaysWithOriginalAccountRegardlessOfScanOrder() { + let forkAccounts = [ + ClaudeAccountUsageAttribution.Account(id: "original", prompts: ["source-session": [at(0)]]), + ClaudeAccountUsageAttribution.Account(id: "fork", prompts: ["fork-session": [at(10)]]), + ] + let source = entry("same-turn", session: "source-session", at: at(5), tokens: 100, + day: "2026-09-17") + let replay = entry("same-turn", session: "fork-session", at: at(15), tokens: 100, + day: "2026-09-17") + + for scanned in [[source, replay], [replay, source]] { + let deduped = LocalUsageReader.dedupKeepMax(scanned) + let result = ClaudeAccountUsageAttribution.usage( + entries: deduped, accounts: forkAccounts, now: at(20), + todayKey: "2026-09-17", monthStartKey: "2026-09-01") + + XCTAssertEqual(deduped.count, 1, "the replayed turn is counted once") + XCTAssertEqual(deduped[0].date, at(5), "the original turn time survives a later replay envelope") + XCTAssertEqual(deduped[0].claudeSessionIDs, ["fork-session", "source-session"]) + XCTAssertEqual(result.byAccount["original"]?.todayTokens, 100, + "a future prompt in the fork cannot claim a historical turn") + XCTAssertNil(result.byAccount["fork"]) + XCTAssertEqual(result.unattributed.todayTokens, 0) + } + } + func testEdgeCasesStayDeterministic() { XCTAssertEqual(owner("resumed", -1), "default", "before any prompt: the login that started the session") XCTAssertEqual(owner("tie", 6), "default", "equal prompt times: the first account in order") diff --git a/docs/reference/defect-log.md b/docs/reference/defect-log.md index 06c91bb66..00a50a0e8 100644 --- a/docs/reference/defect-log.md +++ b/docs/reference/defect-log.md @@ -266,6 +266,9 @@ read_when: 지출을 여러 파일에 남기거나 시각을 다시 찍는다. 규칙: ① dedup 키는 *턴 자체* 의 전역 유일 id(파일·세션 경로를 섞지 마라 — 복사본이 별건이 된다) ② 시각은 *기록* 시각이 아니라 *턴* 시각(fork 는 봉투 timestamp 를 새로 찍는다 → 주/월 합계가 포크 시점으로 몰린다) ③ 부모에 접혀 들어오는 자식(서브에이전트) 세션은 제외. + 다중 계정 귀속에서는 dedup 된 턴이 발견된 **모든 세션 id를 보존**하고, 턴 시각 이전의 프롬프트가 있는 + 원본 세션을 우선한다. 분기 세션의 미래 프롬프트로 과거 턴을 귀속하지 않는다. 회귀 가드는 같은 턴의 + 원본·분기 복사본을 양쪽 입력 순서로 넣어 합계 1회와 동일한 원본 계정 귀속을 함께 단언한다. - **로그 루트는 한 곳이 아니다.** Claude 사용 로그는 CLI 기본 위치 말고도 `CLAUDE_CONFIG_DIR`(콤마 다중), XDG 스타일 `~/.config/claude/projects`, 그리고 Claude Desktop 임베디드 세션(`local-agent-mode-sessions`/ `claude-code-sessions` 아래 세션마다 자체 `.claude/projects`)에 남는다. 루트 추가는 From 4bfae978a1e9d2242c3e5a2241ab7eae32d57cca Mon Sep 17 00:00:00 2001 From: "Patrick.Park" Date: Fri, 25 Sep 2026 11:04:45 +0900 Subject: [PATCH 02/15] fix: let collected Pokemon appearances be selected separately (#345) * fix: let collected Pokemon appearances be selected separately * test: verify individual colors in native collection views * test: wait for rendered appearance before checking sprite colors --- .../PokeTokenBar/Core/CompanionStore.swift | 8 +- Sources/PokeTokenBar/Core/Localization.swift | 3 + Sources/PokeTokenBar/UI/CompanionView.swift | 57 ++++- Tests/PokeTokenBarTests/CompanionTests.swift | 46 ++++ .../DexColorRenderingTests.swift | 207 ++++++++++++++++++ Tests/PokeTokenBarTests/UnownTests.swift | 11 + docs/reference/defect-log.md | 15 ++ 7 files changed, 337 insertions(+), 10 deletions(-) create mode 100644 Tests/PokeTokenBarTests/DexColorRenderingTests.swift diff --git a/Sources/PokeTokenBar/Core/CompanionStore.swift b/Sources/PokeTokenBar/Core/CompanionStore.swift index 5cc470fd0..0e59ef6d5 100644 --- a/Sources/PokeTokenBar/Core/CompanionStore.swift +++ b/Sources/PokeTokenBar/Core/CompanionStore.swift @@ -386,6 +386,7 @@ final class CompanionStore { /// 이 종이 현재 키우는 개체의 **현재 형태**인가. 지나온 진화 단계에는 서지 않는다. let isRaising: Bool var unownForm: UnownForm? = nil + var hasNormal = false /// Species IDs remain Pokédex numbers; selection also includes the Unown letter. var collectionID: String { @@ -412,6 +413,7 @@ final class CompanionStore { let rarity: Rarity var names: [String: String]? var isShiny = false + var hasNormal = false } /// 도감 목록 — 보유 종만, 도감 번호 오름차순. @@ -436,7 +438,7 @@ final class CompanionStore { let key = DexKey(id, unownForm: entry.unownForm, groupUnownForms: groupUnownForms) var a = acc[key] ?? DexAccumulator(rarity: entry.rarity) if let n = entry.names?[id] { a.names = n } // 이름 없는 구버전 항목이 덮어쓰지 않게 - if entry.isShiny { a.isShiny = true } + if entry.isShiny { a.isShiny = true } else { a.hasNormal = true } acc[key] = a } } @@ -447,7 +449,7 @@ final class CompanionStore { let key = DexKey(id, unownForm: active.unownForm, groupUnownForms: groupUnownForms) var a = acc[key] ?? DexAccumulator(rarity: active.rarity) if let n = currentLine?.names[id] { a.names = n } - if currentIsShiny { a.isShiny = true } // 위장 중 숨김 규칙 재사용 + if currentIsShiny { a.isShiny = true } else { a.hasNormal = true } // 위장 중 숨김 규칙 재사용 acc[key] = a } } @@ -462,7 +464,7 @@ final class CompanionStore { rarity: a.rarity, isShiny: a.isShiny, isRaising: key.speciesID == state.active?.currentID && (!groupUnownForms || key.unownForm == currentUnownForm), - unownForm: key.unownForm) + unownForm: key.unownForm, hasNormal: a.hasNormal) } } diff --git a/Sources/PokeTokenBar/Core/Localization.swift b/Sources/PokeTokenBar/Core/Localization.swift index b15e39169..035d35231 100644 --- a/Sources/PokeTokenBar/Core/Localization.swift +++ b/Sources/PokeTokenBar/Core/Localization.swift @@ -775,6 +775,9 @@ struct L { var dexFilterHint: String { t("탭하면 이 희귀도만 보기 · 다시 탭하면 전체", "Tap to show only this rarity · tap again to clear", "タップでこの希少度のみ表示・再タップで全体", "Toca para ver solo esta rareza · toca de nuevo para ver todo", "Touche pour n'afficher que cette rareté · touche à nouveau pour tout afficher", "Toque para ver só esta raridade · toque de novo para ver tudo", "Tippe, um nur diese Seltenheit zu sehen · tippe erneut für alle") } /// 도감 칸의 ✨ 를 읽어주는 명사 — 이모지는 스크린리더가 일관되게 읽지 못한다. var dexShinyLabel: String { t("이로치", "Shiny", "色違い", "Variocolor", "Chromatique", "Shiny", "Schillernd") } + var dexNormalLabel: String { t("일반", "Normal", "通常", "Normal", "Normal", "Normal", "Normal") } + var dexAppearance: String { t("모습", "Appearance", "姿", "Aspecto", "Apparence", "Aparência", "Aussehen") } + var dexAppearancePreview: String { t("수집한 모습 · 종 정보", "Collected appearance · species reference", "収集した姿・種情報", "Aspecto coleccionado · datos de especie", "Apparence collectionnée · données de l’espèce", "Aparência coletada · dados da espécie", "Gesammeltes Aussehen · Speziesdaten") } // MARK: Pokémon 상세 var loadingPokemonDetails: String { t("포켓몬 정보를 불러오는 중…", "Loading Pokémon details…", "ポケモン情報を読み込み中…", "Cargando detalles del Pokémon…", "Chargement des détails du Pokémon…", "Carregando detalhes do Pokémon…", "Pokémon-Details werden geladen…") } var pokemonDetailsUnavailable: String { t("포켓몬 정보를 불러오지 못했어요.", "Pokémon details could not be loaded.", "ポケモン情報を読み込めませんでした。", "No se pudieron cargar los detalles.", "Impossible de charger les détails.", "Não foi possível carregar os detalhes.", "Pokémon-Details konnten nicht geladen werden.") } diff --git a/Sources/PokeTokenBar/UI/CompanionView.swift b/Sources/PokeTokenBar/UI/CompanionView.swift index 301416960..f6c3447c1 100644 --- a/Sources/PokeTokenBar/UI/CompanionView.swift +++ b/Sources/PokeTokenBar/UI/CompanionView.swift @@ -1044,12 +1044,24 @@ private struct DexGridView: View { /// Scrollable species + individual page. A Pokédex species may aggregate several catches, so the /// picker selects the exact persisted profile while the immutable PokéAPI section stays shared. @MainActor -private struct PokemonDetailView: View { +struct PokemonDetailView: View { let store: CompanionStore let species: CompanionStore.DexSpecies let onBack: () -> Void + private let spriteStore: SpriteStore @State private var selectedInstanceID = "" @State private var selectedUnownForm: UnownForm? + @State private var selectedShiny: Bool? + + init(store: CompanionStore, species: CompanionStore.DexSpecies, + onBack: @escaping () -> Void, selectedShiny: Bool? = nil, + spriteStore: SpriteStore = .shared) { + self.store = store + self.species = species + self.onBack = onBack + self.spriteStore = spriteStore + _selectedShiny = State(initialValue: selectedShiny) + } private var formSpecies: [CompanionStore.DexSpecies] { species.id == UnownForm.speciesID ? store.unownFormSpecies : [] @@ -1062,9 +1074,17 @@ private struct PokemonDetailView: View { ?? forms.first ?? species } - private var individuals: [DexEntry] { + private var allIndividuals: [DexEntry] { store.pokemonIndividuals(speciesID: species.id, unownForm: displayedSpecies.unownForm) } + private var displayedShiny: Bool { + if let selectedShiny, + selectedShiny ? displayedSpecies.isShiny : displayedSpecies.hasNormal { return selectedShiny } + return allIndividuals.first?.isShiny ?? displayedSpecies.isShiny + } + private var individuals: [DexEntry] { + allIndividuals.filter { $0.isShiny == displayedShiny } + } private var individual: DexEntry? { individuals.first { $0.id == selectedInstanceID } ?? individuals.first } @@ -1082,8 +1102,12 @@ private struct PokemonDetailView: View { ScrollView { VStack(alignment: .leading, spacing: 12) { if species.id == UnownForm.speciesID { unownFormPicker } + if displayedSpecies.hasNormal && displayedSpecies.isShiny { appearancePicker } identityHeader - if individuals.count > 1 { individualPicker } + if !individuals.isEmpty { individualPicker } + else { + Text(store.l.dexAppearancePreview).font(.caption).foregroundStyle(.secondary) + } if let details = store.pokemonDetailsByID[species.id] { if let individual, let profile = individual.profile { individualSection(entry: individual, profile: profile, details: details) @@ -1124,6 +1148,7 @@ private struct PokemonDetailView: View { Button { selectedUnownForm = form selectedInstanceID = "" + selectedShiny = nil } label: { VStack(spacing: 0) { SpriteView(speciesID: UnownForm.speciesID, size: 28, @@ -1161,14 +1186,14 @@ private struct PokemonDetailView: View { let species = displayedSpecies return HStack(spacing: 14) { SpriteView(speciesID: species.id, size: 82, animated: true, - shiny: individual?.isShiny ?? species.isShiny, unownForm: species.unownForm) + shiny: displayedShiny, spriteStore: spriteStore, unownForm: species.unownForm) .frame(width: 82, height: 82) VStack(alignment: .leading, spacing: 5) { Text(species.name).font(.title3.weight(.bold)) Text(store.l.rarityLabel(species.rarity)) .font(.caption.weight(.semibold)).foregroundStyle(.secondary) - if species.isShiny { Text("✨ \(store.l.dexShinyLabel)").font(.caption2) } - if species.isRaising { Text(store.l.dexRaising).font(.caption2).foregroundStyle(Color.accentColor) } + if displayedShiny { Text("✨ \(store.l.dexShinyLabel)").font(.caption2) } + if let individual, store.isActiveDexEntry(individual) { Text(store.l.dexRaising).font(.caption2).foregroundStyle(Color.accentColor) } let isRepresentative = store.isRepresentative(species) RepresentativeFooterButton(localization: store.l, isRepresentative: isRepresentative) { @@ -1179,16 +1204,34 @@ private struct PokemonDetailView: View { } } + private var appearancePicker: some View { + Picker(store.l.dexAppearance, selection: Binding( + get: { displayedShiny }, set: { selectedShiny = $0; selectedInstanceID = "" })) { + Text(store.l.dexNormalLabel).tag(false) + Text("✨ \(store.l.dexShinyLabel)").tag(true) + } + .pickerStyle(.segmented) + } + private var individualPicker: some View { Picker(store.l.pokemonIndividual, selection: Binding( get: { individual?.id ?? "" }, set: { selectedInstanceID = $0 })) { ForEach(Array(individuals.enumerated()), id: \.element.id) { index, entry in - Text("#\(index + 1) · Lv. \(entry.profile?.level ?? 5)").tag(entry.id) + Text(individualLabel(entry, index: index)).tag(entry.id) } } .pickerStyle(.menu) } + private func individualLabel(_ entry: DexEntry, index: Int) -> String { + let appearance = entry.isShiny ? "✨ \(store.l.dexShinyLabel)" : store.l.dexNormalLabel + let date = entry.caughtAt.map { + $0.formatted(Date.FormatStyle(date: .numeric, time: .omitted) + .locale(Locale(identifier: store.language.rawValue))) + } ?? (store.isActiveDexEntry(entry) ? store.l.dexRaising : "—") + return "#\(index + 1) · \(appearance) · Lv. \(entry.profile?.level ?? 5) · \(date)" + } + private func individualSection(entry: DexEntry, profile: PokemonProfile, details: PokemonDetails) -> some View { VStack(alignment: .leading, spacing: 8) { diff --git a/Tests/PokeTokenBarTests/CompanionTests.swift b/Tests/PokeTokenBarTests/CompanionTests.swift index 73978334a..04a7dfa01 100644 --- a/Tests/PokeTokenBarTests/CompanionTests.swift +++ b/Tests/PokeTokenBarTests/CompanionTests.swift @@ -448,6 +448,50 @@ final class CompanionStoreTests: XCTestCase { let folded = s.dexSpecies XCTAssertEqual(folded.map(\.id), [1, 2], "이로치 개체가 지나온 체인 전 종") XCTAssertEqual(folded.map(\.isShiny), [true, true], "한 개체라도 이로치면 종에 플래그") + XCTAssertEqual(folded.map(\.hasNormal), [true, true]) + } + + func testDexAppearancesPreserveBothColorsRegardlessOfCatchOrder() throws { + let normal = DexEntry(baseID: 605, finalID: 606, chainOrder: [605, 606], + rarity: .rare, caughtAt: fixedNow) + let shiny = DexEntry(baseID: 605, finalID: 606, chainOrder: [605, 606], + rarity: .rare, caughtAt: fixedNow, isShiny: true) + for entries in [[normal, shiny], [shiny, normal], [normal], [shiny]] { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("poke-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: url) } + var state = CompanionState() + state.dex = entries + try JSONEncoder().encode(state).write(to: url) + let s = CompanionStore(provider: StubProvider(value: linear3), clock: { fixedNow }, + fileURL: url, rng: SeededRNG(seed: 7)) + XCTAssertEqual(s.dexSpecies.map(\.id), [605, 606]) + XCTAssertTrue(s.dexSpecies.allSatisfy { $0.hasNormal == entries.contains { !$0.isShiny } }) + XCTAssertTrue(s.dexSpecies.allSatisfy { $0.isShiny == entries.contains { $0.isShiny } }) + XCTAssertEqual(s.dexEntries.map(\.isShiny), entries.map(\.isShiny)) + XCTAssertTrue(s.pokemonIndividuals(speciesID: 605).isEmpty, + "Earlier evolution previews must not manufacture another individual") + } + } + + func testDexAppearancesCombineActiveAndArchivedWithoutFutureForms() throws { + for activeShiny in [false, true] { + var state = CompanionState() + state.dex = [DexEntry(baseID: 1, finalID: 1, chainOrder: [1], rarity: .common, + caughtAt: fixedNow, isShiny: !activeShiny)] + state.active = MonState(baseID: 1, pathIDs: [1, 2], plannedPathIDs: [1, 2, 3], + stageIndex: 1, usedAtStage: 0, rarity: .common, + totalForms: 3, isShiny: activeShiny) + let url = FileManager.default.temporaryDirectory.appendingPathComponent("poke-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: url) } + try JSONEncoder().encode(state).write(to: url) + let s = CompanionStore(provider: StubProvider(value: linear3), clock: { fixedNow }, + fileURL: url, rng: SeededRNG(seed: 7)) + XCTAssertEqual(s.dexSpecies.map(\.id), [1, 2]) + XCTAssertTrue(try XCTUnwrap(s.dexSpecies.first).hasNormal) + XCTAssertTrue(try XCTUnwrap(s.dexSpecies.first).isShiny) + XCTAssertEqual(s.dexSpecies.last?.hasNormal, !activeShiny) + XCTAssertEqual(s.dexSpecies.last?.isShiny, activeShiny) + } } // MARK: 대표 플로팅 펫 (육성 대상과 표시 대상 분리) @@ -587,9 +631,11 @@ final class CompanionStoreTests: XCTestCase { let disguised = try store(revealed: false) XCTAssertTrue(disguised.state.active?.isShiny ?? false, "내부적으론 이로치") XCTAssertEqual(disguised.dexSpecies.first?.isShiny, false, "위장 중엔 도감에도 숨김") + XCTAssertEqual(disguised.dexSpecies.first?.hasNormal, true) let revealed = try store(revealed: true) XCTAssertEqual(revealed.dexSpecies.first?.isShiny, true, "리빌 후엔 도감에 공개") + XCTAssertEqual(revealed.dexSpecies.first?.hasNormal, false) } /// 지금 키우는 종의 이름은 **로드된 라인**에서 온다 — 졸업분이 아직 없어도 `#id` 로 떨어지지 않는다. diff --git a/Tests/PokeTokenBarTests/DexColorRenderingTests.swift b/Tests/PokeTokenBarTests/DexColorRenderingTests.swift new file mode 100644 index 000000000..4d1217e1a --- /dev/null +++ b/Tests/PokeTokenBarTests/DexColorRenderingTests.swift @@ -0,0 +1,207 @@ +import AppKit +import SwiftUI +import ImageIO +import UniformTypeIdentifiers +import Vision +import XCTest +@testable import PokeTokenBar + +private struct ColorRenderingProvider: PokeProviding { + func line(baseSpeciesID: Int) async throws -> EvoLine { throw URLError(.notConnectedToInternet) } + func baseSpeciesIndex() async throws -> [BaseSpecies] { [] } + func baseSpecies(id: Int) async throws -> BaseSpecies? { nil } +} + +@MainActor +final class DexColorRenderingTests: XCTestCase { + // Synthetic pixels make the two appearances unambiguous without network or user saves. + private func solidImage(_ color: NSColor) -> NSImage { + let image = NSImage(size: NSSize(width: 96, height: 96)) + image.lockFocus() + color.setFill() + NSRect(x: 0, y: 0, width: 96, height: 96).fill() + image.unlockFocus() + return image + } + + private func withSprites(_ body: () throws -> Void) throws { + let keys = [false, true].map { + SpriteLoader.cacheDir.appendingPathComponent( + SpriteStore.cacheKey(speciesID: 606, animated: false, shiny: $0) + ".png").path as NSString + } + let previous = keys.map { SpriteLoader.imageCache.object(forKey: $0) } + for (key, color) in zip(keys, [NSColor.red, NSColor.blue]) { + SpriteLoader.imageCache.setObject(solidImage(color), forKey: key) + } + defer { + for (key, image) in zip(keys, previous) { + if let image { SpriteLoader.imageCache.setObject(image, forKey: key) } + else { SpriteLoader.imageCache.removeObject(forKey: key) } + } + } + try body() + } + + private func fixture(shinyLast: Bool) throws -> (CompanionStore, URL) { + let file = FileManager.default.temporaryDirectory.appendingPathComponent("dex-color-\(UUID()).json") + var state = CompanionState() + state.language = .en + state.dex = [!shinyLast, shinyLast].enumerated().map { index, shiny in + DexEntry(baseID: 606, finalID: 606, chainOrder: [606], rarity: .common, + caughtAt: Date(timeIntervalSince1970: 1_700_000_000 + Double(index)), + isShiny: shiny, names: [606: ["en": "Elgyem"]]) + } + try JSONEncoder().encode(state).write(to: file) + return (CompanionStore(provider: ColorRenderingProvider(), fileURL: file), file) + } + + private func colorRows(_ captured: NSBitmapImageRep, shiny: Bool) -> [Int] { + // AppKit display caches may use float/extended color formats. Normalize via PNG + // before reading components, matching the pixels written to the evidence file. + guard let data = captured.representation(using: .png, properties: [:]), + let bitmap = NSBitmapImageRep(data: data) else { return [] } + var rows: [Int] = [] + for y in 0.. 0.8 && color.redComponent < 0.3 + : color.redComponent > 0.8 && color.blueComponent < 0.3) { + matches += 1 + } + } + if matches >= 20 { rows.append(y) } + } + return rows + } + + /// The selector's header updates synchronously, but SpriteView changes its cached image in + /// .task(id:). Wait for those rendered pixels rather than assuming one 100ms sleep is enough. + /// The deadline only bounds a broken test; the full pixel and badge assertions remain below. + private func waitForDetailSprite(_ host: NSView, shiny: Bool) async throws -> NSBitmapImageRep { + let deadline = ContinuousClock.now + .seconds(5) + while true { + host.layoutSubtreeIfNeeded() + let bitmap = try XCTUnwrap(host.bitmapImageRepForCachingDisplay(in: host.bounds)) + host.cacheDisplay(in: host.bounds, to: bitmap) + let png = try XCTUnwrap(bitmap.representation(using: .png, properties: [:])) + let normalized = try XCTUnwrap(NSBitmapImageRep(data: png)) + let scale = CGFloat(bitmap.pixelsWide) / host.bounds.width + // Center of the synthetic 82pt identity sprite, below the appearance picker. + let pixel = try XCTUnwrap(normalized.colorAt(x: Int(41 * scale), y: Int(101 * scale))? + .usingColorSpace(.deviceRGB)) + let ready = pixel.greenComponent < 0.4 + && (shiny ? pixel.blueComponent > 0.8 && pixel.redComponent < 0.3 + : pixel.redComponent > 0.8 && pixel.blueComponent < 0.3) + if ready || ContinuousClock.now >= deadline { return bitmap } + // Yield the main actor so the appearance's .task and following SwiftUI render can run. + try await Task.sleep(for: .milliseconds(20)) + } + } + + func testDetailSelectorChangesMountedPixelsAndReturnsToNormal() async throws { + let (store, file) = try fixture(shinyLast: true) + defer { try? FileManager.default.removeItem(at: file) } + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("detail-colors-\(UUID())") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + for (shiny, color) in zip([false, true], [NSColor.red, NSColor.blue]) { + let cgImage = try XCTUnwrap(solidImage(color).cgImage(forProposedRect: nil, context: nil, hints: nil)) + let bitmap = NSBitmapImageRep(cgImage: cgImage) + let key = SpriteStore.cacheKey(speciesID: 606, animated: false, shiny: shiny) + try XCTUnwrap(bitmap.representation(using: .png, properties: [:])).write(to: directory.appendingPathComponent(key + ".png")) + let data = NSMutableData() + let destination = try XCTUnwrap(CGImageDestinationCreateWithData(data, UTType.gif.identifier as CFString, 1, nil)) + CGImageDestinationAddImage(destination, cgImage, nil) + XCTAssertTrue(CGImageDestinationFinalize(destination)) + let animatedKey = SpriteStore.cacheKey(speciesID: 606, animated: true, shiny: shiny) + try (data as Data).write(to: directory.appendingPathComponent(animatedKey + ".gif")) + } + let species = try XCTUnwrap(store.dexSpecies.first) + let view = PokemonDetailView(store: store, species: species, onBack: {}, selectedShiny: false, + spriteStore: SpriteStore(directory: directory)) + .frame(width: PopoverMetrics.contentWidth, height: 520) + .background(Color.white) + .environment(\.colorScheme, .light) + let host = NSHostingView(rootView: view) + host.frame = NSRect(x: 0, y: 0, width: PopoverMetrics.contentWidth, height: 520) + let window = NSWindow(contentRect: NSRect(x: -10000, y: -10000, + width: PopoverMetrics.contentWidth, height: 520), styleMask: .borderless, backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.appearance = NSAppearance(named: .aqua) + window.contentView = host + window.orderFront(nil) + defer { window.close() } + func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) } + for (step, shiny) in [false, true, false].enumerated() { + host.layoutSubtreeIfNeeded() + let segmented = try XCTUnwrap(descendants(host).compactMap { $0 as? NSSegmentedControl }.first) + segmented.selectedSegment = shiny ? 1 : 0 + segmented.sendAction(segmented.action, to: segmented.target) + let bitmap = try await waitForDetailSprite(host, shiny: shiny) + XCTAssertGreaterThan(colorRows(bitmap, shiny: shiny).count, 40, + "step \(step): selected \(shiny ? "shiny" : "normal") sprite must appear within 5 seconds") + XCTAssertEqual(colorRows(bitmap, shiny: !shiny).count, 0, + "step \(step): selected \(shiny ? "shiny" : "normal") must not retain the previous appearance") + let scale = CGFloat(bitmap.pixelsWide) / PopoverMetrics.contentWidth + let header = try XCTUnwrap(bitmap.cgImage?.cropping(to: + CGRect(x: 90 * scale, y: 58 * scale, width: 220 * scale, height: 85 * scale))) + let recognition = VNRecognizeTextRequest() + recognition.recognitionLevel = .accurate + recognition.recognitionLanguages = ["en-US"] + try VNImageRequestHandler(cgImage: header).perform([recognition]) + let text = recognition.results?.compactMap { $0.topCandidates(1).first?.string }.joined(separator: " ") ?? "" + XCTAssertTrue(text.contains("Elgyem"), "header crop must contain the rendered identity: \(text)") + XCTAssertEqual(text.contains("Shiny"), shiny, "badge must follow the displayed individual: \(text)") + try XCTUnwrap(bitmap.representation(using: .png, properties: [:])).write(to: + URL(fileURLWithPath: "/private/tmp/dex-color-detail-\(shiny ? "shiny" : "normal").png")) + } + } + + func testCatchLogRendersEachIndividualsColorInBothAcquisitionOrdersAndOnReentry() throws { + try withSprites { + for shinyLast in [false, true] { + let (store, file) = try fixture(shinyLast: shinyLast) + defer { try? FileManager.default.removeItem(at: file) } + for visit in 0..<2 { + let navigation = PopoverNavigation() + navigation.showingCollectionLog = true + let view = CollectionView(store: store, navigation: navigation) + .frame(width: PopoverMetrics.contentWidth, height: 520) + .background(Color.white) + .environment(\.colorScheme, .light) + .environment(\.locale, store.language.displayLocale) + let host = NSHostingView(rootView: view) + host.frame = NSRect(x: 0, y: 0, width: PopoverMetrics.contentWidth, height: 520) + let window = NSWindow(contentRect: NSRect(x: -10000, y: -10000, + width: PopoverMetrics.contentWidth, height: 520), + styleMask: .borderless, backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.appearance = NSAppearance(named: .aqua) + window.contentView = host + window.orderFront(nil) + defer { window.close() } + host.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.1)) + host.layoutSubtreeIfNeeded() + let bitmap = try XCTUnwrap(host.bitmapImageRepForCachingDisplay(in: host.bounds)) + host.cacheDisplay(in: host.bounds, to: bitmap) + try XCTUnwrap(bitmap.representation(using: .png, properties: [:])).write(to: + URL(fileURLWithPath: "/private/tmp/dex-color-log-shiny-last-\(shinyLast).png")) + let normalRows = colorRows(bitmap, shiny: false) + let shinyRows = colorRows(bitmap, shiny: true) + XCTAssertGreaterThan(normalRows.count, 20, "normal catch must retain normal pixels") + XCTAssertGreaterThan(shinyRows.count, 20, "shiny catch must retain shiny pixels") + let firstNormal = try XCTUnwrap(normalRows.first) + let firstShiny = try XCTUnwrap(shinyRows.first) + XCTAssertEqual(firstShiny < firstNormal, shinyLast, "newest catch is first; colors must follow its individual") + if visit == 0 { + try XCTUnwrap(bitmap.representation(using: .png, properties: [:])).write(to: + URL(fileURLWithPath: "/private/tmp/dex-color-log-shiny-last-\(shinyLast).png")) + } + } + } + } + } +} diff --git a/Tests/PokeTokenBarTests/UnownTests.swift b/Tests/PokeTokenBarTests/UnownTests.swift index d9101b28c..9fe3e5689 100644 --- a/Tests/PokeTokenBarTests/UnownTests.swift +++ b/Tests/PokeTokenBarTests/UnownTests.swift @@ -262,6 +262,17 @@ final class UnownTests: XCTestCase { } } + func testAppearanceOwnershipIsIndependentForEachUnownForm() throws { + var state = CompanionState() + state.dex = [entry(.a), entry(.a, shiny: true), entry(.b, shiny: true), entry(.c)] + let s = try store(state) + XCTAssertEqual(s.unownFormSpecies.map(\.unownForm), [.a, .b, .c]) + XCTAssertEqual(s.unownFormSpecies.map(\.hasNormal), [true, false, true]) + XCTAssertEqual(s.unownFormSpecies.map(\.isShiny), [true, true, false]) + XCTAssertEqual(s.dexSpecies.first?.hasNormal, true) + XCTAssertEqual(s.dexSpecies.first?.isShiny, true) + } + func testEveryFormRoundTripsAndSharesOneSpeciesCell() throws { for form in UnownForm.allCases { let restored = try JSONDecoder().decode(MonState.self, diff --git a/docs/reference/defect-log.md b/docs/reference/defect-log.md index 00a50a0e8..b1e3e9148 100644 --- a/docs/reference/defect-log.md +++ b/docs/reference/defect-log.md @@ -25,6 +25,21 @@ read_when: ## 판정·데이터 +- **Species ownership is not an individual's appearance.** A species-level shiny flag means + at least one shiny was collected; using it for the selected individual's badge mislabeled + normal catches, and earlier evolution pages offered no way to choose their normal appearance. + Track normal and shiny ownership separately, let detail pages select an owned appearance, + and keep the selected profile, sprite, and badge consistent. Catch-log rows must always use + their own persisted color. Existing tests checked aggregate ownership and reload predicates, + while the catch-log rendering fixture contained only normal individuals; they did not exercise + mixed-color records together. Cover both acquisition orders and native rendering with distinct + synthetic sprite colors, without modifying user saves or bundling third-party artwork. + Native appearance tests must wait for the expected rendered sprite with a bounded deadline, + then assert both expected pixels and absence of the previous color. A fixed 100ms sleep passed + locally but sampled the previous sprite in macOS 15 CI: the header had updated while the + sprite's independent SwiftUI `.task(id:)` had not finished rendering. Keep transition-index + diagnostics and prove a permanently stale sprite still fails after the readiness deadline. + - **Localized metadata names must not replace persistent API identifiers.** The dex rendered ability, move, and type slugs directly, while existing tests covered species names and profile metadata rather than these visible labels. All five detail-view name sites now use a shared From 13e3bf2aa0aa5c94fc7f093516172d71f8b4b00f Mon Sep 17 00:00:00 2001 From: Killian HERZER <114735716+KillianHzr@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:12:38 +0200 Subject: [PATCH 03/15] feat(save): add local auto-backup snapshots, corruption recovery, and restore UI (#330) * feat(save): add local auto-backup snapshots, corruption recovery, and restore UI * docs: add PULL_REQUEST.md description for snapshot backup feature * fix(save): remove auto-snapshot from init and isolate snapshot directory by state URL * fix(save): read the selected snapshot before taking the safety snapshot At the 10-snapshot retention limit, the pre-restore safety snapshot pruned the oldest file before it was read, so restoring the oldest entry failed. Load and validate the selection first, then restore from memory. --- PULL_REQUEST.md | 36 +++ .../PokeTokenBar/Core/CompanionStore.swift | 83 ++++++- Sources/PokeTokenBar/Core/Localization.swift | 135 +++++++++++ Sources/PokeTokenBar/Core/SaveSnapshots.swift | 182 ++++++++++++++ Sources/PokeTokenBar/UI/SettingsView.swift | 107 +++++++++ .../LocalizationInterpolationTests.swift | 6 + .../PokeTokenBarTests/SaveSnapshotTests.swift | 223 ++++++++++++++++++ docs/reference/defect-log.md | 8 + 8 files changed, 776 insertions(+), 4 deletions(-) create mode 100644 PULL_REQUEST.md create mode 100644 Sources/PokeTokenBar/Core/SaveSnapshots.swift create mode 100644 Tests/PokeTokenBarTests/SaveSnapshotTests.swift diff --git a/PULL_REQUEST.md b/PULL_REQUEST.md new file mode 100644 index 000000000..e30b1cb28 --- /dev/null +++ b/PULL_REQUEST.md @@ -0,0 +1,36 @@ +# feat(save): add local auto-backup snapshots, corruption recovery, and restore UI + +## Summary + +This PR introduces an automated local snapshot backup and recovery mechanism for `CompanionState` without modifying the core state schema or depending on external cloud providers. + +Key enhancements: +- **Periodic automatic snapshots with rotation**: Takes point-in-time snapshots of the companion state into `/snapshots/` at an interval of at least 12 hours during regular play and saves. Retains the newest 10 snapshots automatically and prunes older ones. +- **Automatic corruption self-healing**: If `companion-state.json` is corrupted or fails to decode at startup, the app preserves the corrupted file as `.corrupt` and automatically restores from the latest valid snapshot instead of starting from a blank state. +- **Settings UI & 1-click rollback**: Adds an "Automatic Backups (Snapshots)" section within the Settings `Backup & Transfer` group: + - Instant "Create snapshot" button for manual restore points. + - Snapshot list displaying companion sprite, creation timestamp, Pokédex count, and lifetime tokens. + - "Restore" button triggering a safety confirmation dialog comparing target vs current progress, taking a pre-restore backup snapshot, and seamlessly applying the rollback. +- **Full 7-language localization & safety**: All strings are routed through `Localization.swift` across all 7 supported languages (`ko`, `en`, `ja`, `es`, `fr`, `pt`, `de`), without Hangul string literals in UI sources. `CompanionState` stored properties remain unmodified, strictly preserving `SaveTransferTests` field classification. + +## Type of change + +- [ ] Bug fix +- [x] New feature +- [ ] Refactor / cleanup +- [ ] Documentation +- [ ] Other: + +## UI changes + +| Before | After | +| ------ | ----- | +| The Settings `Backup & Transfer` section only provided manual "Export save" and "Import save" buttons requiring manual JSON file management via system file dialogs. | The Settings `Backup & Transfer` section now includes an "Automatic Backups (Snapshots)" panel with a "Create snapshot" button and a scrollable list of recent restore points showing companion thumbnails, timestamp, Pokédex count, token count, and a 1-click "Restore" button with safety confirmation. | + +## Checklist + +- [x] `swift build` and `swift test` pass locally +- [x] PR title and description are written in English +- [x] UI changes are described above (before/after — images optional) +- [x] No copyrighted assets, secrets, or private tooling references are committed (see [CONTRIBUTING](../CONTRIBUTING.md)) +- [x] Tests were added or updated for this change diff --git a/Sources/PokeTokenBar/Core/CompanionStore.swift b/Sources/PokeTokenBar/Core/CompanionStore.swift index 0e59ef6d5..7de656b90 100644 --- a/Sources/PokeTokenBar/Core/CompanionStore.swift +++ b/Sources/PokeTokenBar/Core/CompanionStore.swift @@ -50,6 +50,8 @@ final class CompanionStore { private let defaults: UserDefaults /// 세션 내 활성 개체 교체 감지용. await 뒤 이전 개체의 결과가 새 개체를 덮지 않게 한다. private var activeGeneration = 0 + private var lastAutoSnapshotDate: Date? + private(set) var availableSnapshots: [SaveSnapshot] = [] // MARK: 난이도 배율 (설정 — UserDefaults) // @@ -93,6 +95,7 @@ final class CompanionStore { defaults.set(shopDifficulty, forKey: "shopDifficulty") migratePokemonProfilesIfNeeded() refreshRepresentativeSubject() + refreshSnapshots() if state.active != nil { displayState = .idle } } @@ -661,6 +664,7 @@ final class CompanionStore { displayState = computeState(burnTier: burnTier, limitWarning: limitWarning, hasUsageData: hasUsageData, today: todayTokens) save() + autoSnapshotIfNeeded() } /// 토큰 증분을 현재 포켓몬에 적용 — 임계 도달 시 진화/졸업. @@ -1564,6 +1568,65 @@ final class CompanionStore { } } + // MARK: 스냅샷 (로컬 자동 백업 & 복원) + + func refreshSnapshots() { + availableSnapshots = SaveSnapshotManager.listSnapshots(for: fileURL) + } + + @discardableResult + func createManualSnapshot(now: Date? = nil) throws -> SaveSnapshot { + let timestamp = now ?? clock() + let snapshot = try SaveSnapshotManager.createSnapshot( + state: state, + for: fileURL, + date: timestamp, + appVersion: SaveSnapshotManager.defaultAppVersion, + deviceName: SaveSnapshotManager.defaultDeviceName + ) + lastAutoSnapshotDate = timestamp + refreshSnapshots() + return snapshot + } + + private var automaticSnapshotInterval: TimeInterval { + SaveSnapshotManager.minAutoSnapshotInterval + } + + func autoSnapshotIfNeeded(now: Date? = nil) { + let timestamp = now ?? clock() + let newest = availableSnapshots.first + + guard newest == nil || + timestamp.timeIntervalSince(newest!.date) >= automaticSnapshotInterval + else { + return + } + + guard state.usedSinceInstall > 0 || !state.dex.isEmpty || state.active != nil else { return } + + _ = try? createManualSnapshot(now: timestamp) + } + + func restoreSnapshot( + _ snapshot: SaveSnapshot, + todayTokensByProvider: [String: Int] = [:], + todayDate: String = "", + hasUsageData: Bool = false + ) throws { + // Read and validate the selected snapshot before taking the safety snapshot: at the retention + // limit, that extra file prunes the oldest one, which may be the snapshot being restored. + // A snapshot that no longer decodes also fails here, before anything is written or pruned. + let envelope = try SaveSnapshotManager.loadEnvelope(from: snapshot.fileURL) + _ = try? createManualSnapshot() + try applySave(envelope, + todayTokensByProvider: todayTokensByProvider, + todayDate: todayDate, + hasUsageData: hasUsageData) + refreshSnapshots() + AppLog.write("snapshot restored: \(snapshot.id)") + } + // MARK: Pokémon combat profiles / details /// Exact current/final individuals for a Pokédex species. Earlier evolution stages remain @@ -1707,12 +1770,24 @@ final class CompanionStore { private func load() { guard let data = try? Data(contentsOf: fileURL) else { return } // 파일 없음 = 신규 설치 guard let s = try? JSONDecoder().decode(CompanionState.self, from: data) else { - // 디코드 실패(전면 손상/미래 스키마) → fresh 로 시작하되, 다음 save() 가 원본을 덮어써 영구 - // 유실되기 전에 .corrupt 로 보존해 수동 복구 여지를 남긴다(도감 per-entry 격리로 못 살린 경우 대비). + // 디코드 실패(전면 손상/미래 스키마) → 원본을 .corrupt 로 백업 후 유효한 스냅샷으로 복구 시도. let backup = fileURL.appendingPathExtension("corrupt") try? FileManager.default.removeItem(at: backup) - try? FileManager.default.moveItem(at: fileURL, to: backup) - AppLog.write("companion state decode failed — original backed up to \(backup.lastPathComponent), starting fresh") + do { + try FileManager.default.moveItem(at: fileURL, to: backup) + AppLog.write("companion state decode failed — original backed up to \(backup.lastPathComponent)") + } catch { + AppLog.write("failed to move corrupt state file to \(backup.lastPathComponent): \(error)") + } + + if let recovered = SaveSnapshotManager.loadLatestValidSnapshot(for: fileURL) { + state = SaveTransfer.sanitized(recovered) + AppLog.write("automatically recovered companion state from snapshot") + save() + } else { + state = CompanionState() + AppLog.write("no snapshot available, starting fresh") + } return } // 불러오기 경계와 같은 정규화를 디스크에서 읽을 때도 건다. 불러오기만 막으면 **이미 저장된** diff --git a/Sources/PokeTokenBar/Core/Localization.swift b/Sources/PokeTokenBar/Core/Localization.swift index 035d35231..0644bb741 100644 --- a/Sources/PokeTokenBar/Core/Localization.swift +++ b/Sources/PokeTokenBar/Core/Localization.swift @@ -551,6 +551,141 @@ struct L { "Importado — Pokédex \(dex) · \(tokens) acumulados", "Importiert – \(dex) im Pokédex · \(tokens) insgesamt") } + + // MARK: 스냅샷 백업 (설정 → 자동 백업) + var snapshotsSectionTitle: String { + t("자동 백업 (스냅샷)", + "Automatic Backups (Snapshots)", + "自動バックアップ(スナップショット)", + "Copias automáticas (Instantáneas)", + "Sauvegardes automatiques (Instantanés)", + "Backups automáticos (Instantâneos)", + "Automatische Sicherungen (Snapshots)") + } + var createSnapshotButton: String { + t("스냅샷 만들기", + "Create snapshot", + "スナップショットを作成", + "Crear instantánea", + "Créer un instantané", + "Criar instantâneo", + "Snapshot erstellen") + } + var createSnapshotHint: String { + t("진행 상태의 로컬 복원 지점을 즉시 저장해요 (최근 10개 보존)", + "Saves an instant local restore point of your progress (keeps up to 10)", + "現在の進行状況の復元ポイントを即座に保存します(最新10件を保持)", + "Guarda un punto de restauración local de tu progreso (mantiene hasta 10)", + "Enregistre un point de restauration local de ta progression (jusqu'à 10 conservés)", + "Salva um ponto de restauração local do seu progresso (mantém até 10)", + "Speichert einen lokalen Wiederherstellungspunkt deines Fortschritts (behält bis zu 10)") + } + var snapshotCreatedToast: String { + t("스냅샷이 생성되었습니다", + "Snapshot created", + "スナップショットを作成しました", + "Instantánea creada", + "Instantané créé", + "Instantâneo criado", + "Snapshot erstellt") + } + var restoreSnapshotButton: String { + t("복원", + "Restore", + "復元", + "Restaurar", + "Restaurer", + "Restaurar", + "Wiederherstellen") + } + var restoreConfirmTitle: String { + t("이 스냅샷으로 복원할까요?", + "Restore this snapshot?", + "このスナップショットに復元しますか?", + "¿Restaurar esta instantánea?", + "Restaurer cet instantané ?", + "Restaurar este instantâneo?", + "Diesen Snapshot wiederherstellen?") + } + func restoreConfirmBody(snapshotDate: String, snapshotDex: Int, snapshotTokens: String, + currentDex: Int, currentTokens: String) -> String { + t(""" + 복원할 스냅샷: 도감 \(snapshotDex)마리 · 누적 \(snapshotTokens) + 저장 시각: \(snapshotDate) + 현재 상태: 도감 \(currentDex)마리 · 누적 \(currentTokens) + + 현재 상태는 새 스냅샷으로 자동 백업된 뒤 복원됩니다. + """, + """ + Target snapshot: \(snapshotDex) in Pokédex · \(snapshotTokens) lifetime + Created: \(snapshotDate) + Current state: \(currentDex) in Pokédex · \(currentTokens) lifetime + + Your current state will be backed up as a new snapshot before restoring. + """, + """ + 復元するスナップショット: 図鑑 \(snapshotDex)匹 · 累計 \(snapshotTokens) + 作成日時: \(snapshotDate) + 現在の状態: 図鑑 \(currentDex)匹 · 累計 \(currentTokens) + + 現在の状態は復元前に新しいスナップショットとしてバックアップされます。 + """, + """ + Instantánea a restaurar: Pokédex \(snapshotDex) · \(snapshotTokens) acumulados + Creada: \(snapshotDate) + Estado actual: Pokédex \(currentDex) · \(currentTokens) acumulados + + Tu estado actual se guardará como una nueva instantánea antes de restaurar. + """, + """ + Instantané à restaurer : Pokédex \(snapshotDex) · \(snapshotTokens) cumulés + Créé le : \(snapshotDate) + État actuel : Pokédex \(currentDex) · \(currentTokens) cumulés + + Ton état actuel sera sauvegardé dans un nouvel instantané avant la restauration. + """, + """ + Instantâneo a restaurar: Pokédex \(snapshotDex) · \(snapshotTokens) acumulados + Criado: \(snapshotDate) + Estado atual: Pokédex \(currentDex) · \(currentTokens) acumulados + + Seu estado atual será salvo como um novo instantâneo antes de restaurar. + """, + """ + Wiederherzustellender Snapshot: \(snapshotDex) im Pokédex · \(snapshotTokens) insgesamt + Erstellt: \(snapshotDate) + Aktueller Stand: \(currentDex) im Pokédex · \(currentTokens) insgesamt + + Dein aktueller Stand wird vor der Wiederherstellung als neuer Snapshot gesichert. + """) + } + func restoreDoneMessage(dex: Int, tokens: String) -> String { + t("복원되었습니다 — 도감 \(dex)마리 · 누적 \(tokens)", + "Restored — \(dex) in Pokédex · \(tokens) lifetime", + "復元しました — 図鑑 \(dex)匹 · 累計 \(tokens)", + "Restaurado — Pokédex \(dex) · \(tokens) acumulados", + "Restauré — Pokédex \(dex) · \(tokens) cumulés", + "Restaurado — Pokédex \(dex) · \(tokens) acumulados", + "Wiederhergestellt – \(dex) im Pokédex · \(tokens) insgesamt") + } + var noSnapshotsYet: String { + t("아직 저장된 스냅샷이 없습니다", + "No snapshots saved yet", + "保存されたスナップショットはまだありません", + "Aún no hay instantáneas guardadas", + "Aucun instantané enregistré pour l'instant", + "Nenhum instantâneo salvo ainda", + "Noch keine Snapshots gespeichert") + } + func snapshotDexAndTokens(dex: Int, tokens: String) -> String { + t("도감 \(dex)마리 · 누적 \(tokens)", + "Pokédex \(dex) · \(tokens) lifetime", + "図鑑 \(dex)匹 · 累計 \(tokens)", + "Pokédex \(dex) · \(tokens) acumulados", + "Pokédex \(dex) · \(tokens) cumulés", + "Pokédex \(dex) · \(tokens) acumulados", + "Pokédex \(dex) · \(tokens) insgesamt") + } var importErrorNotSaveFile: String { t("PokeTokenBar 세이브 파일이 아니에요.", "That isn't a PokeTokenBar save file.", diff --git a/Sources/PokeTokenBar/Core/SaveSnapshots.swift b/Sources/PokeTokenBar/Core/SaveSnapshots.swift new file mode 100644 index 000000000..5707a1cdf --- /dev/null +++ b/Sources/PokeTokenBar/Core/SaveSnapshots.swift @@ -0,0 +1,182 @@ +import Foundation + +/// A point-in-time local snapshot of CompanionState, with summary metadata for listing and rollback. +struct SaveSnapshot: Identifiable, Sendable, Equatable { + let id: String + let fileURL: URL + let date: Date + let dexCount: Int + let lifetimeTokens: Int + let currentSpeciesID: Int? + let currentIsShiny: Bool +} + +enum SaveSnapshotManager { + static let snapshotPrefix = "companion-snapshot-" + static let maxSnapshotsToKeep = 10 + static let minAutoSnapshotInterval: TimeInterval = 12 * 3600 // 12 hours between auto-snapshots + + static var defaultDeviceName: String { + Host.current().localizedName ?? ProcessInfo.processInfo.hostName + } + + static var defaultAppVersion: String { + Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "2.5.0" + } + + /// Directory where local snapshots are stored: `/.snapshots//` + static func snapshotsDirectory(for stateURL: URL) -> URL { + let dir = stateURL.deletingLastPathComponent() + .appendingPathComponent(".snapshots") + .appendingPathComponent(stateURL.lastPathComponent, isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + /// Generates a snapshot filename for the given timestamp. + static func snapshotFileName(date: Date) -> String { + "\(snapshotPrefix)\(secondStamp(date)).json" + } + + private static func secondStamp(_ date: Date) -> String { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.dateFormat = "yyyy-MM-dd-HHmmss" + return f.string(from: date) + } + + private static func parseStamp(_ filename: String) -> Date? { + guard filename.hasPrefix(snapshotPrefix) && filename.hasSuffix(".json") else { return nil } + var raw = String(filename.dropFirst(snapshotPrefix.count).dropLast(".json".count)) + if let dash = raw.lastIndex(of: "-"), let _ = Int(raw[raw.index(after: dash)...]) { + raw = String(raw[.. [SaveSnapshot] { + let dir = snapshotsDirectory(for: stateFileURL) + guard let files = try? FileManager.default.contentsOfDirectory(atPath: dir.path) else { + return [] + } + + var results: [SaveSnapshot] = [] + for file in files where file.hasPrefix(snapshotPrefix) && file.hasSuffix(".json") { + let url = dir.appendingPathComponent(file) + guard let data = try? Data(contentsOf: url) else { continue } + if let envelope = try? SaveTransfer.decode(data) { + let st = envelope.state + results.append(SaveSnapshot( + id: file, + fileURL: url, + date: envelope.exportedAt, + dexCount: st.dex.count, + lifetimeTokens: st.usedSinceInstall, + currentSpeciesID: st.active?.currentID, + currentIsShiny: st.active?.isShiny ?? false + )) + } else if let st = try? JSONDecoder().decode(CompanionState.self, from: data) { + let date = parseStamp(file) ?? (try? url.resourceValues(forKeys: [.creationDateKey]).creationDate) ?? Date() + results.append(SaveSnapshot( + id: file, + fileURL: url, + date: date, + dexCount: st.dex.count, + lifetimeTokens: st.usedSinceInstall, + currentSpeciesID: st.active?.currentID, + currentIsShiny: st.active?.isShiny ?? false + )) + } + } + + return results.sorted { + if $0.date != $1.date { return $0.date > $1.date } + return $0.id > $1.id + } + } + + /// Loads and returns the SaveEnvelope from a snapshot file. + static func loadEnvelope(from url: URL) throws -> SaveEnvelope { + let data = try Data(contentsOf: url) + if let envelope = try? SaveTransfer.decode(data) { + return envelope + } + if let st = try? JSONDecoder().decode(CompanionState.self, from: data) { + let date = parseStamp(url.lastPathComponent) + ?? (try? url.resourceValues(forKeys: [.creationDateKey]).creationDate) + ?? Date() + return SaveEnvelope( + format: SaveEnvelope.formatID, + schema: SaveEnvelope.schemaVersion, + appVersion: defaultAppVersion, + exportedAt: date, + sourceDevice: defaultDeviceName, + state: SaveTransfer.sanitized(st) + ) + } + throw SaveTransferError.notASaveFile + } + + /// Loads sanitized CompanionState from a snapshot file. + static func loadState(from url: URL) -> CompanionState? { + try? loadEnvelope(from: url).state + } + + /// Creates a snapshot from the current CompanionState and saves it into the snapshots directory. + @discardableResult + static func createSnapshot( + state: CompanionState, + for stateFileURL: URL, + date: Date, + appVersion: String = defaultAppVersion, + deviceName: String = defaultDeviceName + ) throws -> SaveSnapshot { + let dir = snapshotsDirectory(for: stateFileURL) + let filename = snapshotFileName(date: date) + var targetURL = dir.appendingPathComponent(filename) + if FileManager.default.fileExists(atPath: targetURL.path) { + let base = filename.dropLast(".json".count) + var counter = 1 + while FileManager.default.fileExists(atPath: dir.appendingPathComponent("\(base)-\(counter).json").path) { + counter += 1 + } + targetURL = dir.appendingPathComponent("\(base)-\(counter).json") + } + let data = try SaveTransfer.encode(state: state, appVersion: appVersion, deviceName: deviceName, now: date) + try data.write(to: targetURL, options: .atomic) + pruneOldSnapshots(for: stateFileURL) + return SaveSnapshot( + id: targetURL.lastPathComponent, + fileURL: targetURL, + date: date, + dexCount: state.dex.count, + lifetimeTokens: state.usedSinceInstall, + currentSpeciesID: state.active?.currentID, + currentIsShiny: state.active?.isShiny ?? false + ) + } + + /// Prunes snapshots keeping only the newest `maxSnapshotsToKeep`. + static func pruneOldSnapshots(for stateFileURL: URL) { + let snapshots = listSnapshots(for: stateFileURL) + guard snapshots.count > maxSnapshotsToKeep else { return } + for old in snapshots.dropFirst(maxSnapshotsToKeep) { + try? FileManager.default.removeItem(at: old.fileURL) + } + } + + /// Returns the most recent valid snapshot, if any. + static func latestSnapshot(for stateFileURL: URL) -> SaveSnapshot? { + listSnapshots(for: stateFileURL).first + } + + /// Loads the latest valid snapshot as CompanionState, if available. + static func loadLatestValidSnapshot(for stateFileURL: URL) -> CompanionState? { + guard let latest = latestSnapshot(for: stateFileURL) else { return nil } + return loadState(from: latest.fileURL) + } +} diff --git a/Sources/PokeTokenBar/UI/SettingsView.swift b/Sources/PokeTokenBar/UI/SettingsView.swift index 54027ceef..6938ded5b 100644 --- a/Sources/PokeTokenBar/UI/SettingsView.swift +++ b/Sources/PokeTokenBar/UI/SettingsView.swift @@ -82,6 +82,7 @@ struct SettingsView: View { .frame(maxWidth: .infinity, alignment: .leading) } .onAppear { + companion.refreshSnapshots() guard !didApplyStartExpanded else { return } didApplyStartExpanded = true if startExpanded { @@ -408,6 +409,61 @@ struct SettingsView: View { Spacer() Button(l.importSaveButton) { importSave(store) } } + Divider() + snapshotsSection(store) + } + } + + @ViewBuilder + private func snapshotsSection(_ store: UsageStore) -> some View { + groupRow { + VStack(alignment: .leading, spacing: 1) { + Text(l.snapshotsSectionTitle) + Text(l.createSnapshotHint).font(.caption2).foregroundStyle(.tertiary) + } + Spacer() + Button(l.createSnapshotButton) { takeManualSnapshot() } + } + if companion.availableSnapshots.isEmpty { + groupRow { + Text(l.noSnapshotsYet).font(.caption2).foregroundStyle(.tertiary) + Spacer() + } + } else { + ForEach(companion.availableSnapshots) { snapshot in + Divider() + snapshotRow(snapshot, store: store) + } + } + } + + @ViewBuilder + private func snapshotRow(_ snapshot: SaveSnapshot, store: UsageStore) -> some View { + groupRow { + HStack(spacing: 8) { + if let speciesID = snapshot.currentSpeciesID { + SpriteView(speciesID: speciesID, size: 28, shiny: snapshot.currentIsShiny) + .frame(width: 28, height: 28) + } else { + SpriteView(speciesID: nil, size: 28) + .frame(width: 28, height: 28) + } + VStack(alignment: .leading, spacing: 2) { + Text(Self.exportedAtText(snapshot.date, language: companion.language)) + .font(.caption) + Text(l.snapshotDexAndTokens( + dex: snapshot.dexCount, + tokens: TokenFormatter.compact(snapshot.lifetimeTokens) + )) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + Spacer() + Button(l.restoreSnapshotButton) { + confirmAndRestoreSnapshot(snapshot, store: store) + } + .controlSize(.small) } } @@ -883,6 +939,57 @@ struct SettingsView: View { style: .informational) } + private func takeManualSnapshot() { + do { + try companion.createManualSnapshot() + presentAlert(title: l.createSnapshotButton, message: l.snapshotCreatedToast, style: .informational) + } catch { + AppLog.write("manual snapshot creation failed: \(error)") + presentAlert(title: l.createSnapshotButton, message: l.userFacingError(error), style: .warning) + } + } + + private func confirmAndRestoreSnapshot(_ snapshot: SaveSnapshot, store: UsageStore) { + let current = companion.transferSummary + let confirm = NSAlert() + confirm.alertStyle = .warning + confirm.messageText = l.restoreConfirmTitle + confirm.informativeText = l.restoreConfirmBody( + snapshotDate: Self.exportedAtText(snapshot.date, language: companion.language), + snapshotDex: snapshot.dexCount, + snapshotTokens: TokenFormatter.compact(snapshot.lifetimeTokens), + currentDex: current.dexCount, + currentTokens: TokenFormatter.compact(current.lifetimeTokens) + ) + confirm.addButton(withTitle: l.restoreSnapshotButton) + confirm.addButton(withTitle: l.cancel) + for (index, button) in confirm.buttons.enumerated() { + button.keyEquivalent = ImportConfirmPolicy.keyEquivalent(forButtonAt: index) + } + NSApp.activate(ignoringOtherApps: true) + guard confirm.runModal() == .alertFirstButtonReturn else { return } + + do { + try companion.restoreSnapshot( + snapshot, + todayTokensByProvider: store.todayTokensByProvider, + todayDate: LocalUsageReader.todayKey(), + hasUsageData: store.hasUsageData + ) + presentAlert( + title: l.restoreSnapshotButton, + message: l.restoreDoneMessage( + dex: snapshot.dexCount, + tokens: TokenFormatter.compact(snapshot.lifetimeTokens) + ), + style: .informational + ) + } catch { + AppLog.write("snapshot restore failed: \(error)") + presentAlert(title: l.restoreSnapshotButton, message: l.importErrorMessage(error), style: .warning) + } + } + /// 확인창에 보일 내보낸 시각 — 사용자 로케일 기준 짧은 표기. static func exportedAtText(_ date: Date, language: AppLanguage) -> String { let f = DateFormatter() diff --git a/Tests/PokeTokenBarTests/LocalizationInterpolationTests.swift b/Tests/PokeTokenBarTests/LocalizationInterpolationTests.swift index 66db06d66..7e621d9ca 100644 --- a/Tests/PokeTokenBarTests/LocalizationInterpolationTests.swift +++ b/Tests/PokeTokenBarTests/LocalizationInterpolationTests.swift @@ -73,6 +73,12 @@ final class LocalizationInterpolationTests: XCTestCase { currentDex: 1717, currentTokens: b), "4242", a, "ZQXEXPORTEDAT", "ZQXDEVICE", "1717", b) expect(lang, "importSaveDone", l.importSaveDone(dex: 4242, tokens: a), "4242", a) + expect(lang, "restoreConfirmBody", + l.restoreConfirmBody(snapshotDate: "ZQXEXPORTEDAT", snapshotDex: 4242, + snapshotTokens: a, currentDex: 1717, currentTokens: b), + "ZQXEXPORTEDAT", "4242", a, "1717", b) + expect(lang, "restoreDoneMessage", l.restoreDoneMessage(dex: 4242, tokens: a), "4242", a) + expect(lang, "snapshotDexAndTokens", l.snapshotDexAndTokens(dex: 4242, tokens: a), "4242", a) // Mail report / 메일 리포트 expect(lang, "reportMailFallback", l.reportMailFallback(a), a) diff --git a/Tests/PokeTokenBarTests/SaveSnapshotTests.swift b/Tests/PokeTokenBarTests/SaveSnapshotTests.swift new file mode 100644 index 000000000..87660cf7e --- /dev/null +++ b/Tests/PokeTokenBarTests/SaveSnapshotTests.swift @@ -0,0 +1,223 @@ +import XCTest +@testable import PokeTokenBar + +private enum SnapshotStubError: Error { case unavailable } + +private struct OfflineProvider: PokeProviding { + func line(baseSpeciesID: Int) async throws -> EvoLine { throw SnapshotStubError.unavailable } + func baseSpeciesIndex() async throws -> [BaseSpecies] { throw SnapshotStubError.unavailable } + func baseSpecies(id: Int) async throws -> BaseSpecies? { throw SnapshotStubError.unavailable } +} + +private final class MutableClock: @unchecked Sendable { + var now: Date + init(_ now: Date) { self.now = now } +} + +@MainActor +final class SaveSnapshotTests: XCTestCase { + private let baseNow = Date(timeIntervalSince1970: 1_700_000_000) + + private func tempURL(_ tag: String) -> URL { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("ptb-snapshot-\(tag)-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir.appendingPathComponent("companion-state.json") + } + + private func sampleState(tokens: Int, dexCount: Int) -> CompanionState { + var s = CompanionState() + s.installBaselineSet = true + s.usedSinceInstall = tokens + s.spentTokens = 1_000 + for i in 1...max(0, dexCount) { + s.dex.append(DexEntry(baseID: i, finalID: i, chainOrder: [i], rarity: .common, caughtAt: baseNow)) + } + s.active = MonState(baseID: 1, pathIDs: [1], plannedPathIDs: [1], stageIndex: 0, usedAtStage: 500, rarity: .common, totalForms: 1) + return s + } + + func testSnapshotCreationAndListing() throws { + let url = tempURL("create") + let clock = MutableClock(baseNow) + var state = sampleState(tokens: 12_500, dexCount: 3) + try JSONEncoder().encode(state).write(to: url) + + let store = CompanionStore(provider: OfflineProvider(), clock: { clock.now }, fileURL: url) + XCTAssertEqual(store.availableSnapshots.count, 0) + + let snapshot = try store.createManualSnapshot() + XCTAssertEqual(snapshot.dexCount, 3) + XCTAssertEqual(snapshot.lifetimeTokens, 12_500) + XCTAssertEqual(snapshot.currentSpeciesID, 1) + XCTAssertFalse(snapshot.currentIsShiny) + XCTAssertTrue(FileManager.default.fileExists(atPath: snapshot.fileURL.path)) + + XCTAssertEqual(store.availableSnapshots.count, 1) + XCTAssertEqual(store.availableSnapshots.first?.id, snapshot.id) + } + + func testSnapshotPruningKeepsAtMostTen() throws { + let url = tempURL("pruning") + let state = sampleState(tokens: 20_000, dexCount: 2) + try JSONEncoder().encode(state).write(to: url) + + for i in 1...15 { + let date = baseNow.addingTimeInterval(Double(i * 3600)) + try SaveSnapshotManager.createSnapshot(state: state, for: url, date: date) + } + + let snapshots = SaveSnapshotManager.listSnapshots(for: url) + XCTAssertEqual(snapshots.count, 10, "Should keep at most 10 snapshots") + + // Newest snapshot should be from the 15th iteration + let newestDate = baseNow.addingTimeInterval(Double(15 * 3600)) + let oldestKeptDate = baseNow.addingTimeInterval(Double(6 * 3600)) + XCTAssertEqual(snapshots.first?.date, newestDate) + XCTAssertEqual(snapshots.last?.date, oldestKeptDate) + } + + func testAutoSnapshotIntervalGuard() throws { + let url = tempURL("autointerval") + let clock = MutableClock(baseNow) + let state = sampleState(tokens: 5_000, dexCount: 1) + try JSONEncoder().encode(state).write(to: url) + + let store = CompanionStore(provider: OfflineProvider(), clock: { clock.now }, fileURL: url) + try store.createManualSnapshot() + XCTAssertEqual(store.availableSnapshots.count, 1) + + // 1 hour later: should not snapshot + clock.now = baseNow.addingTimeInterval(3600) + store.autoSnapshotIfNeeded() + XCTAssertEqual(store.availableSnapshots.count, 1) + + // 13 hours later: should snapshot + clock.now = baseNow.addingTimeInterval(13 * 3600) + store.autoSnapshotIfNeeded() + XCTAssertEqual(store.availableSnapshots.count, 2) + } + + func testAutoCorruptionRecoveryFromLatestSnapshot() throws { + let url = tempURL("recovery") + let clock = MutableClock(baseNow) + let state = sampleState(tokens: 77_777, dexCount: 5) + try JSONEncoder().encode(state).write(to: url) + + let store = CompanionStore(provider: OfflineProvider(), clock: { clock.now }, fileURL: url) + try store.createManualSnapshot() + XCTAssertEqual(store.availableSnapshots.count, 1) + + // Corrupt main state file + let garbage = "{ corrupted state json missing brackets..." + try Data(garbage.utf8).write(to: url) + + // Recreate store at the same URL + let recoveredStore = CompanionStore(provider: OfflineProvider(), clock: { clock.now }, fileURL: url) + + // Verify state was automatically recovered from the snapshot + XCTAssertEqual(recoveredStore.state.usedSinceInstall, 77_777) + XCTAssertEqual(recoveredStore.state.dex.count, 5) + XCTAssertEqual(recoveredStore.state.active?.baseID, 1) + + // Verify .corrupt backup file was created + let corruptBackup = url.appendingPathExtension("corrupt") + XCTAssertTrue(FileManager.default.fileExists(atPath: corruptBackup.path)) + XCTAssertEqual(try String(contentsOf: corruptBackup, encoding: .utf8), garbage) + } + + func testRestoreSnapshotRollsBackProgressAndPreservesCurrentAsSnapshot() throws { + let url = tempURL("restore") + let clock = MutableClock(baseNow) + + // Snapshot 1 (earlier state) + let state1 = sampleState(tokens: 10_000, dexCount: 1) + try JSONEncoder().encode(state1).write(to: url) + let store = CompanionStore(provider: OfflineProvider(), clock: { clock.now }, fileURL: url) + let snapshot1 = try store.createManualSnapshot() + + // Advance to Snapshot 2 (later state) + clock.now = baseNow.addingTimeInterval(3600) + let state2 = sampleState(tokens: 90_000, dexCount: 4) + try JSONEncoder().encode(state2).write(to: url) + let store2 = CompanionStore(provider: OfflineProvider(), clock: { clock.now }, fileURL: url) + XCTAssertEqual(store2.state.usedSinceInstall, 90_000) + XCTAssertEqual(store2.state.dex.count, 4) + + // Restore Snapshot 1 + clock.now = baseNow.addingTimeInterval(7200) + try store2.restoreSnapshot(snapshot1) + + // Verify progress is rolled back to snapshot 1 + XCTAssertEqual(store2.state.usedSinceInstall, 10_000) + XCTAssertEqual(store2.state.dex.count, 1) + + // Verify snapshot of pre-restore state (state 2) was saved + let snapshots = store2.availableSnapshots + XCTAssertTrue(snapshots.contains { $0.lifetimeTokens == 90_000 && $0.dexCount == 4 }) + } + + func testRestoreOldestSnapshotAtRetentionLimit() throws { + let url = tempURL("restore-oldest-at-limit") + let clock = MutableClock(baseNow) + + // Fill the retention limit exactly; the oldest entry carries distinct progress. + for i in 0.. Date: Fri, 25 Sep 2026 04:13:38 +0200 Subject: [PATCH 04/15] feat: add a usage recap for any week, month or year (#332) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add a usage recap for any week, month or year A recap screen opens from the week/month row on Home: one calendar week, month or year at a time, with Previous and Next to step back through time. The card shows the total, one meter per day (per month in the year view), the change against the previous period, best day, best streak, active days and the individuals graduated in that period. It copies to the clipboard or saves as a PNG; as with the rest of the app, nothing leaves the Mac. Providers only report the current month, so every refresh copies the daily totals into a local ledger in UserDefaults. A day never goes down, which survives rotated logs, and the ledger keeps this year and the last one so the year view has something to compare against. The ledger stores the first day it can vouch for, next to the rows. That is what tells "no usage" from "not recorded yet": days before it, and days still ahead, are drawn as unknown instead of as zeros, and a comparison is only shown when the previous period is covered from its first day. A period still running is compared over the same number of days, so the 18th of the month does not read as a drop against a whole month. The ledger is written after the enrichment phase, where the month series actually arrives; writing it after phase 1 left the first refresh empty. * feat(recap): carry the Unown letter onto the recap card The recap was branched before Unown forms landed: the raised Pokémon and the graduates fell back to the default A sprite, and the strip named them all "Unown". The export preloads the same form, since ImageRenderer only draws what is already cached. * fix(recap): remove the PNG export Copy image and Save PNG composed Pokémon sprites into an exported image, which the copyrighted-asset policy in CONTRIBUTING.md rules out. The recap stays in the app; RecapExport, its sprite preload, the three export strings and the file-name assertion go. --- README.ja.md | 1 + README.ko.md | 1 + README.md | 1 + Sources/PokeTokenBar/Core/Localization.swift | 50 +++ Sources/PokeTokenBar/Core/UsageRecap.swift | 233 ++++++++++ Sources/PokeTokenBar/Core/UsageStore.swift | 22 + Sources/PokeTokenBar/UI/PopoverView.swift | 11 + Sources/PokeTokenBar/UI/UsageRecapView.swift | 410 ++++++++++++++++++ Tests/PokeTokenBarTests/UsageRecapTests.swift | 386 +++++++++++++++++ docs/reference/defect-log.md | 20 + scripts/test-gate.sh | 1 + 11 files changed, 1136 insertions(+) create mode 100644 Sources/PokeTokenBar/Core/UsageRecap.swift create mode 100644 Sources/PokeTokenBar/UI/UsageRecapView.swift create mode 100644 Tests/PokeTokenBarTests/UsageRecapTests.swift diff --git a/README.ja.md b/README.ja.md index 640254819..b1ca6f7f2 100644 --- a/README.ja.md +++ b/README.ja.md @@ -167,6 +167,7 @@ Pi は複数のモデルを一つのセッションログに流すことがあ - **複数の Claude アカウント** — 設定フォルダ(`CLAUDE_CONFIG_DIR`)ごとにログインした Claude Code アカウントには公式上限の下にそれぞれのタブが付き、ゲージ・ふしぎなアメ・通知・今日/今月のトークンを個別に表示します。`~/.claude-*` フォルダとエクスポートされた `CLAUDE_CONFIG_DIR` は自動で見つけ、それ以外の場所は設定 → 詳細で追加します。メニューバーの上限 %・コンパニオンの様子・予測は最後に使ったアカウントに従います(設定 → 一般 → 追跡する Claude アカウント)。 - **消費予測** — 現在の5時間ウィンドウが100%に達する時刻を予測。 - **アプリ内アップデート** — ワンクリックの更新確認、設定に現在のバージョンを表示。 +- **使用量のふりかえり** — 週・月・年ごとに過去までさかのぼれるポケモン図鑑風のまとめ(合計・ゲージ・前の期間との比較(進行中は同じ日数どうし)・最高の日・最長連続・卒業)。記録は今年と昨年分をローカルに保存。 ## 対応ツール diff --git a/README.ko.md b/README.ko.md index 088b22ff7..edd8bc75f 100644 --- a/README.ko.md +++ b/README.ko.md @@ -167,6 +167,7 @@ Pi는 모델 여러 개를 세션 로그 하나로 흘려보낼 수 있습니다 - **여러 Claude 계정** — 설정 폴더(`CLAUDE_CONFIG_DIR`)마다 따로 로그인한 Claude Code 계정은 공식 한도 아래에 각자의 탭이 생기고, 게이지·이상한 사탕·알림·오늘/이번 달 토큰을 따로 보여 줍니다. `~/.claude-*` 폴더와 export 된 `CLAUDE_CONFIG_DIR` 은 자동으로 찾고, 다른 위치는 설정 → 고급에서 추가합니다. 메뉴바 한도 %·컴패니언 기분·예측은 마지막으로 사용한 계정을 따릅니다(설정 → 일반 → 추적할 Claude 계정). - **소진 예측** — 현재 5시간 창이 100%에 도달할 시각 예측. - **인앱 업데이트** — 원클릭 업데이트 확인, 설정에 현재 버전 표시. +- **사용량 돌아보기** — 주·월·연 단위로 지난 기간까지 넘겨 보는 포켓몬 도감 스타일 요약(합계·게이지·이전 기간과 비교(진행 중이면 같은 일수끼리)·최고의 날·최장 연속·졸업). 기록은 올해와 작년분을 로컬에 보관. ## 지원 도구 diff --git a/README.md b/README.md index 1ca8ed26f..af68c5ca5 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,7 @@ Reset countdowns include the clock time, and the colored percentage stays aligne - **Several Claude accounts** — Claude Code logins kept in their own config folder (`CLAUDE_CONFIG_DIR`) each get a tab under the official limits, with their own gauges, Rare Candy, alerts, and today/month tokens. `~/.claude-*` folders and an exported `CLAUDE_CONFIG_DIR` are found automatically; add others in Settings → Advanced. The menu bar percentage, companion mood, and forecast follow the account you used last (Settings → General → Tracked Claude account). - **Burn-rate forecast** — projects when the current 5h window hits 100%. - **In-app updates** — one-click update check; current version shown in Settings. +- **Usage recap** — a Pokédex-style recap of any week, month or year you step back to (total, meters, comparison with the previous period, day for day while it is still running, best day, best streak, graduations). History is kept locally for this year and the last. ## Works with diff --git a/Sources/PokeTokenBar/Core/Localization.swift b/Sources/PokeTokenBar/Core/Localization.swift index 0644bb741..344c60863 100644 --- a/Sources/PokeTokenBar/Core/Localization.swift +++ b/Sources/PokeTokenBar/Core/Localization.swift @@ -165,6 +165,56 @@ struct L { var evolutionScrollNext: String { t("다음 진화 보기", "Show next evolutions", "次の進化を見る", "Ver evoluciones siguientes", "Voir les évolutions suivantes", "Ver próximas evoluções", "Nächste Entwicklungen anzeigen") } var back: String { t("뒤로", "Back", "戻る", "Atrás", "Retour", "Voltar", "Zurück") } + + // MARK: Usage recap + var recapOpen: String { t("사용량 돌아보기", "Usage recap", "使用量のふりかえり", "Resumen de uso", "Récap d'utilisation", "Resumo de uso", "Nutzungsrückblick") } + var recapTokensUnit: String { t("토큰", "tokens", "トークン", "tokens", "tokens", "tokens", "Tokens") } + func recapScopeName(_ scope: RecapScope) -> String { + switch scope { + case .week: t("주간", "Week", "週", "Semana", "Semaine", "Semana", "Woche") + case .month: t("월간", "Month", "月", "Mes", "Mois", "Mês", "Monat") + case .year: t("연간", "Year", "年", "Año", "Année", "Ano", "Jahr") + } + } + /// Shown under a running period, whose chip compares the same number of days. + func recapCompareSoFar(_ scope: RecapScope) -> String { + switch scope { + case .week: t("지난주 같은 기간과 비교", "Compared with the same days last week", "先週の同じ期間と比較", + "Comparado con los mismos días de la semana pasada", "Comparé aux mêmes jours de la semaine dernière", + "Comparado com os mesmos dias da semana passada", "Verglichen mit denselben Tagen der Vorwoche") + case .month: t("지난달 같은 기간과 비교", "Compared with the same days last month", "先月の同じ期間と比較", + "Comparado con los mismos días del mes pasado", "Comparé aux mêmes jours du mois dernier", + "Comparado com os mesmos dias do mês passado", "Verglichen mit denselben Tagen des Vormonats") + case .year: t("작년 같은 기간과 비교", "Compared with the same days last year", "昨年の同じ期間と比較", + "Comparado con los mismos días del año pasado", "Comparé aux mêmes jours de l'an dernier", + "Comparado com os mesmos dias do ano passado", "Verglichen mit denselben Tagen des Vorjahres") + } + } + var recapPrevious: String { t("이전", "Previous", "前へ", "Anterior", "Précédent", "Anterior", "Vorherige") } + var recapNext: String { t("다음", "Next", "次へ", "Siguiente", "Suivant", "Próximo", "Nächste") } + var recapBestDay: String { t("최고의 날", "Best day", "最高の日", "Mejor día", "Meilleur jour", "Melhor dia", "Bester Tag") } + var recapActiveDays: String { t("활동한 날", "Active days", "稼働日", "Días activos", "Jours actifs", "Dias ativos", "Aktive Tage") } + var recapBestStreak: String { t("최장 연속", "Best streak", "最長連続", "Mejor racha", "Meilleure série", "Melhor sequência", "Beste Serie") } + var recapGraduates: String { t("졸업", "Graduated", "卒業", "Graduados", "Diplômés", "Formados", "Abschlüsse") } + var recapNoData: String { t("기록 없음", "no data", "記録なし", "sin datos", "pas de données", "sem dados", "keine Daten") } + func recapDays(_ count: Int) -> String { + t("\(count)일", "\(count)d", "\(count)日", "\(count) d", "\(count) j", "\(count) d", "\(count) T") + } + func recapBestDayLine(_ day: String, _ tokens: String) -> String { + t("최고의 날은 \(day) — \(tokens) 토큰.", + "Your best day was \(day) — \(tokens) tokens.", + "最高の日は \(day)、\(tokens) トークン。", + "Tu mejor día fue el \(day): \(tokens) tokens.", + "Ton meilleur jour, c'était \(day) : \(tokens) tokens.", + "Seu melhor dia foi \(day): \(tokens) tokens.", + "Dein bester Tag war \(day) — \(tokens) Tokens.") + } + var recapNoGraduates: String { + t("이 기간에 졸업한 포켓몬이 없어요.", "No graduation in this period.", "この期間の卒業はありません。", + "Ninguna graduación en este periodo.", "Aucun diplômé sur cette période.", + "Nenhuma formatura neste período.", "Kein Abschluss in diesem Zeitraum.") + } + var generalSectionTitle: String { t("일반", "General", "一般", "General", "Général", "Geral", "Allgemein") } var menuBarSectionTitle: String { t("메뉴바에 표시", "Show in menu bar", "メニューバーに表示", "Mostrar en la barra de menús", "Afficher dans la barre des menus", "Mostrar na barra de menus", "In der Menüleiste anzeigen") } var advancedSectionTitle: String { t("고급", "Advanced", "詳細", "Avanzado", "Avancé", "Avançado", "Erweitert") } diff --git a/Sources/PokeTokenBar/Core/UsageRecap.swift b/Sources/PokeTokenBar/Core/UsageRecap.swift new file mode 100644 index 000000000..527647964 --- /dev/null +++ b/Sources/PokeTokenBar/Core/UsageRecap.swift @@ -0,0 +1,233 @@ +import Foundation + +/// Day-by-day token totals kept across months, so a recap survives the 1st of the month. +/// +/// Providers only report the current month (`monthDaily`), so on the 3rd a seven-day window would +/// lose four days and a year would be out of reach. This ledger is a local cache of numbers the app +/// already derived — no new user state, nothing to transfer between devices, and it rebuilds itself +/// from the next refresh. +struct UsageLedger: Equatable, Sendable { + static let defaultsKey = "dailyTokenLedger" + static let coverageKey = "dailyTokenLedgerSince" + + private(set) var tokensByDay: [String: Int] = [:] + /// The first day the ledger vouches for. From there on a missing row means "no usage"; before it + /// the app was not recording, which a recap must not present as a quiet stretch. + private(set) var coveredSince: String? + + func tokens(on day: String) -> Int { tokensByDay[day] ?? 0 } + + func covers(_ day: String) -> Bool { coveredSince.map { day >= $0 } ?? false } + + /// Records a refresh. A day never goes down: a rotated or pruned log makes the provider report + /// less than it did yesterday, and the recap would lose days that really happened. Today keeps + /// climbing all day, so taking the larger of the two is also what the live total does. + mutating func merge(_ series: [DailyUsage]) { + for day in series where day.totalTokens > 0 { + tokensByDay[day.date] = max(tokensByDay[day.date] ?? 0, day.totalTokens) + } + // The series lists every day since the 1st, empty ones included, so the whole stretch was + // scanned: that is what makes a zero a real zero. + if let first = series.map(\.date).min(), coveredSince.map({ first < $0 }) ?? true { + coveredSince = first + } + } + + /// Drops what no recap can reach any more. Day keys are `yyyy-MM-dd`, which sorts + /// lexicographically the same way it sorts chronologically. + mutating func prune(before cutoff: String) { + tokensByDay = tokensByDay.filter { $0.key >= cutoff } + // The dropped days are unknown again, not empty. + if let since = coveredSince, since < cutoff { coveredSince = cutoff } + } + + static func load(from defaults: UserDefaults) -> UsageLedger { + var ledger = UsageLedger() + // Hand-edited defaults are data, not truth: keep what parses as a day and drop the rest. + // A junk key that sorted below every real day would otherwise backdate the coverage. + let formatter = LocalUsageReader.localDayFormatter() + if let stored = defaults.dictionary(forKey: defaultsKey) as? [String: Int] { + ledger.tokensByDay = stored.filter { formatter.date(from: $0.key) != nil && $0.value > 0 } + } + if let since = defaults.string(forKey: coverageKey), formatter.date(from: since) != nil { + ledger.coveredSince = since + } + return ledger + } + + func save(to defaults: UserDefaults) { + defaults.set(tokensByDay, forKey: Self.defaultsKey) + defaults.set(coveredSince, forKey: Self.coverageKey) + } +} + +enum RecapScope: String, CaseIterable, Sendable { + case week, month, year + + fileprivate var component: Calendar.Component { + switch self { + case .week: .weekOfYear + case .month: .month + case .year: .year + } + } +} + +/// One calendar week, month or year. Calendar periods rather than rolling windows, so stepping +/// back lands on "the week of the 7th", not on an arbitrary seven days. +struct RecapPeriod: Equatable, Sendable { + let scope: RecapScope + /// First instant of the period. + let start: Date + /// First instant after the period. + let end: Date + + init(scope: RecapScope, containing date: Date, calendar: Calendar) { + self.scope = scope + // API-forced unwrap: every Gregorian date sits in a week, a month and a year. + let interval = calendar.dateInterval(of: scope.component, for: date) + ?? DateInterval(start: calendar.startOfDay(for: date), duration: 86_400) + start = interval.start + end = interval.end + } + + func shifted(by offset: Int, calendar: Calendar) -> RecapPeriod { + let moved = calendar.date(byAdding: scope.component, value: offset, to: start) ?? start + return RecapPeriod(scope: scope, containing: moved, calendar: calendar) + } + + /// Gregorian whatever the system calendar, so a year runs January to December and the day keys + /// match `LocalUsageReader.localDayFormatter`. The week still starts on the user's first weekday. + static func calendar(timeZone: TimeZone = .current) -> Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = timeZone + calendar.firstWeekday = Calendar.current.firstWeekday + return calendar + } +} + +/// A recap of one period — read-only, computed on demand. Nothing here is a reward or a goal: it +/// reports what already happened, so the app still asks nothing of the user. +// Not Equatable: `DexEntry` is not, and the graduated rows carry it whole. +struct UsageRecap: Sendable { + /// One meter: a day in the week and month views, a month in the year view. + struct Bucket: Equatable, Sendable { + /// `yyyy-MM-dd` of the bucket's first day. + let key: String + let tokens: Int + /// Holds today. + let isCurrent: Bool + /// False for the future and for days before the ledger started: unknown, not zero. + let hasData: Bool + } + + struct Day: Equatable, Sendable { + let key: String + let tokens: Int + } + + let period: RecapPeriod + let buckets: [Bucket] + let total: Int + /// Today falls inside the period. + let isInProgress: Bool + /// The previous period over the same number of days while this one is running, the whole of it + /// otherwise. nil when the ledger does not reach its first day: days the app never saw would + /// count as zero and invent a jump. + let previousTotal: Int? + let bestDay: Day? + let activeDays: Int + /// Days of the period, up to today, that the ledger covers: the denominator of `activeDays`. + let countedDays: Int + /// Longest run of consecutive active days inside the period. + let bestStreak: Int + /// Individuals graduated during the period, newest first. + let graduated: [DexEntry] + /// The previous period holds at least one day the ledger covers. + let canGoBack: Bool + + var delta: Double? { + guard let previousTotal, previousTotal > 0 else { return nil } + return Double(total - previousTotal) / Double(previousTotal) + } + + static func make(ledger: UsageLedger, dex: [DexEntry], period: RecapPeriod, now: Date, + calendar: Calendar) -> UsageRecap { + let formatter = LocalUsageReader.localDayFormatter(timeZone: calendar.timeZone) + let today = calendar.startOfDay(for: now) + + // `date(byAdding:)` rather than +86400: a DST day is 23 or 25 hours long. + func days(_ start: Date, _ end: Date) -> [Date] { + var result: [Date] = [] + var cursor = calendar.startOfDay(for: start) + while cursor < end { + result.append(cursor) + guard let next = calendar.date(byAdding: .day, value: 1, to: cursor) else { break } + cursor = next + } + return result + } + func known(_ key: String) -> Bool { ledger.tokens(on: key) > 0 || ledger.covers(key) } + + let periodDays = days(period.start, period.end) + let elapsed = periodDays.filter { $0 <= today }.map { date in + let key = formatter.string(from: date) + return Day(key: key, tokens: ledger.tokens(on: key)) + } + var bestStreak = 0 + var run = 0 + for day in elapsed { + run = day.tokens > 0 ? run + 1 : 0 + bestStreak = max(bestStreak, run) + } + + let buckets: [Bucket] + switch period.scope { + case .week, .month: + buckets = periodDays.map { date in + let key = formatter.string(from: date) + return Bucket(key: key, tokens: ledger.tokens(on: key), isCurrent: date == today, + hasData: date <= today && known(key)) + } + case .year: + buckets = (0..<12).map { index in + let start = calendar.date(byAdding: .month, value: index, to: period.start) ?? period.start + let end = calendar.date(byAdding: .month, value: 1, to: start) ?? period.end + let keys = days(start, end).filter { $0 <= today }.map { formatter.string(from: $0) } + return Bucket(key: formatter.string(from: start), + tokens: keys.reduce(0) { $0 + ledger.tokens(on: $1) }, + isCurrent: start <= today && today < end, + hasData: keys.contains(where: known)) + } + } + + let isInProgress = period.start <= today && today < period.end + let previous = period.shifted(by: -1, calendar: calendar) + let previousDays = days(previous.start, previous.end).map { formatter.string(from: $0) } + // A running period is compared over the same number of days, or the 18th of the month + // would read as a 40% drop against a whole month. + let compared = isInProgress ? Array(previousDays.prefix(elapsed.count)) : previousDays + let previousTotal = compared.first.map(ledger.covers) == true + ? compared.reduce(0) { $0 + ledger.tokens(on: $1) } + : nil + + let graduated = dex + .filter { entry in + !entry.isReleased && entry.caughtAt.map { $0 >= period.start && $0 < period.end } == true + } + .sorted { ($0.caughtAt ?? .distantPast) > ($1.caughtAt ?? .distantPast) } + + return UsageRecap( + period: period, + buckets: buckets, + total: elapsed.reduce(0) { $0 + $1.tokens }, + isInProgress: isInProgress, + previousTotal: previousTotal, + bestDay: elapsed.filter { $0.tokens > 0 }.max { $0.tokens < $1.tokens }, + activeDays: elapsed.filter { $0.tokens > 0 }.count, + countedDays: elapsed.filter { known($0.key) }.count, + bestStreak: bestStreak, + graduated: graduated, + canGoBack: previousDays.last.map(ledger.covers) ?? false) + } +} diff --git a/Sources/PokeTokenBar/Core/UsageStore.swift b/Sources/PokeTokenBar/Core/UsageStore.swift index 3dbf6c705..fc9d24d8b 100644 --- a/Sources/PokeTokenBar/Core/UsageStore.swift +++ b/Sources/PokeTokenBar/Core/UsageStore.swift @@ -428,6 +428,24 @@ final class UsageStore { return byDay.values.sorted { $0.date < $1.date } } + /// Day-by-day history behind the usage recap. Providers only report the current month, so the + /// numbers are copied into a rolling ledger at every refresh — see `UsageLedger`. + private(set) var dailyLedger = UsageLedger() + + private func recordDailyLedger() { + var ledger = dailyLedger + ledger.merge(monthDailyTotals) + // This year and the one before it: the year view compares against last year, and nothing + // reaches further back. + let calendar = RecapPeriod.calendar() + let lastYear = RecapPeriod(scope: .year, containing: Date(), calendar: calendar) + .shifted(by: -1, calendar: calendar) + ledger.prune(before: LocalUsageReader.localDayFormatter().string(from: lastYear.start)) + guard ledger != dailyLedger else { return } + dailyLedger = ledger + ledger.save(to: defaults) + } + /// Claude 의 활성 5h 블록 — 5h forecast·"현재 블록" 행은 Claude 공식 한도와 짝이므로 /// providerID 로 명시 조회한다 (전 프로바이더가 블록을 갖게 된 후 first-with-block 은 오매칭). private var claudeActiveBlock: BlockUsage? { @@ -703,6 +721,7 @@ final class UsageStore { additionalClaudeConfigDirs = d.string(forKey: ClaudeAccountRoots.defaultsKey) ?? "" claudeTrackedAccountMode = ClaudeTrackedAccountMode(storedValue: d.string(forKey: ClaudeTrackedAccountMode.defaultsKey)) armedCandyWindows = Set(d.stringArray(forKey: Self.armedCandyWindowsKey) ?? []) + dailyLedger = UsageLedger.load(from: d) if let credential = sessionKeys.credential() { sessionKeyConfigured = true @@ -926,6 +945,9 @@ final class UsageStore { } } } + // 일별 원장은 여기서 갱신한다 — monthDaily 는 phase 2 에서만 채워지므로, phase 1 직후에 + // 기록하면 설치 후 첫 갱신에서 사용량 요약이 통째로 빈다. + recordDailyLedger() // ── 한도 조회 (Keychain 프롬프트로 블로킹될 수 있어 마지막) // 세션 키 경로는 Keychain 을 안 읽으므로 이 토글과 무관하게 조회한다 — 토글을 켠 이유(팝업)가 diff --git a/Sources/PokeTokenBar/UI/PopoverView.swift b/Sources/PokeTokenBar/UI/PopoverView.swift index 8fe36d7a2..1a9369282 100644 --- a/Sources/PokeTokenBar/UI/PopoverView.swift +++ b/Sources/PokeTokenBar/UI/PopoverView.swift @@ -22,6 +22,8 @@ final class PopoverNavigation { var tab: PopoverTab = .home /// 일반적인 컬렉션 재진입에는 마지막 세그먼트를 유지하되, 대표 포켓몬 선택 진입점은 도감으로 강제한다. var showingCollectionLog = false + /// The usage recap takes over the popover like Settings does; closing the popover drops it. + var showingRecap = false /// 프로바이더 탭 선택 — reset() 대상이 아님(팝오버를 다시 열어도 보던 서비스 유지). var providerID: String? /// Claude account tab in the limits section. Kept across openings, like `providerID`. @@ -33,6 +35,7 @@ final class PopoverNavigation { func reset() { showSettings = false expandAdvancedOnOpen = false + showingRecap = false tab = .home } @@ -78,6 +81,8 @@ struct PopoverView: View { .environment(store) .environment(companion) .environment(updater) + } else if nav.showingRecap { + RecapScreen(store: store, companion: companion) { nav.showingRecap = false } } else { mainContent } @@ -176,6 +181,12 @@ struct PopoverView: View { periodLabel(l.thisWeek, tokens: store.weekTotalTokens, cost: store.showsCost ? store.weekUsageCost : nil) periodLabel(l.thisMonth, tokens: store.monthTotalTokens, cost: store.showsCost ? store.monthUsageCost : nil) Spacer() + Button { nav.showingRecap = true } label: { + Image(systemName: "chart.bar.xaxis") + } + .buttonStyle(.borderless) + .help(l.recapOpen) + .accessibilityLabel(l.recapOpen) } .padding(.top, 2) } diff --git a/Sources/PokeTokenBar/UI/UsageRecapView.swift b/Sources/PokeTokenBar/UI/UsageRecapView.swift new file mode 100644 index 000000000..fcb0e3b59 --- /dev/null +++ b/Sources/PokeTokenBar/UI/UsageRecapView.swift @@ -0,0 +1,410 @@ +import SwiftUI + +/// Everything the recap prints, read once. +@MainActor +struct RecapContent { + let language: AppLanguage + let recap: UsageRecap + let companionSpeciesID: Int? + let companionShiny: Bool + let companionUnownForm: UnownForm? + let graduates: [(entry: DexEntry, name: String)] + private let calendar: Calendar + /// Built once per read: a `DateFormatter` is expensive and the card asks for a label per meter. + private let dayKeyFormatter: DateFormatter + private let dayNameFormatter: DateFormatter + private let monthNameFormatter: DateFormatter + + init(store: UsageStore, companion: CompanionStore, scope: RecapScope, offset: Int, + now: Date = Date(), calendar: Calendar = RecapPeriod.calendar()) { + language = companion.language + self.calendar = calendar + let period = RecapPeriod(scope: scope, containing: now, calendar: calendar) + .shifted(by: offset, calendar: calendar) + recap = UsageRecap.make(ledger: store.dailyLedger, dex: companion.state.dex, period: period, + now: now, calendar: calendar) + // The Pokémon being raised right now, not the pinned representative: the recap is about + // usage that just happened, so it shows who lived it. + companionSpeciesID = companion.currentSpeciesID + companionShiny = companion.currentIsShiny + companionUnownForm = companion.currentUnownForm + graduates = recap.graduated.prefix(4).map { entry in + let name = companion.dexStoredChainNames(entry)?[entry.finalID] ?? "#\(entry.finalID)" + return (entry, UnownForm.displayName(name, speciesID: entry.finalID, form: entry.unownForm)) + } + dayKeyFormatter = LocalUsageReader.localDayFormatter(timeZone: calendar.timeZone) + dayNameFormatter = Self.formatter(language, calendar, template: "EEEE d MMM") + monthNameFormatter = Self.formatter(language, calendar, template: "MMMM y") + } + + private static func formatter(_ language: AppLanguage, _ calendar: Calendar, template: String) -> DateFormatter { + let formatter = DateFormatter() + formatter.locale = language.displayLocale + formatter.calendar = calendar + formatter.timeZone = calendar.timeZone + formatter.setLocalizedDateFormatFromTemplate(template) + return formatter + } + + var l: L { L(language) } + var scope: RecapScope { recap.period.scope } + + /// "14–20 Sept 2026", "September 2026" or "2026", from the display locale. It titles the card. + func periodLabel() -> String { + switch scope { + case .week: + let formatter = DateIntervalFormatter() + formatter.locale = language.displayLocale + formatter.calendar = calendar + formatter.timeZone = calendar.timeZone + formatter.dateTemplate = "dMMMy" + let lastDay = calendar.date(byAdding: .day, value: -1, to: recap.period.end) ?? recap.period.start + return formatter.string(from: recap.period.start, to: lastDay) + case .month: + return monthNameFormatter.string(from: recap.period.start) + case .year: + return String(calendar.component(.year, from: recap.period.start)) + } + } + + /// One short label per meter, from the display locale so there is nothing of ours to translate. + /// Weekdays take two letters (one collides: mardi/mercredi, Tuesday/Thursday), a month labels + /// only its weekly marks since 31 numbers do not fit, and a year uses month initials. + func bucketLabels() -> [String] { + let symbols = DateFormatter() + symbols.locale = language.displayLocale + let weekdays = symbols.shortStandaloneWeekdaySymbols ?? [] + let months = symbols.veryShortStandaloneMonthSymbols ?? [] + return recap.buckets.map { bucket in + guard let date = dayKeyFormatter.date(from: bucket.key) else { return "" } + switch scope { + case .week: + guard !weekdays.isEmpty else { return "" } + let weekday = calendar.component(.weekday, from: date) // 1-based + return String(weekdays[(weekday - 1) % weekdays.count].prefix(2)).uppercased() + case .month: + let day = calendar.component(.day, from: date) + return [1, 8, 15, 22, 29].contains(day) ? "\(day)" : "" + case .year: + guard !months.isEmpty else { return "" } + let month = calendar.component(.month, from: date) // 1-based + return months[(month - 1) % months.count].uppercased() + } + } + } + + func dayLabel(_ key: String) -> String { + guard let date = dayKeyFormatter.date(from: key) else { return key } + return dayNameFormatter.string(from: date) + } + + func accessibilityLabel(_ bucket: UsageRecap.Bucket) -> String { + let name: String + if scope == .year, let date = dayKeyFormatter.date(from: bucket.key) { + name = monthNameFormatter.string(from: date) + } else { + name = dayLabel(bucket.key) + } + return "\(name), \(bucket.hasData ? TokenFormatter.grouped(bucket.tokens) : l.recapNoData)" + } + +} + +/// The recap panel — a Pokédex readout, not a second trainer card: red shell, dark LCD, +/// monospaced digits and segmented meters. Colors are all explicit so the card never shifts +/// with the system appearance. +@MainActor +struct RecapCard: View { + let content: RecapContent + + private static let size = CGSize(width: PopoverMetrics.contentWidth, height: 226) + private static let shellTop = Color(red: 0.84, green: 0.21, blue: 0.18) + private static let shellBottom = Color(red: 0.66, green: 0.12, blue: 0.11) + private static let screenInk = Color(red: 0.05, green: 0.07, blue: 0.08) + private static let lcd = Color(red: 0.51, green: 0.98, blue: 0.62) + private static let amber = Color(red: 1.0, green: 0.73, blue: 0.28) + /// Segments per meter. Eight reads as a level gauge; more would turn back into a plain bar. + private static let meterBlocks = 8 + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + header + screen + graduateStrip + } + .padding(12) + .frame(width: Self.size.width, height: Self.size.height, alignment: .topLeading) + .background( + ZStack { + LinearGradient(colors: [Self.shellTop, Self.shellBottom], + startPoint: .top, endPoint: .bottom) + // The hinge side of the case, so the panel reads as a device and not as a card. + HStack(spacing: 0) { + Color.black.opacity(0.14).frame(width: 5) + Spacer(minLength: 0) + } + } + .clipShape(RoundedRectangle(cornerRadius: 10)) + ) + .environment(\.colorScheme, .light) + .environment(\.locale, content.language.displayLocale) + } + + private var header: some View { + HStack(spacing: 6) { + lens + ForEach([Color(red: 0.95, green: 0.35, blue: 0.35), + Color(red: 0.98, green: 0.82, blue: 0.35), + Color(red: 0.45, green: 0.88, blue: 0.5)], id: \.self) { color in + Circle() + .fill(color) + .overlay(Circle().strokeBorder(.black.opacity(0.25), lineWidth: 0.5)) + .frame(width: 6, height: 6) + } + Spacer(minLength: 4) + Text(content.periodLabel().uppercased()) + .font(.system(size: 10, weight: .bold, design: .monospaced)) + .tracking(1.2) + .foregroundStyle(.white.opacity(0.95)) + .lineLimit(1).minimumScaleFactor(0.7) + } + .padding(.leading, 2) + } + + private var lens: some View { + Circle() + .fill(RadialGradient(colors: [Color(red: 0.72, green: 0.88, blue: 1.0), + Color(red: 0.15, green: 0.45, blue: 0.85)], + center: UnitPoint(x: 0.35, y: 0.3), startRadius: 0, endRadius: 11)) + .overlay(Circle().strokeBorder(.white.opacity(0.9), lineWidth: 1.5)) + .overlay( + Circle().fill(.white.opacity(0.75)).frame(width: 4, height: 4) + .offset(x: -3, y: -3) + ) + .frame(width: 16, height: 16) + } + + private var screen: some View { + let recap = content.recap + let l = content.l + return VStack(alignment: .leading, spacing: 7) { + HStack(alignment: .firstTextBaseline, spacing: 5) { + Text(TokenFormatter.compact(recap.total)) + .font(.system(size: 24, weight: .bold, design: .monospaced)) + .foregroundStyle(Self.lcd) + Text(l.recapTokensUnit.uppercased()) + .font(.system(size: 7, weight: .medium, design: .monospaced)) + .tracking(0.6) + .foregroundStyle(Self.lcd.opacity(0.5)) + .lineLimit(1) + Spacer(minLength: 4) + deltaChip + } + meters + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 3) { + readout(l.recapBestDay, recap.bestDay.map { TokenFormatter.compact($0.tokens) } ?? "--") + readout(l.recapBestStreak, l.recapDays(recap.bestStreak)) + } + VStack(alignment: .leading, spacing: 3) { + readout(l.recapActiveDays, "\(recap.activeDays)/\(recap.countedDays)") + readout(l.recapGraduates, "\(recap.graduated.count)") + } + } + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Self.screenInk) + .overlay( + RoundedRectangle(cornerRadius: 6) + .fill(RadialGradient(colors: [Self.lcd.opacity(0.07), .clear], + center: UnitPoint(x: 0.15, y: 0.1), + startRadius: 0, endRadius: 180)) + ) + ) + .overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(.black.opacity(0.35), lineWidth: 2)) + .padding(.horizontal, 2) + } + + @ViewBuilder + private var deltaChip: some View { + // No chip unless the ledger covers the whole compared stretch: a fresh install must not + // claim a jump over days it never saw. + if let delta = content.recap.delta { + let up = delta >= 0 + let color = up ? Self.lcd : Self.amber + Text("\(up ? "▲" : "▼")\(TokenFormatter.percent(abs(delta) * 100))") + .font(.system(size: 9, weight: .bold, design: .monospaced)) + .foregroundStyle(color) + .padding(.horizontal, 5).padding(.vertical, 2) + .background(RoundedRectangle(cornerRadius: 2).strokeBorder(color.opacity(0.6), lineWidth: 1)) + } + } + + private var meters: some View { + let recap = content.recap + let peak = max(1, recap.buckets.map(\.tokens).max() ?? 1) + let labels = content.bucketLabels() + let spacing: CGFloat = switch content.scope { + case .week: 6 + case .month: 1.5 + case .year: 4 + } + return HStack(alignment: .bottom, spacing: spacing) { + ForEach(Array(recap.buckets.enumerated()), id: \.element.key) { index, bucket in + VStack(spacing: 3) { + meter(bucket, peak: peak) + // Zero-width frame: a month's "15" is wider than its column and must not + // widen it, or five meters would come out fatter than the rest. + Text(labels.indices.contains(index) ? labels[index] : "") + .font(.system(size: content.scope == .week ? 8 : 7, + weight: bucket.isCurrent ? .bold : .regular, design: .monospaced)) + .foregroundStyle(bucket.isCurrent ? Self.lcd : Self.lcd.opacity(0.45)) + .fixedSize() + .frame(width: 0, height: 10) + } + .frame(maxWidth: .infinity) + // The meter is plain rectangles, so VoiceOver would otherwise read the label alone. + .accessibilityElement(children: .ignore) + .accessibilityLabel(content.accessibilityLabel(bucket)) + } + } + } + + private func meter(_ bucket: UsageRecap.Bucket, peak: Int) -> some View { + // A bucket with usage always lights one segment, so "quiet" and "nothing" stay distinct; + // an unknown bucket (future, or before the ledger started) dims even its unlit grid. + let lit = bucket.tokens > 0 + ? max(1, Int((Double(bucket.tokens) / Double(peak) * Double(Self.meterBlocks)).rounded())) + : 0 + return VStack(spacing: 2) { + ForEach(0..= Self.meterBlocks - lit + ? (bucket.isCurrent ? Self.lcd : Self.lcd.opacity(0.55)) + : Self.lcd.opacity(bucket.hasData ? 0.09 : 0.035)) + .frame(height: 4) + } + } + } + + private func readout(_ label: String, _ value: String) -> some View { + HStack(spacing: 4) { + Text(label.uppercased()) + .font(.system(size: 7, weight: .medium, design: .monospaced)) + .foregroundStyle(Self.lcd.opacity(0.5)) + .lineLimit(1).minimumScaleFactor(0.7) + Spacer(minLength: 2) + Text(value) + .font(.system(size: 9.5, weight: .bold, design: .monospaced)) + .foregroundStyle(Self.lcd) + .lineLimit(1).minimumScaleFactor(0.7) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + @ViewBuilder + private var graduateStrip: some View { + HStack(spacing: 5) { + SpriteView(speciesID: content.companionSpeciesID, size: 18, animated: false, + shiny: content.companionShiny, unownForm: content.companionUnownForm) + Rectangle().fill(.white.opacity(0.35)).frame(width: 1, height: 14) + if content.graduates.isEmpty { + Text(content.l.recapNoGraduates) + .font(.system(size: 8, design: .monospaced)) + .foregroundStyle(.white.opacity(0.8)) + .lineLimit(2).fixedSize(horizontal: false, vertical: true) + } else { + ForEach(content.graduates, id: \.entry.id) { graduate in + HStack(spacing: 2) { + SpriteView(speciesID: graduate.entry.finalID, size: 18, animated: false, + shiny: graduate.entry.isShiny, + unownForm: graduate.entry.unownForm) + // A badge on the sprite rather than after the name: four chips share + // the row, and a trailing sparkle cut "Charizard" to "Chariza…". + .overlay(alignment: .topTrailing) { + if graduate.entry.isShiny { + Text("✨").font(.system(size: 7)).offset(x: 3, y: -2) + } + } + Text(graduate.name) + .font(.system(size: 8, weight: .semibold, design: .monospaced)) + .foregroundStyle(.white) + .lineLimit(1).minimumScaleFactor(0.7) + } + .padding(.horizontal, 4).padding(.vertical, 2) + .background(RoundedRectangle(cornerRadius: 3).fill(.black.opacity(0.22))) + } + } + Spacer(minLength: 0) + } + .padding(.leading, 2) + } +} + +@MainActor +struct RecapScreen: View { + let store: UsageStore + let companion: CompanionStore + let onBack: () -> Void + + @State private var scope: RecapScope = .week + /// 0 is the running period, -1 the one before it. Never positive: the future has no usage. + @State private var offset = 0 + + var body: some View { + let l = companion.l + let content = RecapContent(store: store, companion: companion, scope: scope, offset: offset) + return VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + Button { onBack() } label: { + Label(l.back, systemImage: "chevron.left").labelStyle(.titleAndIcon) + } + .buttonStyle(.borderless) + .keyboardShortcut(.cancelAction) // Esc, like Settings: both take over the popover + Spacer(minLength: 4) + Picker(l.recapOpen, selection: $scope) { + ForEach(RecapScope.allCases, id: \.self) { Text(l.recapScopeName($0)).tag($0) } + } + .pickerStyle(.segmented) + .labelsHidden() + .fixedSize() + .controlSize(.small) + .onChange(of: scope) { offset = 0 } + } + // The period itself is printed on the card, so this row only moves through time. + HStack { + Button { offset -= 1 } label: { + Label(l.recapPrevious, systemImage: "arrowtriangle.left.fill").labelStyle(.titleAndIcon) + } + .disabled(!content.recap.canGoBack) + Spacer() + Button { offset += 1 } label: { + HStack(spacing: 4) { + Text(l.recapNext) + Image(systemName: "arrowtriangle.right.fill") + } + } + .accessibilityLabel(l.recapNext) + .disabled(offset >= 0) + } + .buttonStyle(.borderless) + .font(.caption) + RecapCard(content: content) + VStack(alignment: .leading, spacing: 2) { + if let best = content.recap.bestDay { + Text(l.recapBestDayLine(content.dayLabel(best.key), TokenFormatter.grouped(best.tokens))) + } + if content.recap.isInProgress, content.recap.delta != nil { + Text(l.recapCompareSoFar(scope)) + } + } + .font(.caption).foregroundStyle(.secondary) + Spacer(minLength: 0) + } + .padding(PopoverMetrics.padding) + .task { await companion.backfillMissingDexNames() } + } +} diff --git a/Tests/PokeTokenBarTests/UsageRecapTests.swift b/Tests/PokeTokenBarTests/UsageRecapTests.swift new file mode 100644 index 000000000..677ea39a9 --- /dev/null +++ b/Tests/PokeTokenBarTests/UsageRecapTests.swift @@ -0,0 +1,386 @@ +import AppKit +import SwiftUI +import XCTest +@testable import PokeTokenBar + +// MARK: Fixtures — a fixed UTC calendar whose weeks start on Monday, so nothing depends on the +// machine running the suite. + +private let utc = TimeZone(identifier: "UTC")! + +private func calendar(firstWeekday: Int = 2) -> Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = utc + calendar.firstWeekday = firstWeekday + return calendar +} + +private func date(_ year: Int, _ month: Int, _ day: Int, hour: Int = 12) -> Date { + calendar().date(from: DateComponents(year: year, month: month, day: day, hour: hour))! +} + +/// Friday 18 September 2026, noon: the week runs Monday 14 to Sunday 20. +private let now = date(2026, 9, 18) + +private func key(_ year: Int, _ month: Int, _ day: Int) -> String { + LocalUsageReader.localDayFormatter(timeZone: utc).string(from: date(year, month, day)) +} + +private func usage(_ date: String, _ tokens: Int) -> DailyUsage { + DailyUsage(date: date, inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, + cacheReadTokens: 0, totalTokens: tokens, totalCost: 0, costCoverage: .source) +} + +/// A ledger that has been recording since `since`, with usage on the given days. +private func ledger(since: String, _ days: [(String, Int)]) -> UsageLedger { + var ledger = UsageLedger() + ledger.merge([usage(since, 0)] + days.map { usage($0.0, $0.1) }) + return ledger +} + +private func recap(_ ledger: UsageLedger, _ scope: RecapScope, offset: Int = 0, dex: [DexEntry] = [], + calendar: Calendar = calendar()) -> UsageRecap { + let period = RecapPeriod(scope: scope, containing: now, calendar: calendar).shifted(by: offset, calendar: calendar) + return UsageRecap.make(ledger: ledger, dex: dex, period: period, now: now, calendar: calendar) +} + +private func graduate(_ id: String, on day: Date, shiny: Bool = false, released: Bool = false) -> DexEntry { + DexEntry(id: id, baseID: 10, finalID: 10, chainOrder: [10], rarity: .common, caughtAt: day, + isShiny: shiny, names: [10: ["en": "S10"]], releasedAt: released ? day : nil) +} + +// MARK: Ledger + +final class UsageLedgerTests: XCTestCase { + func testADayNeverGoesDown() { + var l = UsageLedger() + l.merge([usage("2026-09-10", 500), usage("2026-09-11", 300)]) + // A rotated log makes the provider report less for a day that really happened. + l.merge([usage("2026-09-10", 120), usage("2026-09-11", 900)]) + + XCTAssertEqual(l.tokens(on: "2026-09-10"), 500) + XCTAssertEqual(l.tokens(on: "2026-09-11"), 900) + } + + func testTheSeriesVouchesForItsEmptyDaysToo() { + var l = UsageLedger() + l.merge([usage("2026-09-01", 0), usage("2026-09-02", 40)]) + + XCTAssertEqual(l.tokensByDay, ["2026-09-02": 40], "an empty day adds no row") + XCTAssertEqual(l.coveredSince, "2026-09-01", "but it was scanned, so its zero is real") + XCTAssertTrue(l.covers("2026-09-01")) + XCTAssertFalse(l.covers("2026-08-31")) + + l.merge([usage("2026-09-05", 10)]) + XCTAssertEqual(l.coveredSince, "2026-09-01", "a later series does not shrink the coverage") + } + + /// Pruned days are unknown again: without moving the coverage up, a recap would read them as + /// a stretch with no usage. + func testPruningForgetsTheCoverageItDrops() { + var l = ledger(since: "2025-06-01", [("2025-06-10", 100), ("2026-02-01", 200)]) + l.prune(before: "2026-01-01") + + XCTAssertEqual(l.tokensByDay, ["2026-02-01": 200]) + XCTAssertEqual(l.coveredSince, "2026-01-01") + } + + func testJunkIsDroppedOnLoad() { + let defaults = UserDefaults(suiteName: "ledger-junk-\(UUID().uuidString)")! + defaults.set(["2026-09-10": 400, "nope": 10, "0000-00-00": 10, "2026-09-11": 0], + forKey: UsageLedger.defaultsKey) + defaults.set("0000-00-00", forKey: UsageLedger.coverageKey) + + let loaded = UsageLedger.load(from: defaults) + + XCTAssertEqual(loaded.tokensByDay, ["2026-09-10": 400]) + XCTAssertNil(loaded.coveredSince, "a key that is not a day must not backdate the coverage") + } + + func testSaveAndLoadRoundTrip() { + let defaults = UserDefaults(suiteName: "ledger-round-\(UUID().uuidString)")! + let l = ledger(since: "2026-09-01", [("2026-09-10", 700)]) + l.save(to: defaults) + + XCTAssertEqual(UsageLedger.load(from: defaults), l) + } +} + +// MARK: Refresh stubs (so the ledger test touches neither Keychain nor the network) + +private enum LedgerStubError: Error { case unavailable } + +private struct NoClaudeLimits: ClaudeLimitsProviding { + func fetch(allowKeychainPrompt: Bool) async throws -> LimitStatus { throw LedgerStubError.unavailable } +} +private struct NoCodexLimits: CodexLimitsProviding { + func fetch() async throws -> CodexRateLimitStatus? { nil } +} +private struct NoAntigravityLimits: AntigravityLimitsProviding { + func fetch(allowKeychainPrompt: Bool) async throws -> AntigravityRateLimitStatus { + throw LedgerStubError.unavailable + } +} +private struct NoStatus: ProviderStatusProviding { + func fetch() async -> [String: ProviderStatus] { [:] } +} + +/// Reports today in `fetchDaily` and the month series only in `fetchEnrichment` — like the real +/// providers, where the series lands one phase later than the snapshot. +private final class LedgerProvider: UsageProvider, @unchecked Sendable { + let id = "claude_code" + let displayName = "Claude Code" + let reportsCost = true + private let daily: DailyUsage + private let series: [DailyUsage] + init(daily: DailyUsage, series: [DailyUsage]) { self.daily = daily; self.series = series } + func fetchDaily() async throws -> DailyUsage? { daily } + func fetchEnrichment() async -> ProviderEnrichment { + var enrichment = ProviderEnrichment() + enrichment.periodsOK = true + enrichment.monthDaily = series + return enrichment + } +} + +@MainActor +final class UsageLedgerRecordingTests: XCTestCase { + /// Regression: the ledger used to be written right after phase 1, where `monthDaily` is still + /// nil, so the first refresh after an install recorded nothing and the recap came out empty. + func testTheFirstRefreshAlreadyFillsTheLedger() async { + let formatter = LocalUsageReader.localDayFormatter() + let today = formatter.string(from: Date()) + let yesterday = formatter.string(from: Date().addingTimeInterval(-86_400)) + let defaults = UserDefaults(suiteName: "ledger-refresh-\(UUID().uuidString)")! + let store = UsageStore( + providers: [LedgerProvider(daily: usage(today, 300), + series: [usage(yesterday, 900), usage(today, 300)])], + claudeLimitsProvider: NoClaudeLimits(), codexLimitsProvider: NoCodexLimits(), + antigravityLimitsProvider: NoAntigravityLimits(), statusProvider: NoStatus(), + autoRefresh: false, defaults: defaults) + + await store.refresh(scheduleEmptyRetry: false) + + XCTAssertEqual(store.dailyLedger.tokens(on: today), 300) + XCTAssertEqual(store.dailyLedger.tokens(on: yesterday), 900) + XCTAssertEqual(store.dailyLedger.coveredSince, yesterday) + XCTAssertEqual(UsageLedger.load(from: defaults), store.dailyLedger, "and it survives a relaunch") + } +} + +extension UsageLedgerRecordingTests { + /// The year view compares against last year, so the refresh must keep all of it — and nothing + /// older, or the defaults would grow forever. + func testARefreshKeepsLastYearAndDropsTheYearBefore() async { + let calendar = RecapPeriod.calendar() + let formatter = LocalUsageReader.localDayFormatter() + let thisYear = RecapPeriod(scope: .year, containing: Date(), calendar: calendar) + let lastYearStart = formatter.string(from: thisYear.shifted(by: -1, calendar: calendar).start) + let lastDayTwoYearsAgo = formatter.string(from: thisYear.shifted(by: -1, calendar: calendar).start + .addingTimeInterval(-12 * 3600)) + let today = formatter.string(from: Date()) + let defaults = UserDefaults(suiteName: "ledger-retention-\(UUID().uuidString)")! + var seeded = UsageLedger() + seeded.merge([usage(lastDayTwoYearsAgo, 70), usage(lastYearStart, 80)]) + seeded.save(to: defaults) + let store = UsageStore( + providers: [LedgerProvider(daily: usage(today, 5), series: [usage(today, 5)])], + claudeLimitsProvider: NoClaudeLimits(), codexLimitsProvider: NoCodexLimits(), + antigravityLimitsProvider: NoAntigravityLimits(), statusProvider: NoStatus(), + autoRefresh: false, defaults: defaults) + + await store.refresh(scheduleEmptyRetry: false) + + XCTAssertEqual(store.dailyLedger.tokens(on: lastYearStart), 80, "1 January of last year stays") + XCTAssertEqual(store.dailyLedger.tokens(on: lastDayTwoYearsAgo), 0, "31 December the year before goes") + XCTAssertEqual(store.dailyLedger.coveredSince, lastYearStart) + } +} + +// MARK: Periods + +final class RecapPeriodTests: XCTestCase { + func testAWeekStartsOnTheRegionsFirstWeekday() { + let monday = RecapPeriod(scope: .week, containing: now, calendar: calendar(firstWeekday: 2)) + XCTAssertEqual(monday.start, date(2026, 9, 14, hour: 0)) + XCTAssertEqual(monday.end, date(2026, 9, 21, hour: 0)) + + let sunday = RecapPeriod(scope: .week, containing: now, calendar: calendar(firstWeekday: 1)) + XCTAssertEqual(sunday.start, date(2026, 9, 13, hour: 0)) + } + + func testSteppingBackLandsOnWholePeriods() { + let cal = calendar() + let march = RecapPeriod(scope: .month, containing: date(2026, 3, 31), calendar: cal) + let february = march.shifted(by: -1, calendar: cal) + XCTAssertEqual(february.start, date(2026, 2, 1, hour: 0), "not 31 February rolled into March") + XCTAssertEqual(february.end, date(2026, 3, 1, hour: 0)) + + let lastYear = RecapPeriod(scope: .year, containing: now, calendar: cal).shifted(by: -1, calendar: cal) + XCTAssertEqual(lastYear.start, date(2025, 1, 1, hour: 0)) + XCTAssertEqual(lastYear.end, date(2026, 1, 1, hour: 0)) + } +} + +// MARK: Recap + +final class UsageRecapTests: XCTestCase { + func testEachScopeDrawsItsOwnMeters() { + let l = ledger(since: key(2026, 1, 1), [(key(2026, 9, 2), 10), (key(2026, 9, 17), 20), + (key(2026, 8, 30), 5)]) + + let week = recap(l, .week) + XCTAssertEqual(week.buckets.map(\.key).first, key(2026, 9, 14)) + XCTAssertEqual(week.buckets.count, 7) + XCTAssertEqual(week.buckets.filter(\.isCurrent).map(\.key), [key(2026, 9, 18)]) + + XCTAssertEqual(recap(l, .month).buckets.count, 30) + XCTAssertEqual(recap(l, .month, offset: -7).buckets.count, 28, "February 2026") + + let year = recap(l, .year) + XCTAssertEqual(year.buckets.count, 12) + XCTAssertEqual(year.buckets[8].tokens, 30, "September sums its days") + XCTAssertEqual(year.buckets[7].tokens, 5) + XCTAssertTrue(year.buckets[8].isCurrent) + XCTAssertEqual(year.total, 35) + } + + func testFutureAndUnrecordedDaysAreNotZeros() { + let week = recap(ledger(since: key(2026, 9, 16), [(key(2026, 9, 17), 50)]), .week) + + XCTAssertEqual(week.buckets.map(\.hasData), [false, false, true, true, true, false, false], + "Mon-Tue before the ledger, Sat-Sun still ahead") + XCTAssertEqual(week.activeDays, 1) + XCTAssertEqual(week.countedDays, 3) + + let year = recap(ledger(since: key(2026, 9, 1), []), .year) + XCTAssertEqual(year.buckets.map(\.hasData), Array(repeating: false, count: 8) + [true] + Array(repeating: false, count: 3)) + } + + /// The 18th of the month against a whole previous month would read as a drop; a running period + /// is compared over the same number of days. + func testARunningPeriodIsComparedOverTheSameDays() { + let l = ledger(since: key(2026, 8, 1), [(key(2026, 9, 10), 100), (key(2026, 8, 18), 40), + (key(2026, 8, 19), 1_000)]) + let month = recap(l, .month) + + XCTAssertTrue(month.isInProgress) + XCTAssertEqual(month.previousTotal, 40, "the 19th of August is past the 18th") + XCTAssertEqual(try XCTUnwrap(month.delta), 1.5, accuracy: 0.0001) + } + + func testAFinishedPeriodIsComparedWhole() { + let l = ledger(since: key(2026, 7, 1), [(key(2026, 8, 5), 300), (key(2026, 7, 31), 100)]) + let august = recap(l, .month, offset: -1) + + XCTAssertFalse(august.isInProgress) + XCTAssertEqual(august.previousTotal, 100) + XCTAssertEqual(try XCTUnwrap(august.delta), 2.0, accuracy: 0.0001) + } + + /// Regression: coverage used to be judged on the *newest* day of the previous window, so a + /// ledger starting inside it counted the days before as zero and inflated the delta. + func testAHalfCoveredPreviousPeriodIsNotAComparison() { + let week = recap(ledger(since: key(2026, 9, 9), [(key(2026, 9, 15), 150), (key(2026, 9, 10), 50)]), .week) + + XCTAssertNil(week.previousTotal, "the ledger starts on the Wednesday of the previous week") + XCTAssertNil(week.delta) + } + + func testTheBestStreakStaysInsideThePeriod() { + // Friday 11 to Wednesday 16 without a gap, then Friday 18 alone. + let days = [11, 12, 13, 14, 15, 16, 18].map { (key(2026, 9, $0), 5) } + let week = recap(ledger(since: key(2026, 9, 1), days), .week) + + XCTAssertEqual(week.bestStreak, 3, "Monday 14 to Wednesday 16; the days before the week do not count") + XCTAssertEqual(recap(ledger(since: key(2026, 9, 1), days), .month).bestStreak, 6) + } + + func testGraduationsInThePeriodOnly() { + let dex = [graduate("this-week", on: date(2026, 9, 17)), + graduate("monday", on: date(2026, 9, 14, hour: 0), shiny: true), + graduate("let-go", on: date(2026, 9, 16), released: true), + graduate("last-week", on: date(2026, 9, 13, hour: 23))] + let l = ledger(since: key(2026, 9, 1), []) + + XCTAssertEqual(recap(l, .week, dex: dex).graduated.map(\.id), ["this-week", "monday"], + "newest first, released out, Sunday 13 is last week") + XCTAssertEqual(recap(l, .month, dex: dex).graduated.count, 3) + } + + func testGoingBackStopsWhereTheLedgerStarts() { + let l = ledger(since: key(2026, 9, 1), [(key(2026, 9, 2), 10)]) + + XCTAssertFalse(recap(l, .month).canGoBack, "August was never recorded") + XCTAssertTrue(recap(l, .week, offset: -1).canGoBack, "the week of 31 August holds 1 September") + XCTAssertFalse(recap(l, .week, offset: -2).canGoBack) + XCTAssertFalse(recap(l, .year).canGoBack) + } + + func testAnEmptyLedgerStillDrawsThePeriod() { + let week = recap(UsageLedger(), .week) + + XCTAssertEqual(week.buckets.count, 7) + XCTAssertFalse(week.buckets.contains(where: \.hasData)) + XCTAssertEqual(week.total, 0) + XCTAssertNil(week.bestDay) + XCTAssertNil(week.previousTotal) + XCTAssertFalse(week.canGoBack) + } +} + +// MARK: Rendering + +@MainActor +final class UsageRecapRenderingTests: XCTestCase { + private let url = FileManager.default.temporaryDirectory + .appendingPathComponent("recap-\(UUID().uuidString).json") + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: url) + } + + func testTheCardRendersInEveryLanguageAndScope() throws { + let dex = [graduate("a", on: date(2026, 9, 17)), graduate("b", on: date(2026, 9, 15), shiny: true)] + let dexJSON = String(decoding: try JSONEncoder().encode(dex), as: UTF8.self) + try Data(#"{"installBaselineSet":true,"usedSinceInstall":1000,"lastDate":"d","dex":\#(dexJSON)}"#.utf8) + .write(to: url) + let defaults = UserDefaults(suiteName: "recap-render-\(UUID().uuidString)")! + ledger(since: key(2026, 8, 1), [(key(2026, 9, 17), 5_000), (key(2026, 8, 20), 900)]).save(to: defaults) + let usageStore = UsageStore(providers: [], autoRefresh: false, defaults: defaults) + + for language in AppLanguage.allCases { + let companion = CompanionStore(fileURL: url, defaults: defaults) + companion.setLanguage(language) + for scope in RecapScope.allCases { + let content = RecapContent(store: usageStore, companion: companion, scope: scope, offset: 0, + now: now, calendar: calendar()) + XCTAssertFalse(content.periodLabel().isEmpty, "\(language) \(scope)") + XCTAssertFalse(content.l.recapCompareSoFar(scope).isEmpty) + XCTAssertEqual(content.bucketLabels().count, content.recap.buckets.count) + let renderer = ImageRenderer(content: RecapCard(content: content)) + renderer.scale = 1 + let image = try XCTUnwrap(renderer.cgImage, "\(language) \(scope)") + XCTAssertEqual(image.width, Int(PopoverMetrics.contentWidth)) + } + } + } + + func testTheGraduateStripCarriesTheUnownLetter() throws { + let unown = DexEntry(id: "unown-q", baseID: UnownForm.speciesID, finalID: UnownForm.speciesID, + chainOrder: [UnownForm.speciesID], rarity: .rare, caughtAt: date(2026, 9, 17), + names: [UnownForm.speciesID: ["en": "Unown"]], unownForm: .q) + let dexJSON = String(decoding: try JSONEncoder().encode([unown]), as: UTF8.self) + try Data(#"{"installBaselineSet":true,"usedSinceInstall":1000,"lastDate":"d","language":"en","dex":\#(dexJSON)}"#.utf8) + .write(to: url) + let defaults = UserDefaults(suiteName: "recap-unown-\(UUID().uuidString)")! + ledger(since: key(2026, 9, 1), [(key(2026, 9, 17), 5_000)]).save(to: defaults) + let content = RecapContent(store: UsageStore(providers: [], autoRefresh: false, defaults: defaults), + companion: CompanionStore(fileURL: url, defaults: defaults), + scope: .week, offset: 0, now: now, calendar: calendar()) + + let graduate = try XCTUnwrap(content.graduates.first) + XCTAssertEqual(graduate.entry.unownForm, .q, "the strip would otherwise draw the default A sprite") + XCTAssertEqual(graduate.name, "Unown [Q]") + } +} diff --git a/docs/reference/defect-log.md b/docs/reference/defect-log.md index 4d9c1e475..91726540f 100644 --- a/docs/reference/defect-log.md +++ b/docs/reference/defect-log.md @@ -139,6 +139,18 @@ read_when: (`testParseCursorUsageEventClampsHugeTokenCounts`). Kiro 타임스탬프의 `Int64(Double)` 도 `Int64.max` 를 넘는 유한값에서 트랩한다. 상한은 더하는 추출기마다 같이 건다. +- **기간 비교의 "데이터가 있는가"는 그 구간의 *가장 오래된* 날로 판정한다.** 사용량 요약이 지난주 + 대비를 낼지 말지를 지난 구간의 **첫 날(=가장 최근 날)** 로 판정했더니, 원장이 구간 중간부터 시작해도 + 비교가 성립했다 — 빠진 날들은 0 으로 합산돼 실측 delta 가 +200% 로 부풀었다(가드의 목적이던 "신규 + 설치가 없던 주 대비 급등을 주장하는" 바로 그 증상). 누락분을 0 으로 세는 합계 위에 커버리지 게이트를 + 올릴 땐, 게이트 기준점이 **합계의 모든 날을 덮는지** 확인한다(`testAHalfCoveredPreviousPeriodIsNotAComparison`). + 같은 맥락에서 손편집 가능한 `UserDefaults` 를 원장으로 쓰면 키 검증도 **길이가 아니라 파싱**으로 + 한다 — `"0000-00-00"` 처럼 날짜가 아니면서 모든 실제 날짜보다 작게 정렬되는 키는 커버리지를 위조한다. + 그리고 "0 인가, 모르는가"는 행의 유무로 추론하지 말고 **커버리지 시작일을 따로 저장**한다 + (`UsageLedger.coveredSince`) — 행은 사용량이 있는 날에만 생기므로, 첫 행 날짜로 커버리지를 추정하면 + 조용했던 기간과 기록 이전 기간이 구별되지 않는다. 오래된 행을 잘라낼 땐 커버리지도 함께 올린다 + (`testPruningForgetsTheCoverageItDrops`). + ## 외부 로그·사용량 소스 - **토큰 파일 파서는 외부 도구의 실제 직렬화 구조체(중첩 객체)를 반영해야 한다.** `jetski-standalone-oauth-token` @@ -422,6 +434,14 @@ read_when: **opt-in(`POKETOKENBAR_RUN_LARGE_PERF=1` / `scripts/perf-codex-large-rollout.sh`)이라 CI 는 돌리지 않는다.** 자동으로 막히지 않으므로 이 부류를 건드리면 직접 돌린다. (#184) +- **2단계 갱신에서 파생 상태는 그 값을 *채우는* 단계 뒤에 기록한다.** 사용량 요약의 일별 원장을 + `snapshots = newSnapshots`(phase 1) 직후에 저장했더니, `monthDaily` 는 `fetchEnrichment`(phase 2) + 에서만 채워져 **설치 후 첫 갱신이 통째로 빈 요약**을 만들었다. 두 번째 갱신부터는 phase 1 이 이전 + 스냅샷의 `prevMonthDaily` 를 이어받아 우연히 채워지므로, 단위 테스트도 손으로 앱을 열어보는 확인도 + 통과한다 — 틀리는 건 첫 갱신 한 번뿐이라 눈에 띄지 않는다(개발 앱 defaults 에 키가 안 생긴 걸 보고 + 발견). refresh 파이프라인에 새 파생 상태를 붙일 땐 그 입력이 phase 1/2 중 어디서 채워지는지 먼저 + 확인하고, 회귀 가드는 **첫 refresh 한 번**으로 검증한다(`testTheFirstRefreshAlreadyFillsTheLedger`). + ## 빌드·도구체인 - **새 SDK에서 통과해도 CI의 AppKit Sendable 선언을 가정하지 마라.** 동시 캐시 로드 테스트가 diff --git a/scripts/test-gate.sh b/scripts/test-gate.sh index 1e6d1dc69..3981586eb 100755 --- a/scripts/test-gate.sh +++ b/scripts/test-gate.sh @@ -35,6 +35,7 @@ LOGIC_CORE=( "Sources/PokeTokenBar/Core/LocalUsageCache.swift" "Sources/PokeTokenBar/Core/ModelPricing.swift" "Sources/PokeTokenBar/Core/CustomScanRoots.swift" + "Sources/PokeTokenBar/Core/UsageRecap.swift" ) echo "▶ swift test (--enable-code-coverage)" From b4ff5de931682aa20f690978b5aa03f7b8458013 Mon Sep 17 00:00:00 2001 From: Justin Jeong Date: Fri, 25 Sep 2026 11:19:21 +0900 Subject: [PATCH 05/15] fix: protect legendary companions from accidental egg discard (#314) (#335) Require an extra confirmation step before discarding a legendary or shiny companion when buying an egg in the token shop. - CompanionStore: Add isHighValueCompanion (currentIsShiny || rarity == .legendary) keeping evaluation in the store and preserving Ditto disguise secrecy. - ShopView: Generalize EggCard confirmation stage to preciousConfirm with appropriate legendary/shiny warning copy and discard buttons. - Localization: Add freshEggLegendaryWarning and freshEggDiscardValuable across all 7 supported languages. - Tests: Add PreciousCompanionGuardTests covering legendary, shiny, rare exclusion (avoiding reroll fatigue), disguised Ditto invariants, and 7-language copy. --- .../PokeTokenBar/Core/CompanionStore.swift | 8 ++ Sources/PokeTokenBar/Core/Localization.swift | 2 + Sources/PokeTokenBar/UI/ShopView.swift | 12 +-- .../PreciousCompanionGuardTests.swift | 102 ++++++++++++++++++ 4 files changed, 118 insertions(+), 6 deletions(-) create mode 100644 Tests/PokeTokenBarTests/PreciousCompanionGuardTests.swift diff --git a/Sources/PokeTokenBar/Core/CompanionStore.swift b/Sources/PokeTokenBar/Core/CompanionStore.swift index 7de656b90..a3ad8fc74 100644 --- a/Sources/PokeTokenBar/Core/CompanionStore.swift +++ b/Sources/PokeTokenBar/Core/CompanionStore.swift @@ -181,6 +181,14 @@ final class CompanionStore { if a.dittoDisguise != nil && !a.dittoRevealed { return false } // 위장 중엔 이로치 숨김(리빌 때 공개) return a.isShiny } + /// 새 알(리롤) 구매 시 실수로 놓아주지 않도록 2단계 확인이 필요한 고가치 개체인지 판정. + /// 이로치(shiny)이거나 전설(legendary)인 경우에만 2단계 경고를 띄운다. + /// 희귀(rare)는 고급/희귀 알의 반복 리롤 피로도(alert fatigue)를 방지하기 위해 일반 확인만 거친다. + /// 위장 중인 메타몽은 `currentIsShiny`(=false)와 `rarity`(=.common)의 불변식을 그대로 따라 + /// 리빌 전 정체를 누설하지 않는다. + var isHighValueCompanion: Bool { + currentIsShiny || rarity == .legendary + } var currentNature: PokemonNature? { state.active?.nature } var growthMultiplier: Int? { state.active?.hasGrowthBoost == true ? PokemonBalance.repeatGrowthMultiplier : nil diff --git a/Sources/PokeTokenBar/Core/Localization.swift b/Sources/PokeTokenBar/Core/Localization.swift index 344c60863..eaf477b5c 100644 --- a/Sources/PokeTokenBar/Core/Localization.swift +++ b/Sources/PokeTokenBar/Core/Localization.swift @@ -1389,7 +1389,9 @@ struct L { "\(monName) verabschieden und gegen \(eggName) tauschen?") } var freshEggShinyWarning: String { t("⚠️ 이로치 포켓몬이에요! 정말 놓아줄까요?", "⚠️ This one is shiny! Really send it off?", "⚠️ 色違いです!本当に手放しますか?", "⚠️ ¡Este es variocolor! ¿Seguro que quieres soltarlo?", "⚠️ Celui-ci est chromatique ! Vraiment le laisser partir ?", "⚠️ Esse é shiny! Quer mesmo soltar?", "⚠️ Dieses Pokémon ist schillernd! Wirklich verabschieden?") } + var freshEggLegendaryWarning: String { t("⚠️ 전설 포켓몬이에요! 정말 놓아줄까요?", "⚠️ This is a Legendary Pokémon! Really send it off?", "⚠️ 伝説のポケモンです!本当に手放しますか?", "⚠️ ¡Es un Pokémon legendario! ¿Seguro que quieres soltarlo?", "⚠️ C'est un Pokémon légendaire ! Vraiment le laisser partir ?", "⚠️ Esse é um Pokémon lendário! Quer mesmo soltar?", "⚠️ Dieses Pokémon ist legendär! Wirklich verabschieden?") } var freshEggDiscardShiny: String { t("이로치 놓아주기", "Send shiny off", "手放す", "Soltar variocolor", "Laisser partir le chromatique", "Soltar o shiny", "Schillerndes Pokémon verabschieden") } + var freshEggDiscardValuable: String { t("놓아주기", "Send off", "手放す", "Soltar", "Laisser partir", "Soltar", "Verabschieden") } // MARK: 사탕 획득 알림 ("왜 받는지" = 토큰 한도를 다 채운 수고에 대한 보상) func notifCandyTitle(item: String, count: Int) -> String { diff --git a/Sources/PokeTokenBar/UI/ShopView.swift b/Sources/PokeTokenBar/UI/ShopView.swift index 39c3a8601..16ad0943c 100644 --- a/Sources/PokeTokenBar/UI/ShopView.swift +++ b/Sources/PokeTokenBar/UI/ShopView.swift @@ -136,7 +136,7 @@ private struct EggCard: View { let nav: PopoverNavigation let tier: Rarity? @State private var stage: Stage = .idle - private enum Stage { case idle, confirm, shinyConfirm } + private enum Stage { case idle, confirm, preciousConfirm } private var price: Int { store.price(of: .egg(tier)) } @@ -213,20 +213,20 @@ private struct EggCard: View { Text(l.eggConfirm(store.displayName, l.eggName(tier))) .font(.caption2).foregroundStyle(.secondary).lineLimit(2) Spacer() - // 이로치면 한 번 더 경고, 아니면 즉시 실행. + // 이로치나 전설 등 고가치 포켓몬이면 한 번 더 경고, 아니면 즉시 실행. Button(l.buy) { - if store.currentIsShiny { stage = .shinyConfirm } else { commit() } + if store.isHighValueCompanion { stage = .preciousConfirm } else { commit() } } .buttonStyle(.borderedProminent).controlSize(.small) Button(l.cancel) { stage = .idle } .buttonStyle(.borderless).controlSize(.small) } - case .shinyConfirm: + case .preciousConfirm: HStack(spacing: 8) { - Text(l.freshEggShinyWarning) + Text(store.rarity == .legendary ? l.freshEggLegendaryWarning : l.freshEggShinyWarning) .font(.caption2.weight(.semibold)).foregroundStyle(.orange).lineLimit(2) Spacer() - Button(l.freshEggDiscardShiny) { commit() } + Button(store.currentIsShiny && store.rarity != .legendary ? l.freshEggDiscardShiny : l.freshEggDiscardValuable) { commit() } .buttonStyle(.borderedProminent).controlSize(.small).tint(.orange) Button(l.cancel) { stage = .idle } .buttonStyle(.borderless).controlSize(.small) diff --git a/Tests/PokeTokenBarTests/PreciousCompanionGuardTests.swift b/Tests/PokeTokenBarTests/PreciousCompanionGuardTests.swift new file mode 100644 index 000000000..337bd8509 --- /dev/null +++ b/Tests/PokeTokenBarTests/PreciousCompanionGuardTests.swift @@ -0,0 +1,102 @@ +import XCTest +@testable import PokeTokenBar + +private struct NoOpProvider: PokeProviding { + func line(baseSpeciesID: Int) async throws -> EvoLine { throw URLError(.notConnectedToInternet) } + func baseSpeciesIndex() async throws -> [BaseSpecies] { [] } + func baseSpecies(id: Int) async throws -> BaseSpecies? { nil } +} + +@MainActor +final class PreciousCompanionGuardTests: XCTestCase { + private let now = Date(timeIntervalSince1970: 1_700_000_000) + + private func store( + active: Bool = true, + rarity: Rarity = .common, + shiny: Bool = false, + dittoDisguise: Int? = nil, + dittoRevealed: Bool = false + ) -> CompanionStore { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("guard-\(UUID().uuidString).json") + let disguiseField = dittoDisguise != nil ? ",\"dittoDisguise\":\(dittoDisguise!),\"dittoRevealed\":\(dittoRevealed)" : "" + let mon = "{\"baseID\":10,\"pathIDs\":[10],\"stageIndex\":0,\"usedAtStage\":200000000," + + "\"rarity\":\"\(rarity.rawValue)\",\"totalForms\":3,\"isShiny\":\(shiny)\(disguiseField)}" + let json = "{\"installBaselineSet\":true,\"usedSinceInstall\":5000000000,\"spentTokens\":0," + + "\"lastDate\":\"d\",\"active\":\(active ? mon : "null"),\"dex\":[],\"collectedFinals\":[]}" + try? json.data(using: .utf8)!.write(to: url) + return CompanionStore(provider: NoOpProvider(), clock: { self.now }, fileURL: url, rng: SeededRNG(seed: 1)) + } + + func testHighValueCompanionIdentifiesLegendaryNonShiny() { + let s = store(rarity: .legendary, shiny: false) + XCTAssertEqual(s.rarity, .legendary) + XCTAssertFalse(s.currentIsShiny) + XCTAssertTrue(s.isHighValueCompanion, "전설 포켓몬은 비-이로치라도 고가치 개체로 판정되어 2단계 확인을 거친다") + } + + func testHighValueCompanionIdentifiesShinyNonLegendary() { + let s = store(rarity: .common, shiny: true) + XCTAssertEqual(s.rarity, .common) + XCTAssertTrue(s.currentIsShiny) + XCTAssertTrue(s.isHighValueCompanion, "이로치 포켓몬은 일반 등급이라도 고가치 개체로 판정된다") + } + + func testHighValueCompanionIdentifiesShinyLegendary() { + let s = store(rarity: .legendary, shiny: true) + XCTAssertEqual(s.rarity, .legendary) + XCTAssertTrue(s.currentIsShiny) + XCTAssertTrue(s.isHighValueCompanion, "이로치 전설 포켓몬은 고가치 개체로 판정된다") + } + + func testHighValueCompanionExcludesCommonNonShiny() { + let s = store(rarity: .common, shiny: false) + XCTAssertFalse(s.isHighValueCompanion, "일반 비-이로치 포켓몬은 2단계 경고 없이 즉시 교체 가능") + } + + func testHighValueCompanionExcludesUncommonNonShiny() { + let s = store(rarity: .uncommon, shiny: false) + XCTAssertFalse(s.isHighValueCompanion, "고급 비-이로치 포켓몬은 일반 확인만 거친다") + } + + func testHighValueCompanionExcludesRareNonShinyToAvoidRerollFatigue() { + let s = store(rarity: .rare, shiny: false) + XCTAssertEqual(s.rarity, .rare) + XCTAssertFalse(s.currentIsShiny) + XCTAssertFalse(s.isHighValueCompanion, "희귀(rare) 등급은 희귀 알 반복 리롤 피로도를 막기 위해 2단계 경고 대상에서 제외한다") + } + + func testHighValueCompanionIsFalseWhenNoActive() { + let s = store(active: false) + XCTAssertFalse(s.hasActive) + XCTAssertFalse(s.isHighValueCompanion) + } + + func testDisguisedDittoDoesNotLeakHighValueStatusBeforeReveal() { + // 이로치 메타몽이 일반 포켓몬으로 위장 중인 경우 + let s = store(rarity: .common, shiny: true, dittoDisguise: 10, dittoRevealed: false) + XCTAssertFalse(s.currentIsShiny, "위장 중인 메타몽은 이로치를 숨긴다") + XCTAssertEqual(s.rarity, .common, "위장 중인 메타몽의 외견 등급은 common") + XCTAssertFalse(s.isHighValueCompanion, "위장 중에는 고가치 확인을 띄워 정체를 누설(leak)하지 않는다") + + // 리빌된 메타몽인 경우 + let revealed = store(rarity: .rare, shiny: true, dittoDisguise: 10, dittoRevealed: true) + XCTAssertTrue(revealed.currentIsShiny) + XCTAssertTrue(revealed.isHighValueCompanion, "리빌 후에는 이로치가 드러나 고가치 개체로 판정된다") + } + + func testLocalizationStringsCoverAllSevenLanguages() { + for lang in AppLanguage.allCases { + let l = L(lang) + XCTAssertFalse(l.freshEggLegendaryWarning.isEmpty, "전설 경고 문구가 모든 언어(\(lang))에 존재해야 함") + XCTAssertTrue(l.freshEggLegendaryWarning.hasPrefix("⚠️"), "경고 접두사 ⚠️ 포함") + XCTAssertFalse(l.freshEggDiscardValuable.isEmpty, "놓아주기 버튼 라벨이 모든 언어(\(lang))에 존재해야 함") + } + + // 한국어와 영어 문구 특정 검증 + XCTAssertTrue(L(.ko).freshEggLegendaryWarning.contains("전설 포켓몬")) + XCTAssertEqual(L(.ko).freshEggDiscardValuable, "놓아주기") + XCTAssertTrue(L(.en).freshEggLegendaryWarning.contains("Legendary")) + XCTAssertEqual(L(.en).freshEggDiscardValuable, "Send off") + } +} From f0ef5ff3fc4f549546fa9d600f606097bf6320fa Mon Sep 17 00:00:00 2001 From: Ido Ziv <32648113+IdoZ14@users.noreply.github.com> Date: Fri, 25 Sep 2026 05:20:10 +0300 Subject: [PATCH 06/15] feat(dex): color dex numbers by rarity and add hover feedback (#343) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tint each Pokédex cell's number with its rarity color so rarity is readable in the "all" view, and lift the card on hover (slight scale + shadow on the card shape only) to signal it is clickable. Co-authored-by: Claude Opus 5.5 --- Sources/PokeTokenBar/UI/CompanionView.swift | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/Sources/PokeTokenBar/UI/CompanionView.swift b/Sources/PokeTokenBar/UI/CompanionView.swift index f6c3447c1..0aaf48dc4 100644 --- a/Sources/PokeTokenBar/UI/CompanionView.swift +++ b/Sources/PokeTokenBar/UI/CompanionView.swift @@ -1381,6 +1381,7 @@ private struct DexSpeciesCell: View { let isRepresentative: Bool let unownFormCount: Int let onTap: () -> Void + @State private var isHovered = false /// 로그(56)보다 작다 — 24칸 격자에 이름까지 담아야 한다. 원본 96×96 픽셀아트를 /// interpolation(.none) 으로 축소하므로 이 크기에서도 식별에 문제없다. @@ -1434,8 +1435,20 @@ private struct DexSpeciesCell: View { .strokeBorder(Color.accentColor, lineWidth: 1.5) } } + // 호버 = 클릭 가능 피드백. 확대는 격자 간격 안에 머무는 폭으로만. + // 그림자는 카드 모양에만 — 칸 전체에 `.shadow` 를 걸면 번호·이름·스프라이트 글자마다 그림자가 진다. + .background { + if isHovered { + RoundedRectangle(cornerRadius: 8) + .fill(Color(nsColor: .windowBackgroundColor)) + .shadow(color: .black.opacity(0.25), radius: 4, y: 2) + } + } + .scaleEffect(isHovered ? 1.04 : 1) + .animation(.easeOut(duration: 0.12), value: isHovered) } .buttonStyle(.plain) + .onHover { isHovered = $0 } .help(tooltip) .accessibilityLabel(tooltip) .contextMenu { @@ -1468,8 +1481,9 @@ private struct DexSpeciesCell: View { .accessibilityHidden(true) } } - .font(.system(size: 8, weight: .medium)) - .foregroundStyle(.secondary) + // 번호 색 = 희귀도 — "전체" 보기에서도 칸마다 희귀도가 보인다. + .font(.system(size: 8, weight: .semibold)) + .foregroundStyle(rarityColor(species.rarity)) .padding(.horizontal, 2) .background(.regularMaterial, in: Capsule()) } From eed5b735cdd6cf7728d88ee78b8ec51a155dca12 Mon Sep 17 00:00:00 2001 From: Killian HERZER <114735716+KillianHzr@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:21:34 +0200 Subject: [PATCH 07/15] feat(limits): let each Claude account use its own session key (#346) Additional accounts got their limits from a Keychain token that automatic polls never read, so they stopped at every token expiry or app restart until a manual refresh. Each config folder can now take its own claude.ai session key in Settings > Advanced. --- README.ja.md | 2 +- README.ko.md | 2 +- README.md | 2 +- .../Core/ClaudeAccountRoots.swift | 20 +- Sources/PokeTokenBar/Core/Localization.swift | 19 +- .../Core/SessionKeyLimitsProvider.swift | 21 ++ Sources/PokeTokenBar/Core/UsageStore.swift | 110 +++++- Sources/PokeTokenBar/UI/PopoverView.swift | 7 +- Sources/PokeTokenBar/UI/SettingsView.swift | 92 ++++- .../AccountSessionKeyTests.swift | 319 ++++++++++++++++++ .../LocalizationInterpolationTests.swift | 1 + 11 files changed, 570 insertions(+), 25 deletions(-) create mode 100644 Tests/PokeTokenBarTests/AccountSessionKeyTests.swift diff --git a/README.ja.md b/README.ja.md index b1ca6f7f2..d9ff6bc79 100644 --- a/README.ja.md +++ b/README.ja.md @@ -164,7 +164,7 @@ Pi は複数のモデルを一つのセッションログに流すことがあ - **サービス別タブ** — Claude Code・Codex・Gemini CLI・Antigravity・OpenCode・Hermes Agent・Cursor・Grok CLI・Copilot CLI・Kiro CLI・Pi Agent・omp・Aside のうち2つ以上が検出されると、小さなタブでサービス別の詳細を切替(今日の合計は合算のまま)。 - **公式の上限** — Claude・Codex・Antigravity の5時間/週間使用率とリセットのカウントダウンを、今日の数字のすぐ下に。 - **追加スキャンフォルダ** — 既定のパスの外にあるログのために、プロバイダーごとにスキャンルートを追加(設定 → 詳細)。 -- **複数の Claude アカウント** — 設定フォルダ(`CLAUDE_CONFIG_DIR`)ごとにログインした Claude Code アカウントには公式上限の下にそれぞれのタブが付き、ゲージ・ふしぎなアメ・通知・今日/今月のトークンを個別に表示します。`~/.claude-*` フォルダとエクスポートされた `CLAUDE_CONFIG_DIR` は自動で見つけ、それ以外の場所は設定 → 詳細で追加します。メニューバーの上限 %・コンパニオンの様子・予測は最後に使ったアカウントに従います(設定 → 一般 → 追跡する Claude アカウント)。 +- **複数の Claude アカウント** — 設定フォルダ(`CLAUDE_CONFIG_DIR`)ごとにログインした Claude Code アカウントには公式上限の下にそれぞれのタブが付き、ゲージ・ふしぎなアメ・通知・今日/今月のトークンを個別に表示します。`~/.claude-*` フォルダとエクスポートされた `CLAUDE_CONFIG_DIR` は自動で見つけ、それ以外の場所は設定 → 詳細で追加します。メニューバーの上限 %・コンパニオンの様子・予測は最後に使ったアカウントに従います(設定 → 一般 → 追跡する Claude アカウント)。アカウントごとに claude.ai セッションキーを設定でき(設定 → 詳細)、Keychain なしでもそのアカウントの上限が更新され続けます。 - **消費予測** — 現在の5時間ウィンドウが100%に達する時刻を予測。 - **アプリ内アップデート** — ワンクリックの更新確認、設定に現在のバージョンを表示。 - **使用量のふりかえり** — 週・月・年ごとに過去までさかのぼれるポケモン図鑑風のまとめ(合計・ゲージ・前の期間との比較(進行中は同じ日数どうし)・最高の日・最長連続・卒業)。記録は今年と昨年分をローカルに保存。 diff --git a/README.ko.md b/README.ko.md index edd8bc75f..b0c16cd59 100644 --- a/README.ko.md +++ b/README.ko.md @@ -164,7 +164,7 @@ Pi는 모델 여러 개를 세션 로그 하나로 흘려보낼 수 있습니다 - **서비스별 탭** — Claude Code·Codex·Gemini CLI·Antigravity·OpenCode·Hermes Agent·Cursor·Grok CLI·Copilot CLI·Kiro CLI·Pi Agent·omp·Aside 중 2개 이상 감지되면 작은 탭으로 상세를 서비스별 전환(오늘 합계는 통합 유지). - **공식 한도** — Claude·Codex·Antigravity 5시간/주간 사용률 + 리셋 카운트다운을 오늘 숫자 바로 아래에. - **추가 스캔 폴더** — 기본 경로 밖에 있는 로그를 위해 프로바이더별로 스캔 루트를 더 지정 (설정 → 고급). -- **여러 Claude 계정** — 설정 폴더(`CLAUDE_CONFIG_DIR`)마다 따로 로그인한 Claude Code 계정은 공식 한도 아래에 각자의 탭이 생기고, 게이지·이상한 사탕·알림·오늘/이번 달 토큰을 따로 보여 줍니다. `~/.claude-*` 폴더와 export 된 `CLAUDE_CONFIG_DIR` 은 자동으로 찾고, 다른 위치는 설정 → 고급에서 추가합니다. 메뉴바 한도 %·컴패니언 기분·예측은 마지막으로 사용한 계정을 따릅니다(설정 → 일반 → 추적할 Claude 계정). +- **여러 Claude 계정** — 설정 폴더(`CLAUDE_CONFIG_DIR`)마다 따로 로그인한 Claude Code 계정은 공식 한도 아래에 각자의 탭이 생기고, 게이지·이상한 사탕·알림·오늘/이번 달 토큰을 따로 보여 줍니다. `~/.claude-*` 폴더와 export 된 `CLAUDE_CONFIG_DIR` 은 자동으로 찾고, 다른 위치는 설정 → 고급에서 추가합니다. 메뉴바 한도 %·컴패니언 기분·예측은 마지막으로 사용한 계정을 따릅니다(설정 → 일반 → 추적할 Claude 계정). 계정마다 claude.ai 세션 키를 따로 넣을 수 있어(설정 → 고급), Keychain 없이도 그 계정의 한도가 계속 갱신됩니다. - **소진 예측** — 현재 5시간 창이 100%에 도달할 시각 예측. - **인앱 업데이트** — 원클릭 업데이트 확인, 설정에 현재 버전 표시. - **사용량 돌아보기** — 주·월·연 단위로 지난 기간까지 넘겨 보는 포켓몬 도감 스타일 요약(합계·게이지·이전 기간과 비교(진행 중이면 같은 일수끼리)·최고의 날·최장 연속·졸업). 기록은 올해와 작년분을 로컬에 보관. diff --git a/README.md b/README.md index af68c5ca5..c6126cb0c 100644 --- a/README.md +++ b/README.md @@ -163,7 +163,7 @@ Reset countdowns include the clock time, and the colored percentage stays aligne - **Per-service tabs** — when two or more of Claude Code, Codex, Gemini CLI, Antigravity, OpenCode, Hermes Agent, Cursor, Grok CLI, Copilot CLI, Kiro CLI, Pi Agent, omp, and Aside are detected, compact tabs switch between them; today's total stays combined. - **Official limits** — Claude, Codex & Antigravity 5-hour / weekly utilization with reset countdowns, right under today's numbers. - **Additional scan folders** — per-provider extra scan roots for logs kept outside the built-in paths (Settings → Advanced). -- **Several Claude accounts** — Claude Code logins kept in their own config folder (`CLAUDE_CONFIG_DIR`) each get a tab under the official limits, with their own gauges, Rare Candy, alerts, and today/month tokens. `~/.claude-*` folders and an exported `CLAUDE_CONFIG_DIR` are found automatically; add others in Settings → Advanced. The menu bar percentage, companion mood, and forecast follow the account you used last (Settings → General → Tracked Claude account). +- **Several Claude accounts** — Claude Code logins kept in their own config folder (`CLAUDE_CONFIG_DIR`) each get a tab under the official limits, with their own gauges, Rare Candy, alerts, and today/month tokens. `~/.claude-*` folders and an exported `CLAUDE_CONFIG_DIR` are found automatically; add others in Settings → Advanced. The menu bar percentage, companion mood, and forecast follow the account you used last (Settings → General → Tracked Claude account). Each account can take its own claude.ai session key (Settings → Advanced), so its limits keep refreshing without the Keychain. - **Burn-rate forecast** — projects when the current 5h window hits 100%. - **In-app updates** — one-click update check; current version shown in Settings. - **Usage recap** — a Pokédex-style recap of any week, month or year you step back to (total, meters, comparison with the previous period, day for day while it is still running, best day, best streak, graduations). History is kept locally for this year and the last. diff --git a/Sources/PokeTokenBar/Core/ClaudeAccountRoots.swift b/Sources/PokeTokenBar/Core/ClaudeAccountRoots.swift index 40d5414c4..068b02dfb 100644 --- a/Sources/PokeTokenBar/Core/ClaudeAccountRoots.swift +++ b/Sources/PokeTokenBar/Core/ClaudeAccountRoots.swift @@ -81,8 +81,14 @@ enum ClaudeAccountRoots { savedLogins.login(file: file)?.identity } + /// Organization of this folder's login, to pick the same one among a session key's organizations. + static func savedOrganizationID(in root: URL) -> String? { + savedLogins.login(file: root.appendingPathComponent(".claude.json"))?.organizationID + } + private struct SavedLogin { let identity: AccountIdentity? + var organizationID: String? = nil } private static let savedLogins = SavedLoginCache() @@ -123,7 +129,8 @@ enum ClaudeAccountRoots { return SavedLogin(identity: nil) } let org = (account["organizationName"] as? String).flatMap { $0.isEmpty ? nil : $0 } - return SavedLogin(identity: AccountIdentity(email: email, organizationName: org)) + let orgID = (account["organizationUuid"] as? String).flatMap { $0.isEmpty ? nil : $0 } + return SavedLogin(identity: AccountIdentity(email: email, organizationName: org), organizationID: orgID) } } @@ -257,12 +264,17 @@ struct AdditionalClaudeLimits: Sendable { /// The folder's token was rejected. Only Claude Code running on that folder renews it, so a /// refresh alone cannot help: the tab shows the last values dimmed, with what to do. var isExpired: Bool + /// The rejected credential is the folder's own session key: the fix is a fresh key in Settings, + /// not Claude Code, and a retry would only read the Keychain the key is there to avoid. + var sessionKeyExpired: Bool - init(rootPath: String, status: LimitStatus, isExpired: Bool = false, updatedAt: Date? = nil) { + init(rootPath: String, status: LimitStatus, isExpired: Bool = false, sessionKeyExpired: Bool = false, + updatedAt: Date? = nil) { self.rootPath = rootPath self.key = ClaudeAccountRoots.pathKey(for: URL(fileURLWithPath: rootPath)) self.status = status self.isExpired = isExpired + self.sessionKeyExpired = sessionKeyExpired self.updatedAt = updatedAt } } @@ -282,6 +294,8 @@ struct ClaudeAccountLimits: Sendable, Identifiable { let isDefault: Bool let isExpired: Bool var updatedAt: Date? = nil + /// See `AdditionalClaudeLimits.sessionKeyExpired`. The default account keeps `UsageStore.limitsAuthExpiry`. + var sessionKeyExpired = false static func defaultAccount(_ status: LimitStatus, isExpired: Bool = false, updatedAt: Date? = nil) -> ClaudeAccountLimits { @@ -293,7 +307,7 @@ struct ClaudeAccountLimits: Sendable, Identifiable { ClaudeAccountLimits(id: account.key, windowKeyPrefix: "claude.\(account.key)", fallbackTitle: (account.rootPath as NSString).abbreviatingWithTildeInPath, status: account.status, isDefault: false, isExpired: account.isExpired, - updatedAt: account.updatedAt) + updatedAt: account.updatedAt, sessionKeyExpired: account.sessionKeyExpired) } /// The account has at least one official window to show (a placeholder tab has none). diff --git a/Sources/PokeTokenBar/Core/Localization.swift b/Sources/PokeTokenBar/Core/Localization.swift index eaf477b5c..402996fa1 100644 --- a/Sources/PokeTokenBar/Core/Localization.swift +++ b/Sources/PokeTokenBar/Core/Localization.swift @@ -338,11 +338,20 @@ struct L { "Busca os limites oficiais sem avisos do Keychain. Cole o valor de DevTools → Application → Cookies → claude.ai → sessionKey.", "Ruft offizielle Limits ohne Keychain-Pop-up ab. Füge den Wert aus DevTools → Application → Cookies → claude.ai → sessionKey ein.") } - var sessionKeyDefaultAccountOnly: String { - t("기본 Claude 계정(~/.claude)에만 적용됩니다.", "Applies to the default Claude account (~/.claude) only.", - "デフォルトの Claude アカウント(~/.claude)にのみ適用されます。", "Solo se aplica a la cuenta de Claude predeterminada (~/.claude).", - "Ne concerne que le compte Claude principal (~/.claude).", "Aplica-se apenas à conta padrão do Claude (~/.claude).", - "Gilt nur für das Standard-Claude-Konto (~/.claude).") + var sessionKeyPerAccountNote: String { + t("기본 Claude 계정(~/.claude)에 적용됩니다. 다른 계정은 아래에서 각자 키를 넣을 수 있어요.", + "Applies to the default Claude account (~/.claude). Each other account can have its own key below.", + "デフォルトの Claude アカウント(~/.claude)に適用されます。ほかのアカウントは下でそれぞれキーを設定できます。", + "Se aplica a la cuenta de Claude predeterminada (~/.claude). Cada una de las otras cuentas puede tener su propia clave abajo.", + "Concerne le compte Claude principal (~/.claude). Chaque autre compte peut avoir sa propre clé ci-dessous.", + "Aplica-se à conta padrão do Claude (~/.claude). Cada uma das outras contas pode ter a própria chave abaixo.", + "Gilt für das Standard-Claude-Konto (~/.claude). Jedes weitere Konto kann unten einen eigenen Schlüssel haben.") + } + /// An additional account's own key row: the account's tab title, or its folder before its limits load. + func accountSessionKeyLabel(_ account: String) -> String { + t("\(account) 세션 키", "Session key for \(account)", "\(account) のセッションキー", + "Clave de sesión de \(account)", "Clé de session de \(account)", "Chave de sessão de \(account)", + "Sitzungsschlüssel für \(account)") } /// 평문 보관을 숨기지 않는다 — 사용자가 무엇을 맡기는지, 어떻게 취소하는지 알아야 한다. var sessionKeyStorageNote: String { diff --git a/Sources/PokeTokenBar/Core/SessionKeyLimitsProvider.swift b/Sources/PokeTokenBar/Core/SessionKeyLimitsProvider.swift index 286951182..70467451a 100644 --- a/Sources/PokeTokenBar/Core/SessionKeyLimitsProvider.swift +++ b/Sources/PokeTokenBar/Core/SessionKeyLimitsProvider.swift @@ -80,6 +80,13 @@ struct SessionKeyStore: Sendable { AppStatePaths.directory().appendingPathComponent("session-key.json") } + /// An additional Claude account's own key (`ClaudeAccountRoots`), next to the default one. + /// Named by the folder's path hash, like its alert and candy keys, so no path ends up in a file name. + static func forConfigRoot(_ root: URL, directory: URL = AppStatePaths.directory()) -> SessionKeyStore { + SessionKeyStore(fileURL: directory.appendingPathComponent( + "session-key-\(ClaudeAccountRoots.pathKey(for: root)).json")) + } + func load() -> SessionKeyCredential? { guard let data = try? Data(contentsOf: fileURL), let credential = try? JSONDecoder().decode(SessionKeyCredential.self, from: data), @@ -289,6 +296,20 @@ struct ChainedLimitsProvider: ClaudeLimitsProviding { let primary: any ClaudeLimitsProviding let fallback: any ClaudeLimitsProviding + /// An additional Claude folder: its own session key first, so automatic polls keep it fresh + /// without the Keychain, then the folder's Claude Code token as before. + static func forConfigRoot( + _ root: URL, + sessionKeyDirectory: URL = AppStatePaths.directory(), + http: any SessionKeyHTTPClient = URLSessionSessionKeyClient(), + fallback: (any ClaudeLimitsProviding)? = nil) -> ChainedLimitsProvider + { + ChainedLimitsProvider( + primary: SessionKeyLimitsProvider( + store: .forConfigRoot(root, directory: sessionKeyDirectory), http: http), + fallback: fallback ?? OAuthLimitsProvider(accessTokenCache: .forConfigRoot(root))) + } + func fetch(allowKeychainPrompt: Bool) async throws -> LimitStatus { do { return try await primary.fetch(allowKeychainPrompt: allowKeychainPrompt) diff --git a/Sources/PokeTokenBar/Core/UsageStore.swift b/Sources/PokeTokenBar/Core/UsageStore.swift index fc9d24d8b..c391141c2 100644 --- a/Sources/PokeTokenBar/Core/UsageStore.swift +++ b/Sources/PokeTokenBar/Core/UsageStore.swift @@ -163,7 +163,8 @@ final class UsageStore { didSet { defaults.set(disableKeychainAccess, forKey: "disableKeychainAccess") // 저장 누락이던 기존 버그 — 재시작 후 풀렸음 KeychainAccessGate.isDisabled = disableKeychainAccess - if disableKeychainAccess { clearAdditionalClaudeAccounts() } + // A folder's own session key needs no Keychain either, like the default key below. + if disableKeychainAccess { clearAdditionalClaudeAccounts(keeping: accountSessionKeyPaths) } // 세션 키/토큰 파일이 있으면 Keychain 없이도 한도를 조회할 수 있으므로 섹션을 지우지 않는다. if disableKeychainAccess && !sessionKeyConfigured { limits = nil @@ -173,7 +174,8 @@ final class UsageStore { antigravityLimits = nil antigravityLimitsAuthExpired = false } - if !disableKeychainAccess || sessionKeyConfigured || antigravityLimitsProvider.hasTokenFile { + if !disableKeychainAccess || sessionKeyConfigured || !accountSessionKeyPaths.isEmpty + || antigravityLimitsProvider.hasTokenFile { Task { await refresh() } } } @@ -227,6 +229,9 @@ final class UsageStore { private var additionalProviders: [String: any ClaudeLimitsProviding] = [:] /// 세션 키 저장·조직 조회. 조회 체인과 같은 인스턴스를 공유한다(기본값은 `.shared`). private let sessionKeys: any SessionKeyManaging + /// Each additional folder's own session key, for Settings. The limits chain of that folder reads + /// the same file (`ChainedLimitsProvider.forConfigRoot`). + private let accountSessionKeys: @Sendable (URL) -> any SessionKeyManaging private let codexLimitsProvider: any CodexLimitsProviding private let antigravityLimitsProvider: any AntigravityLimitsProviding private let cursorLimitsProvider: any CursorLimitsProviding @@ -667,7 +672,7 @@ final class UsageStore { claudeLimitsProvider: any ClaudeLimitsProviding = ChainedLimitsProvider( primary: SessionKeyLimitsProvider.shared, fallback: OAuthLimitsProvider()), additionalClaudeLimitsProvider: @escaping @Sendable (URL) -> any ClaudeLimitsProviding = { - OAuthLimitsProvider(accessTokenCache: .forConfigRoot($0)) + ChainedLimitsProvider.forConfigRoot($0) }, discoverClaudeConfigDirs: @escaping @Sendable () -> [URL] = { ClaudeAccountRoots.installedDiscovery() }, readDefaultIdentity: @escaping @Sendable () -> AccountIdentity? = { ClaudeAccountRoots.installedDefaultIdentity() }, @@ -683,6 +688,9 @@ final class UsageStore { cursorLimitsProvider: any CursorLimitsProviding = CursorRateLimitsProvider(), statusProvider: any ProviderStatusProviding = StatuspageStatusProvider(), sessionKeys: any SessionKeyManaging = SessionKeyLimitsProvider.shared, + accountSessionKeys: @escaping @Sendable (URL) -> any SessionKeyManaging = { + SessionKeyLimitsProvider(store: .forConfigRoot($0)) + }, autoRefresh: Bool = true, defaults: UserDefaults = .standard) { self.providers = providers @@ -694,6 +702,7 @@ final class UsageStore { self.claudeUsageEntries = claudeUsageEntries self.readPromptHistory = readPromptHistory self.sessionKeys = sessionKeys + self.accountSessionKeys = accountSessionKeys self.codexLimitsProvider = codexLimitsProvider self.antigravityLimitsProvider = antigravityLimitsProvider self.cursorLimitsProvider = cursorLimitsProvider @@ -1162,22 +1171,28 @@ final class UsageStore { let discover = discoverClaudeConfigDirs let readIdentity = readDefaultIdentity let setting = additionalClaudeConfigDirs + let sessionKeys = accountSessionKeys let found = await Task.detached(priority: .utility) { let detected = discover() let roots = ClaudeAccountRoots.merged(detected: detected, setting: setting) var saved: [String: AccountIdentity] = [:] for root in roots { saved[root.path] = ClaudeAccountRoots.savedIdentity(in: root) } let listed = Set(ClaudeAccountRoots.roots(from: setting).map(\.path)) - return (detected: detected, roots: roots, listed: listed, saved: saved, defaultIdentity: readIdentity()) + let keyed = Set(roots.filter { sessionKeys($0).credential() != nil }.map(\.path)) + return (detected: detected, roots: roots, listed: listed, keyed: keyed, saved: saved, + defaultIdentity: readIdentity()) }.value detectedClaudeConfigDirs = found.detected.map(\.path) defaultSavedIdentity = found.defaultIdentity - let roots = found.roots + additionalClaudeConfigRoots = found.roots.map(\.path) + accountSessionKeyPaths = found.keyed + // Same rule as the default account: with the Keychain off, no account reads a token, and only + // a folder's own session key (no Keychain involved) still loads its limits. + let roots = disableKeychainAccess ? found.roots.filter { found.keyed.contains($0.path) } : found.roots let paths = Set(roots.map(\.path)) additionalProviders = additionalProviders.filter { paths.contains($0.key) } additionalBackoff = additionalBackoff.filter { paths.contains($0.key) } - // Same rule as the default account: with the Keychain off, no account reads a token. - guard !roots.isEmpty, !disableKeychainAccess else { + guard !roots.isEmpty else { clearAdditionalClaudeAccounts() return } @@ -1229,12 +1244,17 @@ final class UsageStore { additionalBackoff[root.path] = (Date().addingTimeInterval(retryAfter ?? interval), interval) } AppLog.write("additional claude limits unavailable (\(root.lastPathComponent)): \(error)") - if Self.isAuthRejection(error) || previous?.isExpired == true { + // The chain rethrows the folder's session key error when it has one (`ChainedLimitsProvider`). + let keyRejected = (error as? LimitsError) == .sessionKeyInvalid + if Self.isAuthRejection(error) || keyRejected || previous?.isExpired == true { // Keep the tab, with the last values when there are some, so the account stays visible. var status = previous?.status ?? LimitStatus() status.fillIdentity(from: found.saved[root.path]) + // Another failure keeps the known cause; a token rejection replaces it. + let sessionKeyExpired = keyRejected + || (!Self.isAuthRejection(error) && previous?.sessionKeyExpired == true) keep(AdditionalClaudeLimits(rootPath: root.path, status: status, isExpired: true, - updatedAt: previous?.updatedAt)) + sessionKeyExpired: sessionKeyExpired, updatedAt: previous?.updatedAt)) } else { // Only a refresh allowed to prompt for this folder can fix it. if mayPrompt { pending = true } @@ -1265,9 +1285,11 @@ final class UsageStore { await refreshClaudeAccountUsage(folders: folders) } - private func clearAdditionalClaudeAccounts() { - additionalLimits = [] + /// `kept`: folders with their own session key, which still load while the Keychain is off. + private func clearAdditionalClaudeAccounts(keeping kept: Set = []) { + additionalLimits.removeAll { !kept.contains($0.rootPath) } additionalLimitsPending = false + guard additionalLimits.isEmpty else { return } // the next refresh recomputes the rest claudeAccountActivity = [:] claudeAccountUsage = [:] claudeAccountBlocks = [:] @@ -1345,6 +1367,72 @@ final class UsageStore { return status == errSecUserCanceled || status == errSecAuthFailed } + // MARK: Additional account session keys + + /// Additional folders at the last refresh (detected and listed), each with its own session key row + /// in Settings. Kept while the Keychain is off, so a key can still be entered then. + private(set) var additionalClaudeConfigRoots: [String] = [] + /// Folders with a saved session key of their own, read from disk at each refresh. + private(set) var accountSessionKeyPaths: Set = [] + private var accountSessionKeyFailures: [String: any Error] = [:] + /// The folder whose pasted key is being checked. + private(set) var validatingAccountSessionKeyPath: String? + + func accountSessionKeyError(for rootPath: String) -> String? { + accountSessionKeyFailures[rootPath].map { Self.friendlyLimitError($0, L(localizationLanguage)) } + } + + /// Same check as the default key (listing the organizations proves the key works), then the refresh + /// shows the account's limits at once. Picks the organization the folder is logged in to when the + /// key sees several: Settings has no organization picker per account. + func saveAccountSessionKey(_ raw: String, for rootPath: String) async { + guard validatingAccountSessionKeyPath == nil else { return } + validatingAccountSessionKeyPath = rootPath + accountSessionKeyFailures[rootPath] = nil + defer { validatingAccountSessionKeyPath = nil } + + let root = URL(fileURLWithPath: rootPath) + let keys = accountSessionKeys(root) + do { + let key = try SessionKeyStore.normalize(raw) + let organizations = try await keys.organizations(sessionKey: key) + guard let picked = Self.accountOrganization( + organizations, loggedIn: ClaudeAccountRoots.savedOrganizationID(in: root)) else { + throw LimitsError.sessionKeyNoOrganization + } + try keys.save(key: key, organizationID: picked.id) + accountSessionKeyPaths.insert(rootPath) + AppLog.write("account session key saved (\(root.lastPathComponent), orgs=\(organizations.count))") + await refresh() + } catch { + accountSessionKeyFailures[rootPath] = error + AppLog.write("account session key save failed (\(root.lastPathComponent)): \(error)") + } + } + + func clearAccountSessionKey(for rootPath: String) { + let root = URL(fileURLWithPath: rootPath) + accountSessionKeys(root).clear() + accountSessionKeyPaths.remove(rootPath) + accountSessionKeyFailures[rootPath] = nil + // An expiry that was the key's goes with it: the folder's token decides again at the next refresh. + if let index = additionalLimits.firstIndex(where: { $0.rootPath == rootPath }), + additionalLimits[index].sessionKeyExpired { + additionalLimits[index].isExpired = false + additionalLimits[index].sessionKeyExpired = false + } + AppLog.write("account session key cleared (\(root.lastPathComponent))") + Task { await refresh() } + } + + /// The folder's own organization first, then the rule of the default key (one with usage data). + nonisolated static func accountOrganization( + _ organizations: [SessionKeyOrganization], loggedIn organizationID: String?) -> SessionKeyOrganization? + { + organizations.first { $0.id == organizationID } + ?? organizations.first(where: \.hasUsageData) ?? organizations.first + } + // MARK: claude.ai 세션 키 (Keychain 프롬프트 없는 한도 경로) /// 키가 저장돼 있는지. 저장 여부만 노출하고 값은 UI 로 되돌리지 않는다. diff --git a/Sources/PokeTokenBar/UI/PopoverView.swift b/Sources/PokeTokenBar/UI/PopoverView.swift index 1a9369282..9b9b45e07 100644 --- a/Sources/PokeTokenBar/UI/PopoverView.swift +++ b/Sources/PokeTokenBar/UI/PopoverView.swift @@ -788,7 +788,12 @@ struct PopoverView: View { private func claudeAccountLimits(_ account: ClaudeAccountLimits, showsUsage: Bool) -> some View { let limits = account.status if account.isExpired, !account.isDefault { - authExpiredNotice(hint: l.additionalAccountExpiredHint(account.fallbackTitle)) + // A dead session key is fixed in Settings; the retry banner would only read the Keychain. + if account.sessionKeyExpired { + sessionKeyExpiredNotice + } else { + authExpiredNotice(hint: l.additionalAccountExpiredHint(account.fallbackTitle)) + } } else if !account.isDefault, account.isStale() { // The default account's stale label lives in the refresh row above the tabs. staleBadge(updatedAt: account.updatedAt) diff --git a/Sources/PokeTokenBar/UI/SettingsView.swift b/Sources/PokeTokenBar/UI/SettingsView.swift index 6938ded5b..253b35e28 100644 --- a/Sources/PokeTokenBar/UI/SettingsView.swift +++ b/Sources/PokeTokenBar/UI/SettingsView.swift @@ -21,6 +21,8 @@ struct SettingsView: View { /// 두 번째 진입부터 접힌 채로 열린다. onAppear 에서 1회 반영한다. @State private var didApplyStartExpanded = false @State private var sessionKeyInput = "" + /// Pasted keys of the additional accounts, by folder path. Emptied once saved, like the default one. + @State private var accountSessionKeyInputs: [String: String] = [:] @State private var isCheckingUpdate = false @State private var didCheckUpdate = false @State private var selectedScanProviderID = "claude_code" @@ -484,8 +486,8 @@ struct SettingsView: View { .font(.caption2).foregroundStyle(.green) } } - // The key only replaces the default login's Keychain read; other accounts keep theirs. - Text(store.claudeAccounts.count > 1 ? l.sessionKeyHint + " " + l.sessionKeyDefaultAccountOnly : l.sessionKeyHint) + // The key only replaces the default login's Keychain read; other accounts have their own below. + Text(store.additionalClaudeConfigRoots.isEmpty ? l.sessionKeyHint : l.sessionKeyHint + " " + l.sessionKeyPerAccountNote) .font(.caption2).foregroundStyle(.tertiary) .fixedSize(horizontal: false, vertical: true) } @@ -545,6 +547,91 @@ struct SettingsView: View { } } + /// Each additional Claude folder can have its own key. Without one, its limits come from its + /// Keychain token, which automatic polls never read: they stop whenever that token expires. + @ViewBuilder + private func accountSessionKeyRows(_ store: UsageStore) -> some View { + ForEach(store.additionalClaudeConfigRoots, id: \.self) { rootPath in + Divider() + accountSessionKeyRow(store, rootPath: rootPath) + } + // The storage note is shown once, under the default key when that one is saved. + if !store.accountSessionKeyPaths.isEmpty, !store.sessionKeyConfigured || store.sessionKeyError != nil { + Text(l.sessionKeyStorageNote) + .font(.caption2).foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 12).padding(.bottom, 6) + } + } + + @ViewBuilder + private func accountSessionKeyRow(_ store: UsageStore, rootPath: String) -> some View { + let folder = (rootPath as NSString).abbreviatingWithTildeInPath + let account = store.claudeAccounts.first { + $0.id == ClaudeAccountRoots.pathKey(for: URL(fileURLWithPath: rootPath)) + } + let title = account?.title ?? folder + let configured = store.accountSessionKeyPaths.contains(rootPath) + let input = Binding(get: { accountSessionKeyInputs[rootPath] ?? "" }, + set: { accountSessionKeyInputs[rootPath] = $0 }) + groupRow { + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 6) { + Text(l.accountSessionKeyLabel(title)) + if account?.sessionKeyExpired == true { + Text(l.sessionKeyExpiredBadge) + .font(.caption2).foregroundStyle(.orange) + } else if configured { + Text(l.sessionKeySaved) + .font(.caption2).foregroundStyle(.green) + } + } + // The email names the browser login to copy the key from; the folder tells two logins apart. + if title != folder { + Text(folder).font(.caption2).foregroundStyle(.tertiary) + } + } + Spacer() + } + groupRow { + SecureField(configured ? "••••••••" : "sk-ant-sid…", text: input) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 220) + .onSubmit { submitAccountSessionKey(store, rootPath: rootPath) } + Button { + submitAccountSessionKey(store, rootPath: rootPath) + } label: { + if store.validatingAccountSessionKeyPath == rootPath { + ProgressView().controlSize(.small) + } else { + Text(l.save) + } + } + .disabled(input.wrappedValue.isEmpty || store.validatingAccountSessionKeyPath != nil) + if configured { + Button(l.delete) { + accountSessionKeyInputs[rootPath] = nil + store.clearAccountSessionKey(for: rootPath) + } + } + Spacer() + } + if let error = store.accountSessionKeyError(for: rootPath) { + Text(error) + .font(.caption2).foregroundStyle(.orange) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 12).padding(.bottom, 6) + } + } + + private func submitAccountSessionKey(_ store: UsageStore, rootPath: String) { + guard let pasted = accountSessionKeyInputs[rootPath], !pasted.isEmpty else { return } + Task { + await store.saveAccountSessionKey(pasted, for: rootPath) + if store.accountSessionKeyError(for: rootPath) == nil { accountSessionKeyInputs[rootPath] = nil } + } + } + private func submitSessionKey(_ store: UsageStore) { let pasted = sessionKeyInput guard !pasted.isEmpty else { return } @@ -585,6 +672,7 @@ struct SettingsView: View { sessionKeyRows(store) // 재시작 후엔 후보 목록이 비어 있어 조직을 바꿀 수 없다 — 열 때 한 번 채운다. .task { await store.refreshSessionOrganizations() } + accountSessionKeyRows(store) Divider() groupRow { VStack(alignment: .leading, spacing: 1) { diff --git a/Tests/PokeTokenBarTests/AccountSessionKeyTests.swift b/Tests/PokeTokenBarTests/AccountSessionKeyTests.swift new file mode 100644 index 000000000..9f6d4cacc --- /dev/null +++ b/Tests/PokeTokenBarTests/AccountSessionKeyTests.swift @@ -0,0 +1,319 @@ +import XCTest +@testable import PokeTokenBar + +// A claude.ai session key per additional Claude folder. Without one, a folder's limits come from its +// Keychain token, which automatic polls never read, so they stopped at every token expiry. + +// MARK: Stubs + +private let validKey = "sk-ant-sid01-" + String(repeating: "a", count: 40) + +private let usageJSON = #"{"five_hour":{"utilization":42,"resets_at":null},"seven_day":{"utilization":10,"resets_at":null}}"# + +private func ok(_ json: String) -> SessionKeyHTTPResponse { + SessionKeyHTTPResponse(status: 200, data: Data(json.utf8), retryAfter: nil) +} + +private actor StubHTTP: SessionKeyHTTPClient { + private let responses: [String: SessionKeyHTTPResponse] + init(_ responses: [String: SessionKeyHTTPResponse]) { self.responses = responses } + func get(_ url: URL, sessionKey: String) async throws -> SessionKeyHTTPResponse { + responses[url.path] ?? SessionKeyHTTPResponse(status: 404, data: Data(), retryAfter: nil) + } +} + +private func status(fiveHour: Double, email: String? = nil) -> LimitStatus { + var status = try! JSONDecoder().decode( + LimitStatus.self, from: Data("{\"five_hour\":{\"utilization\":\(fiveHour),\"resets_at\":null}}".utf8)) + status.accountEmail = email + return status +} + +/// Scripted results, recording the prompt flag of every call. The last result repeats. +private final class ScriptedLimits: ClaudeLimitsProviding, @unchecked Sendable { + nonisolated(unsafe) var results: [Result] + nonisolated(unsafe) var promptFlags: [Bool] = [] + init(_ results: [Result]) { self.results = results } + func fetch(allowKeychainPrompt: Bool) async throws -> LimitStatus { + promptFlags.append(allowKeychainPrompt) + let result = results.count > 1 ? results.removeFirst() : results[0] + return try result.get() + } +} + +private final class AccountKeys: SessionKeyManaging, @unchecked Sendable { + nonisolated(unsafe) var stored: SessionKeyCredential? + let listed: [SessionKeyOrganization] + init(stored: SessionKeyCredential? = nil, organizations: [SessionKeyOrganization] = []) { + self.stored = stored + self.listed = organizations + } + func credential() -> SessionKeyCredential? { stored } + func organizations(sessionKey: String) async throws -> [SessionKeyOrganization] { listed } + func save(key: String, organizationID: String?) throws { + stored = SessionKeyCredential(key: key, organizationID: organizationID) + } + func clear() { stored = nil } +} + +private func organization(_ id: String, hasUsageData: Bool) -> SessionKeyOrganization { + SessionKeyOrganization(id: id, name: id, hasUsageData: hasUsageData, limits: LimitStatus()) +} + +private final class NilDailyProvider: UsageProvider, @unchecked Sendable { + let id = "claude_code" + let displayName = "Claude Code" + let reportsCost = true + func fetchDaily() async throws -> DailyUsage? { nil } + func fetchEnrichment() async -> ProviderEnrichment { ProviderEnrichment() } +} + +private struct NoStatuses: ProviderStatusProviding { + func fetch() async -> [String: ProviderStatus] { [:] } +} + +private struct NoCodex: CodexLimitsProviding { + func fetch() async throws -> CodexRateLimitStatus? { nil } +} + +private struct NoAntigravity: AntigravityLimitsProviding { + func fetch(allowKeychainPrompt: Bool) async throws -> AntigravityRateLimitStatus { + throw LimitsError.keychainInteractionNotAllowed + } +} + +// MARK: Key file and limits chain + +final class AccountSessionKeyChainTests: XCTestCase { + private var directory: URL! + private let root = URL(fileURLWithPath: "/Users/example/.claude-personal", isDirectory: true) + + override func setUpWithError() throws { + directory = FileManager.default.temporaryDirectory + .appendingPathComponent("ptb-account-keys-\(UUID().uuidString)", isDirectory: true) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: directory) + } + + private func saveKey(organizationID: String) throws { + try SessionKeyStore.forConfigRoot(root, directory: directory) + .save(SessionKeyCredential(key: validKey, organizationID: organizationID)) + } + + func testEachFolderHasItsOwnKeyFileNextToTheDefaultOne() { + let store = SessionKeyStore.forConfigRoot(root, directory: directory) + XCTAssertEqual(store.fileURL.lastPathComponent, "session-key-\(ClaudeAccountRoots.pathKey(for: root)).json") + XCTAssertEqual(store.fileURL.deletingLastPathComponent().path, directory.path) + let work = SessionKeyStore.forConfigRoot(URL(fileURLWithPath: "/Users/example/.claude-work"), directory: directory) + XCTAssertNotEqual(work.fileURL, store.fileURL) + } + + /// The point of the key: an automatic poll, which never reads the Keychain, still loads the account. + func testAnAutomaticPollUsesTheFolderKeyInsteadOfItsToken() async throws { + try saveKey(organizationID: "org-personal") + let token = ScriptedLimits([.failure(.keychainInteractionNotAllowed)]) + let chain = ChainedLimitsProvider.forConfigRoot( + root, sessionKeyDirectory: directory, + http: StubHTTP(["/api/organizations/org-personal/usage": ok(usageJSON)]), fallback: token) + + let status = try await chain.fetch(allowKeychainPrompt: false) + XCTAssertEqual(status.fiveHour?.utilization, 42) + XCTAssertEqual(token.promptFlags, [], "the folder's token is not needed") + } + + func testWithoutAKeyTheFolderTokenWorksAsBefore() async throws { + let token = ScriptedLimits([.success(status(fiveHour: 5))]) + let chain = ChainedLimitsProvider.forConfigRoot( + root, sessionKeyDirectory: directory, http: StubHTTP([:]), fallback: token) + + let status = try await chain.fetch(allowKeychainPrompt: true) + XCTAssertEqual(status.fiveHour?.utilization, 5) + XCTAssertEqual(token.promptFlags, [true], "a manual refresh may still read the Keychain") + } + + /// A dead key is fixed in Settings: even a manual refresh must not fall back to a Keychain prompt. + func testADeadKeyNeverFallsBackToAKeychainPrompt() async throws { + try saveKey(organizationID: "org-personal") + let token = ScriptedLimits([.failure(.keychainInteractionNotAllowed)]) + let chain = ChainedLimitsProvider.forConfigRoot( + root, sessionKeyDirectory: directory, + http: StubHTTP([ + "/api/organizations/org-personal/usage": SessionKeyHTTPResponse(status: 401, data: Data(), retryAfter: nil), + ]), + fallback: token) + + do { + _ = try await chain.fetch(allowKeychainPrompt: true) + XCTFail("a dead key must fail") + } catch { + XCTAssertEqual(error as? LimitsError, .sessionKeyInvalid) + } + XCTAssertEqual(token.promptFlags, [false]) + } + + func testTheFolderOrganizationWinsThenTheDefaultRule() { + let personal = organization("org-personal", hasUsageData: true) + let team = organization("org-team", hasUsageData: false) + XCTAssertEqual(UsageStore.accountOrganization([personal, team], loggedIn: "org-team")?.id, "org-team") + XCTAssertEqual(UsageStore.accountOrganization([team, personal], loggedIn: "org-gone")?.id, "org-personal", + "an unknown login falls back to the organization with usage data") + XCTAssertEqual(UsageStore.accountOrganization([team], loggedIn: nil)?.id, "org-team") + XCTAssertNil(UsageStore.accountOrganization([], loggedIn: "org-team")) + } +} + +// MARK: Store pipeline + +@MainActor +final class AccountSessionKeyStoreTests: XCTestCase { + nonisolated(unsafe) private var testDefaults: UserDefaults! + nonisolated(unsafe) private var suiteName: String! + nonisolated(unsafe) private var base: URL! + + override func setUpWithError() throws { + suiteName = "ptb-account-keys-\(UUID().uuidString)" + testDefaults = UserDefaults(suiteName: suiteName) + KeychainAccessGate.isDisabled = false + base = FileManager.default.temporaryDirectory + .appendingPathComponent("ptb-account-keys-store-\(UUID().uuidString)", isDirectory: true) + for folder in ["work", "personal"] { + try FileManager.default.createDirectory( + at: base.appendingPathComponent(folder), withIntermediateDirectories: true) + } + } + + override func tearDownWithError() throws { + KeychainAccessGate.isDisabled = false + testDefaults.removePersistentDomain(forName: suiteName) + try? FileManager.default.removeItem(at: base) + } + + private var work: String { base.appendingPathComponent("work").standardizedFileURL.path } + private var personal: String { base.appendingPathComponent("personal").standardizedFileURL.path } + + private func makeStore(folders: [String: ScriptedLimits], keys: [String: AccountKeys]) -> UsageStore { + testDefaults.set(folders.keys.sorted(by: >).joined(separator: ","), forKey: ClaudeAccountRoots.defaultsKey) + return UsageStore( + providers: [NilDailyProvider()], + claudeLimitsProvider: ScriptedLimits([.success(status(fiveHour: 10, email: "main@example.com"))]), + additionalClaudeLimitsProvider: { folders[$0.path]! }, + codexLimitsProvider: NoCodex(), + antigravityLimitsProvider: NoAntigravity(), + statusProvider: NoStatuses(), + sessionKeys: AccountKeys(), + accountSessionKeys: { keys[$0.path] ?? AccountKeys() }, + autoRefresh: false, + defaults: testDefaults) + } + + /// The key sees the personal and the team organization: the folder's own login decides, + /// even against the default rule (the one with usage data). + func testSavingAKeyPicksTheFolderOrganizationAndShowsTheAccount() async throws { + try Data(#"{"oauthAccount":{"emailAddress":"me@example.com","organizationUuid":"org-team"}}"#.utf8) + .write(to: URL(fileURLWithPath: personal).appendingPathComponent(".claude.json")) + let keys = AccountKeys(organizations: [organization("org-personal", hasUsageData: true), + organization("org-team", hasUsageData: false)]) + let store = makeStore(folders: [personal: ScriptedLimits([.success(status(fiveHour: 42))])], + keys: [personal: keys]) + + await store.saveAccountSessionKey(" \(validKey)\n", for: personal) + + XCTAssertNil(store.accountSessionKeyError(for: personal)) + XCTAssertEqual(keys.stored, SessionKeyCredential(key: validKey, organizationID: "org-team")) + XCTAssertEqual(store.accountSessionKeyPaths, [personal]) + XCTAssertEqual(store.additionalLimits.map(\.status.fiveHour?.utilization), [42], "shown at once") + } + + func testAMalformedKeyIsRejectedAndNothingIsSaved() async { + let keys = AccountKeys(organizations: [organization("org-personal", hasUsageData: true)]) + let store = makeStore(folders: [personal: ScriptedLimits([.success(status(fiveHour: 42))])], + keys: [personal: keys]) + + await store.saveAccountSessionKey("not-a-key", for: personal) + + XCTAssertNotNil(store.accountSessionKeyError(for: personal)) + XCTAssertNil(keys.stored) + XCTAssertTrue(store.accountSessionKeyPaths.isEmpty) + } + + /// A rejected key keeps the tab with its last values and points to Settings; a rejected token + /// (the folder's Claude Code login) keeps the Claude Code advice. + func testARejectedKeyExpiresTheTabUntilAnotherCauseReplacesIt() async { + let folder = ScriptedLimits([ + .success(status(fiveHour: 20, email: "me@example.com")), + .failure(.sessionKeyInvalid), + .failure(.rateLimited(retryAfter: nil)), + .failure(.httpStatus(401)), + ]) + let store = makeStore(folders: [personal: folder], + keys: [personal: AccountKeys(stored: SessionKeyCredential(key: validKey))]) + func account() -> ClaudeAccountLimits? { store.claudeAccounts.first { !$0.isDefault } } + + await store.refresh(scheduleEmptyRetry: false) + XCTAssertEqual(account()?.isExpired, false) + + await store.refresh(scheduleEmptyRetry: false) + XCTAssertEqual(account()?.isExpired, true) + XCTAssertEqual(account()?.sessionKeyExpired, true) + XCTAssertEqual(account()?.status.fiveHour?.utilization, 20, "last values kept") + + await store.refreshLimitTokenFromKeychain() // bypasses the 429 backoff + XCTAssertEqual(account()?.sessionKeyExpired, true, "another failure keeps the known cause") + + await store.refreshLimitTokenFromKeychain() + XCTAssertEqual(account()?.isExpired, true) + XCTAssertEqual(account()?.sessionKeyExpired, false, "a token rejection is not the key's") + } + + func testClearingAKeyDropsItsExpiry() async { + let keys = AccountKeys(stored: SessionKeyCredential(key: validKey)) + let folder = ScriptedLimits([.success(status(fiveHour: 20)), .failure(.sessionKeyInvalid)]) + let store = makeStore(folders: [personal: folder], keys: [personal: keys]) + await store.refresh(scheduleEmptyRetry: false) + await store.refresh(scheduleEmptyRetry: false) + XCTAssertEqual(store.additionalLimits.first?.sessionKeyExpired, true) + + store.clearAccountSessionKey(for: personal) + + XCTAssertNil(keys.stored) + XCTAssertTrue(store.accountSessionKeyPaths.isEmpty) + XCTAssertEqual(store.additionalLimits.first?.isExpired, false) + XCTAssertEqual(store.additionalLimits.first?.sessionKeyExpired, false) + } + + /// Only the key's own expiry goes with it: a rejected folder token still needs Claude Code. + func testClearingAKeyKeepsATokenExpiry() async { + let keys = AccountKeys(stored: SessionKeyCredential(key: validKey)) + let folder = ScriptedLimits([.success(status(fiveHour: 20)), .failure(.httpStatus(401))]) + let store = makeStore(folders: [personal: folder], keys: [personal: keys]) + await store.refresh(scheduleEmptyRetry: false) + await store.refresh(scheduleEmptyRetry: false) + XCTAssertEqual(store.additionalLimits.first?.isExpired, true) + + store.clearAccountSessionKey(for: personal) + + XCTAssertEqual(store.additionalLimits.first?.isExpired, true) + } + + /// Like the default key, a folder's own key needs no Keychain: turning the Keychain off keeps + /// that account, while a folder without a key is cleared and never asked for its token. + func testAKeyedFolderStillLoadsWithTheKeychainOff() async { + let workFolder = ScriptedLimits([.success(status(fiveHour: 20, email: "work@example.com"))]) + let personalFolder = ScriptedLimits([.success(status(fiveHour: 30, email: "personal@example.com"))]) + let store = makeStore(folders: [work: workFolder, personal: personalFolder], + keys: [personal: AccountKeys(stored: SessionKeyCredential(key: validKey))]) + await store.refresh(scheduleEmptyRetry: false) + XCTAssertEqual(store.additionalLimits.map(\.rootPath), [work, personal]) + + store.disableKeychainAccess = true + XCTAssertEqual(store.additionalLimits.map(\.rootPath), [personal], "kept at once") + + await store.refresh(scheduleEmptyRetry: false) + XCTAssertEqual(store.additionalLimits.map(\.rootPath), [personal]) + XCTAssertEqual(workFolder.promptFlags, [false], "the folder without a key is not fetched again") + XCTAssertEqual(store.additionalClaudeConfigRoots, [work, personal], + "both folders keep their key row in Settings") + } +} diff --git a/Tests/PokeTokenBarTests/LocalizationInterpolationTests.swift b/Tests/PokeTokenBarTests/LocalizationInterpolationTests.swift index 7e621d9ca..4232f0716 100644 --- a/Tests/PokeTokenBarTests/LocalizationInterpolationTests.swift +++ b/Tests/PokeTokenBarTests/LocalizationInterpolationTests.swift @@ -51,6 +51,7 @@ final class LocalizationInterpolationTests: XCTestCase { expect(lang, "additionalClaudeAccountsFound", l.additionalClaudeAccountsFound(73), "73") expect(lang, "additionalClaudeAccountsDetected", l.additionalClaudeAccountsDetected(a), a) expect(lang, "additionalAccountExpiredHint", l.additionalAccountExpiredHint(a), a) + expect(lang, "accountSessionKeyLabel", l.accountSessionKeyLabel(a), a) expect(lang, "unattributedClaudeUsage", l.unattributedClaudeUsage(a), a) expect(lang, "trackedAccountToolTip", l.trackedAccountToolTip(a), a) expect(lang, "codexWindow(h)", l.codexWindow(420), "7") // 420 min → 7 h / 420분 → 7시간 From 68deb094c41023f223880f2f9762d349ac9e498c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=ED=8B=B0=EC=8A=A4=28otis=2Esh=29?= Date: Fri, 25 Sep 2026 11:22:28 +0900 Subject: [PATCH 08/15] feat(popover): color quota bars by pace (6 tiers) (#349) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(popover): color quota bars by pace (6 tiers) Quota bars and their percentage now take one of six colors from how far usage runs ahead of or behind the pace marker (used% - elapsed%, in points), with a small text line under the bar naming the tier and the gap. - Same boundaries for every window: < -25 / -10 / +5 / +15 / +30. - At or above the critical threshold the row is always the top tier. - Held for the first 10% of a window, and rows without a pace keep the absolute warn/crit colors. - Alerts, menu bar and floating pet still use absolute thresholds. * fix(popover): keep the yellow tier's percentage readable and stop repeating "pace" - The slightly-fast tier's yellow is hard to read as text on a light background, so its percentage uses the primary text color; the bar, dot and label still carry the tier. - The European delta strings repeated the tier name's "pace" ("Under pace · 15 pts under pace"); they now say how much more or less was used. * refactor(popover): drop the tier text line, keep the tier in the tooltip The row is now colored only; the tier name and delta move into the existing pace tooltip so the information stays available without adding a line under every bar. --- Sources/PokeTokenBar/Core/Localization.swift | 29 +++++++ Sources/PokeTokenBar/Core/PaceTier.swift | 32 ++++++++ Sources/PokeTokenBar/UI/PopoverView.swift | 40 ++++++++-- .../LocalizationInterpolationTests.swift | 2 + Tests/PokeTokenBarTests/PaceTierTests.swift | 75 +++++++++++++++++++ 5 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 Sources/PokeTokenBar/Core/PaceTier.swift create mode 100644 Tests/PokeTokenBarTests/PaceTierTests.swift diff --git a/Sources/PokeTokenBar/Core/Localization.swift b/Sources/PokeTokenBar/Core/Localization.swift index 402996fa1..3b7829098 100644 --- a/Sources/PokeTokenBar/Core/Localization.swift +++ b/Sources/PokeTokenBar/Core/Localization.swift @@ -70,6 +70,35 @@ struct L { "Ritmo — um consumo uniforme nesta janela estaria em \(percent) agora.", "Tempo – bei gleichmäßigem Verbrauch in diesem Fenster wären es jetzt \(percent).") } + /// 페이스 대비 단계 이름. paceHint 와 같은 이유로 "적정"처럼 규범적인 말은 피하고 + /// 소진 속도만 묘사한다. + func paceTier(_ tier: PaceTier) -> String { + switch tier { + case .wayUnder: return t("여유 많음", "Well under pace", "かなり余裕", "Muy por debajo del ritmo", "Bien sous le rythme", "Bem abaixo do ritmo", "Deutlich unter Tempo") + case .under: return t("여유", "Under pace", "余裕あり", "Por debajo del ritmo", "Sous le rythme", "Abaixo do ritmo", "Unter Tempo") + case .onPace: return t("페이스대로", "On pace", "ペース通り", "Al ritmo", "Dans le rythme", "No ritmo", "Im Tempo") + case .slightlyOver: return t("조금 빠름", "Slightly fast", "やや速い", "Algo rápido", "Un peu rapide", "Um pouco rápido", "Etwas schnell") + case .over: return t("빠름", "Fast", "速い", "Rápido", "Rapide", "Rápido", "Schnell") + case .wayOver: return t("매우 빠름", "Very fast", "とても速い", "Muy rápido", "Très rapide", "Muito rápido", "Sehr schnell") + } + } + /// 페이스 대비 차이(%p). **쓴 양**으로 말한다 — "앞섬/위" 같은 위치 표현은 잔량 모드에서 + /// 채움이 마커보다 짧게 그려질 때 방향이 반대로 읽힌다(#286 부류). 0 이면 문구 없음. + /// 유럽어는 단계 이름("Under pace")이 이미 "페이스"를 말하므로 여기선 반복하지 않는다. + func paceDelta(_ points: Int) -> String? { + if points > 0 { + return t("페이스보다 \(points)%p 더 씀", "\(points) pts more used", "ペースより\(points)pt多く使用", + "\(points) pts más de uso", "\(points) pts de plus utilisés", + "\(points) p.p. a mais de uso", "\(points) Pkt. mehr verbraucht") + } + if points < 0 { + let n = -points + return t("페이스보다 \(n)%p 덜 씀", "\(n) pts less used", "ペースより\(n)pt少なく使用", + "\(n) pts menos de uso", "\(n) pts de moins utilisés", + "\(n) p.p. a menos de uso", "\(n) Pkt. weniger verbraucht") + } + return nil + } var limitReached: String { t("한도 도달", "Limit reached", "上限到達", "Límite alcanzado", "Limite atteinte", "Limite atingido", "Limit erreicht") } var personalSpendLimit: String { t("개인 사용 한도", "Personal spend limit", "個人利用上限", "Límite de gasto personal", "Limite de dépense personnelle", "Limite de gasto pessoal", "Persönliches Ausgabenlimit") } var staleLimits: String { t("갱신 지연", "Stale", "更新遅延", "Desactualizado", "Périmé", "Desatualizado", "Nicht aktuell") } diff --git a/Sources/PokeTokenBar/Core/PaceTier.swift b/Sources/PokeTokenBar/Core/PaceTier.swift new file mode 100644 index 000000000..e78926a75 --- /dev/null +++ b/Sources/PokeTokenBar/Core/PaceTier.swift @@ -0,0 +1,32 @@ +import Foundation + +/// 한도 게이지의 페이스 대비 6단계. 기준값은 delta = 사용률 − 페이스(%p) — 창 길이에 이미 +/// 정규화된 값이라 5시간·주간 창이 같은 경계를 쓴다. +/// 판정은 **실제 사용률** 기준이라 표시 모드(used/remaining)와 무관하다 — `limitColor` 와 같은 규칙. +/// 경고 알림·메뉴바·플로팅 펫은 여전히 절대 임계로 판정한다. 이건 팝오버 행의 색만 정한다. +enum PaceTier: Int, CaseIterable { + case wayUnder, under, onPace, slightlyOver, over, wayOver + + /// 창의 이 비율이 지나기 전엔 단계를 보류한다(5시간 창 30분, 주간 창 약 17시간). + /// 초반엔 페이스가 0 에 가까워 요청 하나로도 delta 가 몇 단계씩 튄다. + static let minimumPace = 0.1 + + /// nil 이면 호출부가 절대 임계색(`limitColor`)으로 돌아간다 — 페이스가 없거나 보류 중인 행. + static func tier(utilization: Double, pace: Double?, critThreshold: Double) -> PaceTier? { + guard let pace, pace >= minimumPace else { return nil } + if utilization >= critThreshold { return .wayOver } + // 반올림한 delta 로 판정해 툴팁에 찍히는 정수와 단계가 경계에서 어긋나지 않게 한다. + switch roundedDelta(utilization: utilization, pace: pace) { + case ..<(-25): return .wayUnder + case ..<(-10): return .under + case ..<5: return .onPace + case ..<15: return .slightlyOver + case ..<30: return .over + default: return .wayOver + } + } + + static func roundedDelta(utilization: Double, pace: Double) -> Int { + Int((utilization - pace * 100).rounded()) + } +} diff --git a/Sources/PokeTokenBar/UI/PopoverView.swift b/Sources/PokeTokenBar/UI/PopoverView.swift index 9b9b45e07..54af08d34 100644 --- a/Sources/PokeTokenBar/UI/PopoverView.swift +++ b/Sources/PokeTokenBar/UI/PopoverView.swift @@ -589,9 +589,16 @@ struct PopoverView: View { } /// 툴팁 문구. 숫자도 `limitDisplayPercent` 를 거쳐 눈금 위치와 같은 방향을 말한다. - private func paceHelp(_ pace: Double?) -> String? { + /// 단계가 있으면 이름과 차이를 첫 줄에 둔다 — 행에는 색만 있으므로 글로 된 단계는 여기서 준다. + private func paceHelp(_ pace: Double?, tier: PaceTier?, utilization: Double) -> String? { guard let pace else { return nil } - return l.paceHint(TokenFormatter.percent(store.limitDisplayPercent(pace * 100))) + let hint = l.paceHint(TokenFormatter.percent(store.limitDisplayPercent(pace * 100))) + guard let tier else { return hint } + var head = l.paceTier(tier) + if let delta = l.paceDelta(PaceTier.roundedDelta(utilization: utilization, pace: pace)) { + head += " · " + delta + } + return head + "\n" + hint } @ViewBuilder @@ -610,6 +617,10 @@ struct PopoverView: View { span: TimeInterval? = nil, detail: String? = nil, idleHint: String? = nil) -> some View { let pace = paceFraction(reset: reset, span: span) + // 페이스가 있으면 페이스 대비 단계색, 없거나 창 초반 보류 중이면 기존 절대 임계색. + let tier = PaceTier.tier(utilization: utilization, pace: pace, critThreshold: store.critThreshold) + let tint = tier?.color ?? limitColor(utilization) + let percentTint = tier?.percentColor ?? limitColor(utilization) return VStack(alignment: .leading, spacing: 2) { HStack { Text(name).font(.callout) @@ -632,11 +643,11 @@ struct PopoverView: View { Text(limitPercentText(utilization)) .font(.callout) .monospacedDigit() - .foregroundStyle(limitColor(utilization)) + .foregroundStyle(percentTint) } - LimitProgressBar(usedPercent: utilization, tint: limitColor(utilization), pace: pace) + LimitProgressBar(usedPercent: utilization, tint: tint, pace: pace) } - .helpIfPresent(paceHelp(pace)) + .helpIfPresent(paceHelp(pace, tier: tier, utilization: utilization)) } @ViewBuilder @@ -1326,3 +1337,22 @@ struct LimitProgressBar: View { private static let markerOverhang: CGFloat = 2 private static let markerHeight: CGFloat = trackHeight + markerOverhang * 2 } + +/// 단계색은 시스템 색 — 라이트/다크에서 각각 조정된 값으로 바뀌고, 최상위(.red)는 +/// `limitColor` 의 crit 색과 같아 "crit 이면 항상 빨강"이 두 경로에서 같은 색으로 보인다. +extension PaceTier { + var color: Color { + switch self { + case .wayUnder: return .blue + case .under: return .teal + case .onPace: return .green + case .slightlyOver: return .yellow + case .over: return .orange + case .wayOver: return .red + } + } + + /// % 숫자색. 노랑 글자는 라이트 모드 배경에서 거의 안 읽혀 그 단계만 기본 글자색으로 둔다 — + /// 단계는 채움 색과 툴팁이 여전히 말해 준다. + var percentColor: Color { self == .slightlyOver ? .primary : color } +} diff --git a/Tests/PokeTokenBarTests/LocalizationInterpolationTests.swift b/Tests/PokeTokenBarTests/LocalizationInterpolationTests.swift index 4232f0716..3fa736e54 100644 --- a/Tests/PokeTokenBarTests/LocalizationInterpolationTests.swift +++ b/Tests/PokeTokenBarTests/LocalizationInterpolationTests.swift @@ -58,6 +58,8 @@ final class LocalizationInterpolationTests: XCTestCase { expect(lang, "codexWindow(m)", l.codexWindow(37), "37") expect(lang, "percentRemaining", l.percentRemaining(a), a) expect(lang, "paceHint", l.paceHint(a), a) + expect(lang, "paceDelta(+)", l.paceDelta(4242) ?? "", "4242") + expect(lang, "paceDelta(-)", l.paceDelta(-4242) ?? "", "4242") expect(lang, "limitRefreshHTTPError(401)", l.limitRefreshHTTPError(401), "401") expect(lang, "limitRefreshHTTPError(404)", l.limitRefreshHTTPError(404), "404") diff --git a/Tests/PokeTokenBarTests/PaceTierTests.swift b/Tests/PokeTokenBarTests/PaceTierTests.swift new file mode 100644 index 000000000..98fb63fe2 --- /dev/null +++ b/Tests/PokeTokenBarTests/PaceTierTests.swift @@ -0,0 +1,75 @@ +import XCTest +@testable import PokeTokenBar + +/// 페이스 대비 6단계 판정 — 경계값 양쪽, crit 우선, 페이스 없음/창 초반 보류. +/// pace 0.5 를 기준으로 삼는다: 0.5 × 100 = 50 이 정확히 표현돼 delta 가 부동소수 오차 없이 떨어진다. +final class PaceTierTests: XCTestCase { + private let crit = 95.0 + + private func tier(_ utilization: Double, pace: Double? = 0.5) -> PaceTier? { + PaceTier.tier(utilization: utilization, pace: pace, critThreshold: crit) + } + + func testBoundariesOnBothSides() { + let cases: [(Double, PaceTier)] = [ + (0, .wayUnder), + (24, .wayUnder), // delta −26 + (25, .under), // −25 + (39, .under), // −11 + (40, .onPace), // −10 + (54, .onPace), // +4 + (55, .slightlyOver), // +5 + (64, .slightlyOver), // +14 + (65, .over), // +15 + (79, .over), // +29 + (80, .wayOver), // +30 + ] + for (utilization, expected) in cases { + XCTAssertEqual(tier(utilization), expected, "utilization=\(utilization)") + } + } + + /// 판정은 반올림한 delta 로 한다 — 툴팁에 찍히는 정수와 단계가 경계에서 어긋나지 않게. + func testTierFollowsRoundedDelta() { + XCTAssertEqual(tier(54.6), .slightlyOver) // +4.6 → "5 pts over" 이므로 조금 빠름 + XCTAssertEqual(tier(54.4), .onPace) + XCTAssertEqual(PaceTier.roundedDelta(utilization: 54.6, pace: 0.5), 5) + XCTAssertEqual(PaceTier.roundedDelta(utilization: 38.5, pace: 0.5), -12) + } + + /// delta 가 작아도(여기선 여유 쪽) crit 이상이면 최상위 단계. + func testCritOverridesPace() { + XCTAssertEqual(tier(95, pace: 0.99), .wayOver) + XCTAssertEqual(tier(94, pace: 0.99), .onPace) + } + + func testNoPaceMeansNoTier() { + XCTAssertNil(tier(50, pace: nil)) + } + + /// 창 초반엔 한 번의 요청으로도 delta 가 크게 흔들리므로 보류 — 호출부가 절대 임계색으로 돌아간다. + func testHeldEarlyInWindow() { + XCTAssertNil(tier(10, pace: 0.02)) + XCTAssertNil(tier(99, pace: PaceTier.minimumPace - 0.001)) // crit 도 보류 — limitColor 가 빨강을 준다 + XCTAssertEqual(tier(10, pace: PaceTier.minimumPace), .onPace) + } + + /// 차이 문구는 부호 대신 "더/덜"로 방향을 말한다 — 음수 기호가 새어 나오면 "−12 pts under" 같은 이중 부정. + /// 0 이면 단계 이름("페이스대로")만으로 충분해 문구를 내지 않는다. + func testDeltaTextCarriesNoSignAndSkipsZero() { + for lang in AppLanguage.allCases { + let l = L(lang) + XCTAssertNil(l.paceDelta(0), "\(lang)") + XCTAssertNotEqual(l.paceDelta(12), l.paceDelta(-12), "\(lang)") + XCTAssertFalse(l.paceDelta(-12)?.contains("-12") ?? true, "\(lang)") + } + } + + /// 여섯 단계 이름이 언어마다 모두 달라야 색 없이 글만으로도 단계가 구분된다. + func testTierLabelsDistinctInEveryLanguage() { + for lang in AppLanguage.allCases { + let labels = PaceTier.allCases.map { L(lang).paceTier($0) } + XCTAssertEqual(Set(labels).count, PaceTier.allCases.count, "\(lang): \(labels)") + } + } +} From fe51577e0bdf262587b98beeb784baba19821ee8 Mon Sep 17 00:00:00 2001 From: "Taeyang Jin (Theo)" Date: Fri, 25 Sep 2026 11:23:20 +0900 Subject: [PATCH 09/15] fix: show the real shiny odds in hatch notifications (#351) The shiny hatch notification always said "(1/64)", even with the Shiny Charm, where hatches roll at 1/48. The number was a literal in every language while the roll used ShinyCharm.shinyDenominator. - Add CompanionStore.shinyDenominator(charmOwned:) as the single source for the roll and the copy, and pass the value the hatch used into notifShinyHatchBody(_:odds:). - Drop the odds from the shiny Ditto reveal. Shininess is rolled at hatch, and the charm state at that time is not saved, so the reveal cannot know which odds applied. - Record the class in the defect log. Co-authored-by: Claude Opus 5.5 (1M context) --- .../PokeTokenBar/Core/CompanionStore.swift | 18 +++++++++++---- Sources/PokeTokenBar/Core/Localization.swift | 6 +++-- .../LocalizationInterpolationTests.swift | 2 +- Tests/PokeTokenBarTests/ShinyCharmTests.swift | 22 +++++++++++++++++++ docs/reference/defect-log.md | 12 ++++++++++ 5 files changed, 53 insertions(+), 7 deletions(-) diff --git a/Sources/PokeTokenBar/Core/CompanionStore.swift b/Sources/PokeTokenBar/Core/CompanionStore.swift index a3ad8fc74..7aa603977 100644 --- a/Sources/PokeTokenBar/Core/CompanionStore.swift +++ b/Sources/PokeTokenBar/Core/CompanionStore.swift @@ -1272,9 +1272,17 @@ final class CompanionStore { rarity == .common && totalForms >= 2 && roll % PokemonOdds.dittoDisguiseDenominator == 0 } - /// 이로치 부화 판정(순수) — 미리 뽑은 roll 값 % 분모(부적 보유 48, 없으면 64)==0. (부수효과 없이 xctest) + /// 이로치 부화 분모 — 부적 보유 48, 없으면 64. 판정과 알림 문구가 같은 값을 쓰도록 여기가 단일 소스다. + nonisolated static func shinyDenominator(charmOwned: Bool) -> UInt64 { + charmOwned ? ShinyCharm.shinyDenominator : PokemonOdds.shinyDenominator + } + + /// 지금 부화하면 적용될 이로치 분모. + var shinyDenominator: UInt64 { Self.shinyDenominator(charmOwned: ownsShinyCharm) } + + /// 이로치 부화 판정(순수) — 미리 뽑은 roll 값 % 분모==0. (부수효과 없이 xctest) nonisolated static func rollsShiny(roll: UInt64, charmOwned: Bool) -> Bool { - roll % (charmOwned ? ShinyCharm.shinyDenominator : PokemonOdds.shinyDenominator) == 0 + roll % shinyDenominator(charmOwned: charmOwned) == 0 } /// 실제 부화 로직 — isHatching 락은 호출자(hatch / hatchIfNeeded)가 소유·해제한다. @@ -1317,7 +1325,9 @@ final class CompanionStore { let overflow = max(0, state.eggUsage - eggHatchThreshold) state.eggUsage = 0 state.eggTier = nil // 보증은 이 부화로 소비된다(다음 알은 다시 무보증) - // 개체 롤 — shiny(1/64)·성격(25종)은 부화 순간 확정, 진화해도 유지. + // 개체 롤 — shiny(1/64, 부적 1/48)·성격(25종)은 부화 순간 확정, 진화해도 유지. + // 알림 문구는 이 판정과 같은 분모를 보여준다. + let shinyOdds = shinyDenominator let isShiny = Self.rollsShiny(roll: rng.next(), charmOwned: ownsShinyCharm) let nature = PokemonNature.allCases[Int(rng.next() % UInt64(PokemonNature.allCases.count))] // 메타몽 위장 롤 — common·≥2형태에 한해 1/128. .app 게이트(&& 단락 → 비앱에선 rng 미소비로 @@ -1344,7 +1354,7 @@ final class CompanionStore { let name = UnownForm.displayName(line.localizedName(line.baseID, state.language), speciesID: line.baseID, form: unownForm) notifyCompanionEvent(showShiny ? l.notifShinyHatchTitle : l.notifHatchTitle, - showShiny ? l.notifShinyHatchBody(name) : l.notifHatchBody(name)) + showShiny ? l.notifShinyHatchBody(name, odds: shinyOdds) : l.notifHatchBody(name)) justEvolvedTo = nil // 새 부화는 "성장" 문구(진화 아님) — 직전 진화명이 남아 표시되지 않게 displayState = .levelUp eventUntil = clock().addingTimeInterval(4) diff --git a/Sources/PokeTokenBar/Core/Localization.swift b/Sources/PokeTokenBar/Core/Localization.swift index 3b7829098..dc8b6ea4f 100644 --- a/Sources/PokeTokenBar/Core/Localization.swift +++ b/Sources/PokeTokenBar/Core/Localization.swift @@ -1074,7 +1074,8 @@ struct L { var notifHatchTitle: String { t("🥚 부화!", "🥚 Hatched!", "🥚 孵化!", "🥚 ¡Eclosionó!", "🥚 Éclosion !", "🥚 Chocou!", "🥚 Geschlüpft!") } func notifHatchBody(_ name: String) -> String { t("알에서 \(name)이(가) 나왔어요!", "\(name) hatched from the egg!", "タマゴから \(name) が生まれました!", "¡\(name) salió del huevo!", "\(name) est sorti de l'œuf !", "\(name) saiu do ovo!", "\(name) ist aus dem Ei geschlüpft!") } var notifShinyHatchTitle: String { t("✨ 이로치 포켓몬!", "✨ Shiny Pokémon!", "✨ 色違いポケモン!", "✨ ¡Pokémon variocolor!", "✨ Pokémon chromatique !", "✨ Pokémon shiny!", "✨ Schillerndes Pokémon!") } - func notifShinyHatchBody(_ name: String) -> String { t("이로치 \(name)이(가) 태어났어요! (1/64)", "A shiny \(name) hatched! (1 in 64)", "色違いの \(name) が生まれました!(1/64)", "¡Nació un \(name) variocolor! (1 entre 64)", "Un \(name) chromatique est né ! (1 sur 64)", "Nasceu um \(name) shiny! (1 em 64)", "Ein schillerndes \(name) ist geschlüpft! (1/64)") } + /// 실제 판정에 쓴 분모를 받는다 — 부적이 있으면 64 가 아니다. + func notifShinyHatchBody(_ name: String, odds: UInt64) -> String { t("이로치 \(name)이(가) 태어났어요! (1/\(odds))", "A shiny \(name) hatched! (1 in \(odds))", "色違いの \(name) が生まれました!(1/\(odds))", "¡Nació un \(name) variocolor! (1 entre \(odds))", "Un \(name) chromatique est né ! (1 sur \(odds))", "Nasceu um \(name) shiny! (1 em \(odds))", "Ein schillerndes \(name) ist geschlüpft! (1/\(odds))") } var eggImminent: String { t("곧 부화해요!", "About to hatch!", "もうすぐ孵化!", "¡Está a punto de eclosionar!", "Sur le point d'éclore !", "Está quase chocando!", "Schlüpft gleich!") } /// 첫 실행(아직 토큰 적립 0) 안내 — "왜 아무 일도 안 일어나지"를 방지. var eggFirstRunHint: String { @@ -1091,7 +1092,8 @@ struct L { var notifDittoRevealTitle: String { t("🎭 어라? 메타몽!", "🎭 Huh? It's Ditto!", "🎭 あれ?メタモン!", "🎭 ¿Eh? ¡Es Ditto!", "🎭 Hein ? C'est Métamorph !", "🎭 Ué? É um Ditto!", "🎭 Huch? Ditto!") } func notifDittoRevealBody(_ disguise: String) -> String { t("\(disguise)인 줄 알았는데 — 사실은 메타몽이었어요!", "You thought it was \(disguise) — it was Ditto all along!", "\(disguise) だと思ってた… 実はメタモンでした!", "Pensabas que era \(disguise) — ¡en realidad era Ditto!", "Tu croyais que c'était \(disguise) — c'était Métamorph depuis le début !", "Você achava que era \(disguise) — era um Ditto o tempo todo!", "Du dachtest, es wäre \(disguise) – dabei war es die ganze Zeit Ditto!") } var notifShinyDittoRevealTitle: String { t("🎭✨ 어라? 이로치 메타몽!", "🎭✨ Huh? A shiny Ditto!", "🎭✨ あれ?色違いメタモン!", "🎭✨ ¿Eh? ¡Un Ditto variocolor!", "🎭✨ Hein ? Un Métamorph chromatique !", "🎭✨ Ué? Um Ditto shiny!", "🎭✨ Huch? Ein schillerndes Ditto!") } - func notifShinyDittoRevealBody(_ disguise: String) -> String { t("\(disguise)인 줄 알았는데 — 이로치 메타몽이었어요! (1/64)", "You thought it was \(disguise) — it was a shiny Ditto! (1 in 64)", "\(disguise) だと思ってた… 色違いのメタモンでした!(1/64)", "Pensabas que era \(disguise) — ¡era un Ditto variocolor! (1 entre 64)", "Tu croyais que c'était \(disguise) — c'était un Métamorph chromatique ! (1 sur 64)", "Você achava que era \(disguise) — era um Ditto shiny! (1 em 64)", "Du dachtest, es wäre \(disguise) – dabei war es ein schillerndes Ditto! (1/64)") } + /// 확률을 적지 않는다 — 이로치는 부화 때 굴렸고, 그때의 부적 보유 여부는 저장되지 않는다. + func notifShinyDittoRevealBody(_ disguise: String) -> String { t("\(disguise)인 줄 알았는데 — 이로치 메타몽이었어요!", "You thought it was \(disguise) — it was a shiny Ditto!", "\(disguise) だと思ってた… 色違いのメタモンでした!", "Pensabas que era \(disguise) — ¡era un Ditto variocolor!", "Tu croyais que c'était \(disguise) — c'était un Métamorph chromatique !", "Você achava que era \(disguise) — era um Ditto shiny!", "Du dachtest, es wäre \(disguise) – dabei war es ein schillerndes Ditto!") } var notifGraduateTitle: String { t("🎓 졸업!", "🎓 Graduated!", "🎓 卒業!", "🎓 ¡Graduado!", "🎓 Diplômé !", "🎓 Formatura!", "🎓 Abschied!") } func notifGraduateBody(_ name: String) -> String { t("\(name) — 도감에 보존! 새 알이 도착했어요.", "\(name) — saved to your Pokédex! A new egg has arrived.", "\(name) — 図鑑に保存!新しいタマゴが届きました。", "\(name) — ¡guardado en tu Pokédex! Ha llegado un nuevo huevo.", "\(name) — conservé dans ton Pokédex ! Un nouvel œuf est arrivé.", "\(name) — guardado na sua Pokédex! Chegou um novo ovo.", "\(name) – in deinem Pokédex gespeichert! Ein neues Ei ist da.") } diff --git a/Tests/PokeTokenBarTests/LocalizationInterpolationTests.swift b/Tests/PokeTokenBarTests/LocalizationInterpolationTests.swift index 3fa736e54..ce968975b 100644 --- a/Tests/PokeTokenBarTests/LocalizationInterpolationTests.swift +++ b/Tests/PokeTokenBarTests/LocalizationInterpolationTests.swift @@ -104,7 +104,7 @@ final class LocalizationInterpolationTests: XCTestCase { // System notifications / 시스템 알림 expect(lang, "notifHatchBody", l.notifHatchBody(a), a) - expect(lang, "notifShinyHatchBody", l.notifShinyHatchBody(a), a) + expect(lang, "notifShinyHatchBody", l.notifShinyHatchBody(a, odds: 4242), a, "4242") expect(lang, "notifEvolveBody", l.notifEvolveBody(a), a) expect(lang, "notifDittoRevealBody", l.notifDittoRevealBody(a), a) expect(lang, "notifShinyDittoRevealBody", l.notifShinyDittoRevealBody(a), a) diff --git a/Tests/PokeTokenBarTests/ShinyCharmTests.swift b/Tests/PokeTokenBarTests/ShinyCharmTests.swift index 640829fae..11ca270d2 100644 --- a/Tests/PokeTokenBarTests/ShinyCharmTests.swift +++ b/Tests/PokeTokenBarTests/ShinyCharmTests.swift @@ -46,6 +46,28 @@ final class ShinyCharmTests: XCTestCase { XCTAssertFalse(CompanionStore.rollsShiny(roll: 1, charmOwned: false)) } + /// 알림 문구가 판정과 같은 분모를 쓰는지 — 부화 알림이 부적 보유 중에도 "(1/64)"로 고정돼 있던 결함. + func testShinyNotificationShowsTheOddsTheHatchUsed() { + XCTAssertEqual(CompanionStore.shinyDenominator(charmOwned: false), PokemonOdds.shinyDenominator) + XCTAssertEqual(CompanionStore.shinyDenominator(charmOwned: true), ShinyCharm.shinyDenominator) + XCTAssertEqual(store(charm: false).shinyDenominator, 64) + XCTAssertEqual(store(charm: true).shinyDenominator, 48) + for language in AppLanguage.allCases { + let l = L(language) + let withCharm = l.notifShinyHatchBody("P", odds: 48) + XCTAssertTrue(withCharm.contains("48"), "\(language.rawValue): \(withCharm)") + XCTAssertFalse(withCharm.contains("64"), "\(language.rawValue): \(withCharm)") + } + } + + /// 메타몽 리빌 시점엔 부화 때의 부적 보유 여부를 알 수 없다 — 틀릴 수 있는 확률을 적지 않는다. + func testShinyDittoRevealDoesNotClaimFixedOdds() { + for language in AppLanguage.allCases { + let body = L(language).notifShinyDittoRevealBody("P") + XCTAssertFalse(body.contains("64") || body.contains("48"), "\(language.rawValue): \(body)") + } + } + func testConstantsAndPassiveFlag() { XCTAssertEqual(ShinyCharm.price, 3_000_000_000) XCTAssertEqual(ShinyCharm.shinyDenominator, 48) diff --git a/docs/reference/defect-log.md b/docs/reference/defect-log.md index 91726540f..b5b862c60 100644 --- a/docs/reference/defect-log.md +++ b/docs/reference/defect-log.md @@ -68,6 +68,18 @@ read_when: now covers usage labels, hidden control labels, selected-language backup dates, and Gen-V `light-ball-egg`/`form-change` methods alongside native rendering in all supported languages. +- **Balance numbers in copy come from the runtime value, never a literal.** The shiny hatch + notification said "(1/64)" in every language after the Shiny Charm made the real odds 1/48, and + the first-run egg hint kept "~5M tokens" after the growth slider could move the hatch point + (fixed separately). + The copy was written before the modifiers existed, and `LocalizationInterpolationTests` only + checks that placeholders survive, so a number with no placeholder was never compared with the + value the game uses. Pass the value the logic uses (`CompanionStore.shinyDenominator`, the + difficulty-scaled threshold) into the string. When a message describes an earlier roll whose + inputs are not saved, such as a Ditto reveal after hatch, leave the number out rather than guess. + When adding a modifier (charm, difficulty, boost), grep `Localization.swift` for the constants + it changes. `ShinyCharmTests` checks the notification odds with and without the charm. + - **Cost availability is not a numeric zero.** Codex providers overwrote priced totals with zero while leaving cost UI enabled; earlier tests asserted that subscription policy instead of comparing the public provider result with priced log entries. Preserve explicit source zero, From 014fcb285a97f99ed90e820f7153289d3f42f769 Mon Sep 17 00:00:00 2001 From: "Taeyang Jin (Theo)" Date: Fri, 25 Sep 2026 11:24:13 +0900 Subject: [PATCH 10/15] fix: follow growth difficulty in the first-run egg hint (#352) The first-run egg hint said "~5M tokens" in every language, even when the growth slider moved the hatch point. At 10% the line above it correctly showed "500K to hatch" while the hint below still said 5M. - Make CompanionStore.eggHatchThreshold (already difficulty-scaled) readable and pass it into eggFirstRunHint(_:). - Cover the scaled value and the placeholder in all languages. Co-authored-by: Claude Opus 5.5 (1M context) --- Sources/PokeTokenBar/Core/CompanionStore.swift | 4 ++-- Sources/PokeTokenBar/Core/Localization.swift | 17 +++++++++-------- Sources/PokeTokenBar/UI/CompanionView.swift | 2 +- Tests/PokeTokenBarTests/DifficultyTests.swift | 12 ++++++++++++ .../LocalizationInterpolationTests.swift | 1 + 5 files changed, 25 insertions(+), 11 deletions(-) diff --git a/Sources/PokeTokenBar/Core/CompanionStore.swift b/Sources/PokeTokenBar/Core/CompanionStore.swift index 7aa603977..ce215b545 100644 --- a/Sources/PokeTokenBar/Core/CompanionStore.swift +++ b/Sources/PokeTokenBar/Core/CompanionStore.swift @@ -151,8 +151,8 @@ final class CompanionStore { defaults.set(clamped, forKey: "shopDifficulty") } - /// 난이도를 반영한 알 부화 임계. - private var eggHatchThreshold: Int { + /// 난이도를 반영한 알 부화 임계. 첫 실행 안내 문구도 이 값을 보여준다. + var eggHatchThreshold: Int { PokemonBalance.scaled(PokemonBalance.eggHatchThreshold, by: growthDifficulty) } diff --git a/Sources/PokeTokenBar/Core/Localization.swift b/Sources/PokeTokenBar/Core/Localization.swift index dc8b6ea4f..d1193e28a 100644 --- a/Sources/PokeTokenBar/Core/Localization.swift +++ b/Sources/PokeTokenBar/Core/Localization.swift @@ -1078,14 +1078,15 @@ struct L { func notifShinyHatchBody(_ name: String, odds: UInt64) -> String { t("이로치 \(name)이(가) 태어났어요! (1/\(odds))", "A shiny \(name) hatched! (1 in \(odds))", "色違いの \(name) が生まれました!(1/\(odds))", "¡Nació un \(name) variocolor! (1 entre \(odds))", "Un \(name) chromatique est né ! (1 sur \(odds))", "Nasceu um \(name) shiny! (1 em \(odds))", "Ein schillerndes \(name) ist geschlüpft! (1/\(odds))") } var eggImminent: String { t("곧 부화해요!", "About to hatch!", "もうすぐ孵化!", "¡Está a punto de eclosionar!", "Sur le point d'éclore !", "Está quase chocando!", "Schlüpft gleich!") } /// 첫 실행(아직 토큰 적립 0) 안내 — "왜 아무 일도 안 일어나지"를 방지. - var eggFirstRunHint: String { - t("로컬 AI 코딩 도구의 사용량으로 자라요. 약 5M 토큰을 쓰면 알이 부화해요.", - "Grows from your local AI coding usage. Your egg hatches after ~5M tokens.", - "ローカルの AI コーディング使用量で育ちます。約5Mトークンでタマゴが孵化します。", - "Crece con el uso de tus herramientas locales de programación con IA. Tu huevo eclosiona tras unos 5M de tokens.", - "Il grandit avec l'usage de tes outils de code IA locaux. Ton œuf éclôt après environ 5M de tokens.", - "Cresce com o uso das suas ferramentas locais de programação com IA. O ovo choca depois de uns 5M de tokens.", - "Wächst mit der Nutzung deiner lokalen KI-Coding-Tools. Nach etwa 5M Tokens schlüpft dein Ei.") } + /// `amount` 는 성장 난이도가 반영된 부화 임계(포맷된 값) — 고정 5M 이 아니다. + func eggFirstRunHint(_ amount: String) -> String { + t("로컬 AI 코딩 도구의 사용량으로 자라요. 약 \(amount) 토큰을 쓰면 알이 부화해요.", + "Grows from your local AI coding usage. Your egg hatches after ~\(amount) tokens.", + "ローカルの AI コーディング使用量で育ちます。約\(amount)トークンでタマゴが孵化します。", + "Crece con el uso de tus herramientas locales de programación con IA. Tu huevo eclosiona tras unos \(amount) de tokens.", + "Il grandit avec l'usage de tes outils de code IA locaux. Ton œuf éclôt après environ \(amount) de tokens.", + "Cresce com o uso das suas ferramentas locais de programação com IA. O ovo choca depois de uns \(amount) de tokens.", + "Wächst mit der Nutzung deiner lokalen KI-Coding-Tools. Nach etwa \(amount) Tokens schlüpft dein Ei.") } var notifEvolveTitle: String { t("✨ 진화!", "✨ Evolved!", "✨ 進化!", "✨ ¡Evolucionó!", "✨ Évolution !", "✨ Evoluiu!", "✨ Entwicklung!") } func notifEvolveBody(_ name: String) -> String { t("\(name)(으)로 진화했어요!", "Evolved into \(name)!", "\(name) に進化しました!", "¡Evolucionó a \(name)!", "A évolué en \(name) !", "Evoluiu para \(name)!", "Hat sich zu \(name) entwickelt!") } // 메타몽 위장 리빌 — 진화 못 하는 메타몽이 첫 진화 순간 정체를 드러낸다. diff --git a/Sources/PokeTokenBar/UI/CompanionView.swift b/Sources/PokeTokenBar/UI/CompanionView.swift index 0aaf48dc4..17d324f7a 100644 --- a/Sources/PokeTokenBar/UI/CompanionView.swift +++ b/Sources/PokeTokenBar/UI/CompanionView.swift @@ -607,7 +607,7 @@ struct CompanionHeader: View { } // 첫 실행(적립 0) — 정적 알 앞에서 "고장났나" 오해 방지용 한 줄 안내 if !store.eggStarted { - Text(store.l.eggFirstRunHint) + Text(store.l.eggFirstRunHint(TokenFormatter.compact(store.eggHatchThreshold))) .font(.caption2).foregroundStyle(.tertiary) .fixedSize(horizontal: false, vertical: true) } diff --git a/Tests/PokeTokenBarTests/DifficultyTests.swift b/Tests/PokeTokenBarTests/DifficultyTests.swift index eba05eef3..a94bcda2e 100644 --- a/Tests/PokeTokenBarTests/DifficultyTests.swift +++ b/Tests/PokeTokenBarTests/DifficultyTests.swift @@ -128,6 +128,18 @@ final class DifficultyTests: XCTestCase { XCTAssertNotNil(easy.state.active, "0.5x: 같은 양으로 부화") } + /// 첫 실행 안내가 실제 부화 임계를 말하는지 — 난이도 10% 에서도 "약 5M" 으로 고정돼 있던 결함. + func testFirstRunEggHintShowsTheScaledHatchPoint() { + let easy = store(growth: 0.1) + XCTAssertEqual(easy.eggHatchThreshold, 500_000) + XCTAssertEqual(store(growth: 1.0).eggHatchThreshold, PokemonBalance.eggHatchThreshold) + for language in AppLanguage.allCases { + let hint = L(language).eggFirstRunHint(TokenFormatter.compact(easy.eggHatchThreshold)) + XCTAssertTrue(hint.contains("500K"), "\(language.rawValue): \(hint)") + XCTAssertFalse(hint.contains("5M"), "\(language.rawValue): \(hint)") + } + } + // MARK: 3. 상점 — 결제 금액과 구매 가능 판정이 바뀌는가 func testShopGateAndChargedAmountFollowDifficulty() async { diff --git a/Tests/PokeTokenBarTests/LocalizationInterpolationTests.swift b/Tests/PokeTokenBarTests/LocalizationInterpolationTests.swift index ce968975b..e7e9d248c 100644 --- a/Tests/PokeTokenBarTests/LocalizationInterpolationTests.swift +++ b/Tests/PokeTokenBarTests/LocalizationInterpolationTests.swift @@ -91,6 +91,7 @@ final class LocalizationInterpolationTests: XCTestCase { // Companion progress & status / 컴패니언 진행 · 상태 expect(lang, "stage", l.stage(4242, 1717), "4242", "1717") expect(lang, "eggToHatch", l.eggToHatch(a), a) + expect(lang, "eggFirstRunHint", l.eggFirstRunHint(a), a) expect(lang, "toNextEvolution", l.toNextEvolution(a), a) expect(lang, "toGraduation", l.toGraduation(a), a) expect(lang, "growthBoost", l.growthBoost(4242), "4242") From d44329bbd9e3d7914f0a0880eebb7f2aaf7790cb Mon Sep 17 00:00:00 2001 From: turtle hermit Date: Fri, 25 Sep 2026 11:25:00 +0900 Subject: [PATCH 11/15] fix(ui): give the Home tab a fixed height and reserve a scroller lane (#353) The Home tab was the only popover tab without a ScrollView or height cap, so with several sections visible (companion, usage, limits for multiple Claude accounts) the popover could grow past the bottom of the screen. Wrap it in a ScrollView with the same fixed 520pt height used by Shop, Bag, and Collection. A vertical ScrollView puts right-aligned values (today's cost, peak, the trend chart's today bar, Buy buttons) under the scroller: the overlay scroller floats over that edge while scrolling, and with "Show scroll bars: Always" SwiftUI only reserves a gutter when content overflows. Keep the system scroller and give it its own lane instead of hiding it: - Add PopoverMetrics.scrollerInset (12pt) and .reservesScrollerLane(), trailing padding inside the content of the Home, Shop, Bag, catch log, and Pokemon detail scroll views. Settings already pads 16pt. - Pad inside the content rather than using .contentMargins: AppKit moves the overlay scroller inward with content margins, so it still covers text. - Size in-scroll evolution lines from PopoverMetrics.scrollContentWidth so their paging and edge fades match the visible width. - Add ScrollerLaneTests requiring the lane on every vertical popover ScrollView, and record the defect in docs/reference/defect-log.md. Claude-Session: https://claude.ai/code/session_01PNenigaFnwvcTs2kDND16Z Co-authored-by: turtle hermit <4173875+cte13@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 (1M context) --- Sources/PokeTokenBar/UI/BagView.swift | 1 + Sources/PokeTokenBar/UI/CompanionView.swift | 6 +- Sources/PokeTokenBar/UI/PopoverView.swift | 37 +++- Sources/PokeTokenBar/UI/ShopView.swift | 1 + .../PokeTokenBarTests/ScrollerLaneTests.swift | 168 ++++++++++++++++++ docs/reference/defect-log.md | 22 +++ 6 files changed, 225 insertions(+), 10 deletions(-) create mode 100644 Tests/PokeTokenBarTests/ScrollerLaneTests.swift diff --git a/Sources/PokeTokenBar/UI/BagView.swift b/Sources/PokeTokenBar/UI/BagView.swift index e3c682fec..a9328403b 100644 --- a/Sources/PokeTokenBar/UI/BagView.swift +++ b/Sources/PokeTokenBar/UI/BagView.swift @@ -17,6 +17,7 @@ struct BagView: View { ItemCard(store: store, nav: nav, kind: item.kind, count: item.count) } } + .reservesScrollerLane() } .frame(height: 520) } diff --git a/Sources/PokeTokenBar/UI/CompanionView.swift b/Sources/PokeTokenBar/UI/CompanionView.swift index 17d324f7a..ee5ae62bf 100644 --- a/Sources/PokeTokenBar/UI/CompanionView.swift +++ b/Sources/PokeTokenBar/UI/CompanionView.swift @@ -620,7 +620,7 @@ struct CompanionHeader: View { // 폭을 안 주면 분기 라인(이브이)이 넘쳐 팝오버 콘텐츠 전체가 좌우로 잘린다. EvoLineView(nodes: store.lineNodes, mysteryLabel: store.l.unknownNextEvolution, language: store.language, shiny: store.currentIsShiny, - maxWidth: PopoverMetrics.contentWidth, unownForm: store.currentUnownForm) + maxWidth: PopoverMetrics.scrollContentWidth, unownForm: store.currentUnownForm) } if let g = store.justGraduated { Text(store.l.graduated(g)) @@ -843,6 +843,7 @@ struct CollectionView: View { DexEntryRow(store: store, entry: entry) } } + .reservesScrollerLane() } .frame(maxHeight: .infinity) // 필터 토글 시 목록 최상단으로 — 이전 스크롤 위치가 새 필터 결과 밖이어도 처음부터 보이게. @@ -1128,6 +1129,7 @@ struct PokemonDetailView: View { } } .padding(.bottom, 8) + .reservesScrollerLane() } } .task { @@ -1563,7 +1565,7 @@ private struct DexEntryRow: View { EvoLineView(nodes: entry.chainOrder.map { EvoLineItem(.species($0), .done) }, mysteryLabel: store.l.unknownNextEvolution, language: store.language, thumb: 56, shiny: entry.isShiny, names: names, - maxWidth: PopoverMetrics.contentWidth - Self.cardPadding * 2, unownForm: entry.unownForm) + maxWidth: PopoverMetrics.scrollContentWidth - Self.cardPadding * 2, unownForm: entry.unownForm) if let caughtAt = entry.caughtAt { Text(caughtAt, style: .relative).font(.system(size: 9)).foregroundStyle(.tertiary) } diff --git a/Sources/PokeTokenBar/UI/PopoverView.swift b/Sources/PokeTokenBar/UI/PopoverView.swift index 54af08d34..8f0a05df7 100644 --- a/Sources/PokeTokenBar/UI/PopoverView.swift +++ b/Sources/PokeTokenBar/UI/PopoverView.swift @@ -10,6 +10,20 @@ enum PopoverMetrics { static let padding: CGFloat = 14 /// 이 폭을 넘는 자식은 팝오버 창에 좌우로 잘린다. static let contentWidth: CGFloat = width - padding * 2 + /// 세로 스크롤 영역 오른쪽에 비워 두는 스크롤러 레인. 얇은 오버레이 스크롤러(휴지 ~6pt, 가장자리 + /// 안쪽)가 우측 정렬 수치·버튼 위에 뜨지 않게 한다. 스크롤이 필요 없어도 항상 비워 탭 간 폭이 같다. + static let scrollerInset: CGFloat = 12 + /// 세로 스크롤 영역 안의 자식이 쓸 수 있는 폭. 스크롤 안에서 폭을 고정하는 자식(진화 라인 등)은 + /// `contentWidth` 대신 이 값을 써야 한다 — 제안 폭보다 넓은 자식 하나가 열 전체를 다시 넓혀 레인이 사라진다. + static let scrollContentWidth: CGFloat = contentWidth - scrollerInset +} + +extension View { + /// 팝오버 세로 `ScrollView` 의 콘텐츠에 스크롤러 레인을 비운다. `.contentMargins` 는 쓰지 않는다 — + /// AppKit 이 오버레이 스크롤러를 콘텐츠 인셋만큼 안쪽으로 옮겨 레인이 생기지 않는다(실측). + func reservesScrollerLane() -> some View { + padding(.trailing, PopoverMetrics.scrollerInset) + } } /// 팝오버 내부 내비게이션 상태(현재 탭 / 컬렉션 세그먼트 / 설정 표시 여부). @@ -137,15 +151,22 @@ struct PopoverView: View { } else if nav.tab == .shop { ShopView(store: companion, nav: nav) } else { - CompanionHeader(store: companion) - Divider() - header - Divider() - providerStatusBanner // 인시던트 있을 때만 — 한도 가용 여부와 무관(API 다운=한도 nil 케이스에도) - if selectedProviderHasLimits { - limitsSection - Divider() + // 고정 높이 — 상점/가방/컬렉션과 동일(팝오버가 화면을 넘어가는 것을 방지). + ScrollView { + VStack(alignment: .leading, spacing: 12) { + CompanionHeader(store: companion) + Divider() + header + Divider() + providerStatusBanner // 인시던트 있을 때만 — 한도 가용 여부와 무관(API 다운=한도 nil 케이스에도) + if selectedProviderHasLimits { + limitsSection + Divider() + } + } + .reservesScrollerLane() } + .frame(height: 520) } footer } diff --git a/Sources/PokeTokenBar/UI/ShopView.swift b/Sources/PokeTokenBar/UI/ShopView.swift index 16ad0943c..38e800513 100644 --- a/Sources/PokeTokenBar/UI/ShopView.swift +++ b/Sources/PokeTokenBar/UI/ShopView.swift @@ -26,6 +26,7 @@ struct ShopView: View { } } } + .reservesScrollerLane() } .frame(height: 520) } diff --git a/Tests/PokeTokenBarTests/ScrollerLaneTests.swift b/Tests/PokeTokenBarTests/ScrollerLaneTests.swift new file mode 100644 index 000000000..a5c52407c --- /dev/null +++ b/Tests/PokeTokenBarTests/ScrollerLaneTests.swift @@ -0,0 +1,168 @@ +import XCTest +@testable import PokeTokenBar + +/// The popover's vertical scroll views keep macOS's thin overlay scroller, which floats over the right +/// edge of the content while scrolling. Each one must pad its content with `.reservesScrollerLane()` +/// so the scroller never sits on right-aligned numbers, prices, or buttons. +final class ScrollerLaneTests: XCTestCase { + /// Files whose vertical ScrollView already clears the scroller with its own padding. + /// SettingsView pads its content 16pt on every side. + private static let exempt: Set = ["SettingsView.swift"] + + func testEveryPopoverVerticalScrollViewReservesTheScrollerLane() throws { + let ui = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // PokeTokenBarTests + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // repo root + .appendingPathComponent("Sources/PokeTokenBar/UI") + let enumerator = try XCTUnwrap(FileManager.default.enumerator(at: ui, includingPropertiesForKeys: nil)) + var checked = 0 + var offenders: [String] = [] + + for case let url as URL in enumerator where url.pathExtension == "swift" { + guard !Self.exempt.contains(url.lastPathComponent) else { continue } + let source = try String(contentsOf: url, encoding: .utf8) + for site in Self.verticalScrollViews(in: source) { + checked += 1 + if !site.reservesLane { offenders.append("\(url.lastPathComponent):\(site.line)") } + } + } + + // Home, Shop, Bag, catch log, Pokémon detail — guards against a vacuous pass if the scan breaks. + XCTAssertGreaterThanOrEqual(checked, 5) + XCTAssertTrue(offenders.isEmpty, """ + Vertical ScrollViews in the popover must apply .reservesScrollerLane() to their content, + or the overlay scroller covers right-aligned content. Missing at: \(offenders.joined(separator: ", ")) + """) + } + + func testScannerFindsTheLaneOnlyInsideTheScrollViewsOwnContent() { + let source = """ + ScrollView { + VStack { Text("a") } + // comment + .reservesScrollerLane() + } + .frame(height: 520) + + ScrollView { + VStack { Text("b") } + } + .reservesScrollerLane() + + ScrollView(.horizontal) { HStack { } } + + ScrollView { Text("\\(x) .reservesScrollerLane()") } + + ScrollView { Text(\"\"\" + .reservesScrollerLane() + \"\"\") } + + ScrollViewReader { proxy in } + """ + let sites = Self.verticalScrollViews(in: source) + XCTAssertEqual(sites.map(\.line), [1, 8, 15, 17]) + // Padding outside the ScrollView pads the scroller too, so it doesn't count; neither does a string. + XCTAssertEqual(sites.map(\.reservesLane), [true, false, false, false]) + } + + // MARK: - Scanner + + struct Site: Equatable { + let line: Int + let reservesLane: Bool + } + + /// Finds each vertical `ScrollView` and reports whether `.reservesScrollerLane()` is applied inside its + /// trailing closure. Comments and string literals are blanked first so they can't satisfy the check. + static func verticalScrollViews(in source: String) -> [Site] { + let chars = Array(blankingCommentsAndStrings(source)) + let keyword = Array("ScrollView") + var sites: [Site] = [] + var i = 0 + while i + keyword.count <= chars.count { + let isMatch = Array(chars[i.. Bool { c.isLetter || c.isNumber || c == "_" } + + private static func skipWhitespace(_ chars: [Character], _ start: Int) -> Int { + var i = start + while i < chars.count, chars[i].isWhitespace { i += 1 } + return i + } + + /// Index of the bracket closing the one at `open`. Runs on blanked source, so no string handling is needed. + private static func matching(_ chars: [Character], _ open: Int) -> Int { + let (o, c): (Character, Character) = chars[open] == "(" ? ("(", ")") : ("{", "}") + var depth = 0 + for i in open.. String { + let chars = Array(source) + var out = chars + var i = 0 + while i < chars.count { + let next: Character? = i + 1 < chars.count ? chars[i + 1] : nil + if chars[i] == "/", next == "/" { + while i < chars.count, chars[i] != "\n" { out[i] = " "; i += 1 } + } else if chars[i] == "/", next == "*" { + while i < chars.count, !(chars[i] == "*" && i + 1 < chars.count && chars[i + 1] == "/") { + if chars[i] != "\n" { out[i] = " " } + i += 1 + } + if i + 1 < chars.count { out[i] = " "; out[i + 1] = " "; i += 2 } + } else if chars[i] == "\"", next == "\"", i + 2 < chars.count, chars[i + 2] == "\"" { + i += 3 // multi-line literal: blank everything up to the closing triple quote + while i + 2 < chars.count, !(chars[i] == "\"" && chars[i + 1] == "\"" && chars[i + 2] == "\"") { + if chars[i] != "\n" { out[i] = " " } + i += 1 + } + i += 3 + } else if chars[i] == "\"" { + i += 1 + var depth = 0 // interpolation nesting: `\(` opens, `)` at depth 1 closes + while i < chars.count { + if depth == 0, chars[i] == "\"" { break } + if chars[i] == "\\", i + 1 < chars.count, chars[i + 1] == "(" { depth += 1; out[i] = " "; out[i + 1] = " "; i += 2; continue } + if depth == 0, chars[i] == "\\" { out[i] = " "; if i + 1 < chars.count { out[i + 1] = " " }; i += 2; continue } + if depth > 0, chars[i] == "(" { depth += 1 } + if depth > 0, chars[i] == ")" { depth -= 1 } + if chars[i] != "\n" { out[i] = " " } + i += 1 + } + i += 1 // closing quote + } else { + i += 1 + } + } + return String(out) + } +} diff --git a/docs/reference/defect-log.md b/docs/reference/defect-log.md index b5b862c60..adbe4d115 100644 --- a/docs/reference/defect-log.md +++ b/docs/reference/defect-log.md @@ -731,6 +731,28 @@ read_when: 새 프로바이더 UI 를 붙일 땐 같은 부류의 형제 문구(여기선 `claudeAuthExpiredTitle/Hint`)를 먼저 찾아 문안 구조까지 맞춘다 — 문구만 새로 지으면 같은 화면에서 두 안내가 다른 말투로 갈린다. +- **팝오버 세로 `ScrollView` 는 콘텐츠 안쪽에 스크롤러 레인을 비운다 — `.reservesScrollerLane()`.** + 홈 탭을 `ScrollView` + 고정 520pt 로 감싸자(상점·가방·도감과 같은 높이), 오른쪽 끝에 붙은 수치(오늘 비용·Peak·추이 막대 + "오늘" 칸)를 스크롤러가 가렸다. 두 경로가 있다: ① 얇은 오버레이 스크롤러(기본)는 스크롤 중 콘텐츠 + 위에 뜬다 — 상점·가방·도감 카드의 "구매" 버튼 끝도 같다. ② "스크롤 막대 항상 표시"에선 SwiftUI 가 + **넘칠 때만** legacy 거터(~15pt)를 예약하는데, 홈 콘텐츠가 520pt 보다 짧으면(실측 477.5pt) 거터 없이 + 332pt 전폭으로 깔리고 빈 트랙이 그 위에 그려진다. + **왜 못 걸렀나:** 고정 높이 변경은 "팝오버가 화면 밖으로 넘친다"만 검증했고, 스크롤러와 콘텐츠의 겹침은 어떤 + 테스트도 보지 않았다. 오버레이 스크롤러는 스크롤할 때만 보여 정지 스크린샷에도 안 잡힌다. + **수정:** 스크롤바를 숨기지 않는다(`.scrollIndicators(.never)` 는 스크롤 위치 단서를 없애고, + 사용자가 고른 "항상 표시" 설정을 무시한다). 대신 스크롤 콘텐츠에 trailing 12pt 를 항상 비운다 + (`PopoverMetrics.scrollerInset`) — 스크롤 여부와 무관하게 비워 탭 간 폭이 같다. 설정은 자체 16pt + 패딩이 있어 제외. + **함정:** `.contentMargins(.trailing, _, for: .scrollContent)` 는 안 된다 — AppKit 이 오버레이 + 스크롤러를 콘텐츠 인셋만큼 안쪽으로 옮겨 여전히 텍스트 위에 뜬다(렌더로 확인). 반드시 콘텐츠 **안쪽** + 패딩이어야 한다. 또 스크롤 안에서 폭을 고정하는 자식은 `scrollContentWidth` 를 써야 한다 — 제안 폭보다 + 넓은 자식 하나가 열 전체를 다시 전폭으로 넓힌다(`frame(width:)`·`minWidth` 332 자식으로 재현). + 진화 라인의 현재 셀 폭(홈 310pt·도감 302pt)으로는 이 범위에 안 들어 실제 오버플로는 없지만, 페이징 + 폭·페이드가 실제 보이는 폭과 맞도록 `scrollContentWidth` 로 바꿨다. + **회귀 가드:** `ScrollerLaneTests` 가 `Sources/PokeTokenBar/UI` 의 모든 세로 `ScrollView` 가 자기 + 클로저 안에서 `.reservesScrollerLane()` 을 쓰는지 괄호 매칭으로 검사한다(주석·문자열 제외, 바깥에 붙인 + 패딩은 스크롤러까지 밀어 불인정). 다섯 곳 각각을 빼면 해당 `파일:줄` 로 실패하는 것을 확인했다. + ## 에너지 (상시 표시 애니메이션) - **메뉴바 상태아이템 = idle CPU 저격수 (두 규칙 필수).** 실측: 라이브 앱 idle ~14% CPU → 수정 후 ~2%. From 832154af7ef69353b06adb26a5130cccf845f01f Mon Sep 17 00:00:00 2001 From: turtle hermit Date: Fri, 25 Sep 2026 11:26:07 +0900 Subject: [PATCH 12/15] fix: price Claude Opus 5.5 so its usage cost is not Unavailable (#354) claude-opus-5-5 has no row in ModelPricing.table, so every Opus 5.5 request falls to CostCoverage.unavailable and Claude Code cost renders as "Unavailable" (menu bar $-, Home today, daily trend, provider row). Add the exact row from the Claude pricing page ($4 input / $20 output, 5m cache write $5, cache read $0.20 = 0.05x input rather than the usual 0.1x) and cover it in testModelsReadFromProviderLogsAreAllPriced. Claude-Session: https://claude.ai/code/session_01PNenigaFnwvcTs2kDND16Z Co-authored-by: turtle hermit <4173875+cte13@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 (1M context) --- Sources/PokeTokenBar/Core/ModelPricing.swift | 2 ++ Tests/PokeTokenBarTests/ModelPricingTests.swift | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/PokeTokenBar/Core/ModelPricing.swift b/Sources/PokeTokenBar/Core/ModelPricing.swift index 3d03053d6..634f7d082 100644 --- a/Sources/PokeTokenBar/Core/ModelPricing.swift +++ b/Sources/PokeTokenBar/Core/ModelPricing.swift @@ -26,6 +26,8 @@ enum ModelPricing { // Claude 5 family. Exact rows are mandatory: the model-family fallback that used to // price these was removed in #289, which left them unpriced and the cost row blank (#303). "claude-opus-5": .perMillion(5, 25, 6.25, 0.5), + // Opus 5.5, checked 2026-09-25: cheaper than Opus 5, cache read cut to $0.20/MTok (0.05× input). + "claude-opus-5-5": .perMillion(4, 20, 5, 0.2), "claude-sonnet-5": .perMillion(2, 10, 2.5, 0.2), "claude-opus-4-20250514": .perMillion(15, 75, 18.75, 1.5), "claude-sonnet-4-20250514": .perMillion(3, 15, 3.75, 0.3), diff --git a/Tests/PokeTokenBarTests/ModelPricingTests.swift b/Tests/PokeTokenBarTests/ModelPricingTests.swift index cd50bcd9c..7d06d1f2b 100644 --- a/Tests/PokeTokenBarTests/ModelPricingTests.swift +++ b/Tests/PokeTokenBarTests/ModelPricingTests.swift @@ -15,6 +15,7 @@ final class ModelPricingTests: XCTestCase { func testCurrentClaudeStandardRates() { // https://platform.claude.com/docs/en/about-claude/pricing — base input, output, 5m cache write, cache read. XCTAssertEqual(ModelPricing.rate(for: "claude-opus-5"), .perMillion(5, 25, 6.25, 0.5)) + XCTAssertEqual(ModelPricing.rate(for: "claude-opus-5-5"), .perMillion(4, 20, 5, 0.2)) XCTAssertEqual(ModelPricing.rate(for: "claude-sonnet-5"), .perMillion(2, 10, 2.5, 0.2)) } @@ -28,7 +29,7 @@ final class ModelPricingTests: XCTestCase { // A real Claude Code bucket split: every request logs cache creation and cache reads, // so a row without a cache-write rate is unpriced in practice even when its input and // output columns are filled in. - for model in ["claude-opus-5", "claude-sonnet-5", "claude-opus-4-8", "claude-opus-4-7", + for model in ["claude-opus-5", "claude-opus-5-5", "claude-sonnet-5", "claude-opus-4-8", "claude-opus-4-7", "claude-sonnet-4-6", "claude-haiku-4-5", "claude-fable-5", "claude-fable-5-1"] { let cost = try XCTUnwrap( ModelPricing.estimatedCost(model: model, input: 2, output: 175, From 671c1ece5112b7148c84904bffc28e7c491026d2 Mon Sep 17 00:00:00 2001 From: turtle hermit Date: Fri, 25 Sep 2026 11:26:57 +0900 Subject: [PATCH 13/15] fix: stop the test suite from calling live limits endpoints with the user's login (#355) UsageStore defaults every limits provider to the real one, so any test that does not inject a stub runs it. Credentials are also plain files (~/.claude/.credentials.json, the Antigravity token file, Cursor's state.vscdb), so on a contributor's machine the no-prompt path succeeds and `swift test` calls Anthropic, Google and Cursor with that account: spending its rate limit (which the real app then backs off from) and, for Antigravity, its refresh token. Gate every network boundary of the limits providers with AppEnv.allowsLiveLimitsFetch (app bundle, or PTB_PARITY=1, or an explicit test opt-in for stubbed transports): - OAuthLimitsProvider: usage call and the profile identity call - AntigravityRateLimitsProvider: quota call and the Google token refresh - URLSessionSessionKeyClient: the real claude.ai client (tests inject theirs) - CursorRateLimitsProvider: perform (usage and token refresh) The gate sits at the network boundary, not before the credential read, so KeychainAutoPathTests keeps observing real reads. A raw `swift build` binary now shows no live limits unless run with PTB_PARITY=1. LiveCredentialCallGateTests checks the gate is closed under `swift test`, that Claude and Antigravity stop at it when a credential exists, and scans every networking *LimitsProvider.swift so the next provider cannot ship without it. CredentialSwitchCacheTests opts in for its three stubbed-transport tests. Claude-Session: https://claude.ai/code/session_01PNenigaFnwvcTs2kDND16Z Co-authored-by: turtle hermit <4173875+cte13@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 (1M context) --- .../Core/AntigravityRateLimitsProvider.swift | 3 + Sources/PokeTokenBar/Core/AppEnv.swift | 20 +++++ .../Core/CursorRateLimitsProvider.swift | 2 + .../Core/OAuthLimitsProvider.swift | 4 + .../Core/SessionKeyLimitsProvider.swift | 2 + Sources/PokeTokenBar/Core/UsageStore.swift | 3 +- .../CredentialSwitchCacheTests.swift | 9 +++ .../LiveCredentialCallGateTests.swift | 80 +++++++++++++++++++ .../UsageEnvironmentTests.swift | 2 + 9 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 Tests/PokeTokenBarTests/LiveCredentialCallGateTests.swift diff --git a/Sources/PokeTokenBar/Core/AntigravityRateLimitsProvider.swift b/Sources/PokeTokenBar/Core/AntigravityRateLimitsProvider.swift index c98617b4a..17e6c74ee 100644 --- a/Sources/PokeTokenBar/Core/AntigravityRateLimitsProvider.swift +++ b/Sources/PokeTokenBar/Core/AntigravityRateLimitsProvider.swift @@ -64,6 +64,7 @@ public struct AntigravityRateLimitsProvider: AntigravityLimitsProviding, Sendabl } private func fetchStatus(accessToken: String) async throws -> AntigravityRateLimitStatus { + guard AppEnv.allowsLiveLimitsFetch else { throw LimitsError.liveFetchNotPermitted } var endpoints: [URL] = [] if let envURLString = UsageEnvironment.value("CLOUD_CODE_URL"), let envURL = URL(string: envURLString + "/v1internal:retrieveUserQuotaSummary") { @@ -235,6 +236,8 @@ actor AntigravityTokenCache { } private func refreshGoogleToken(refreshToken: String) async throws -> AntigravityOAuthCredential? { + // A separate network boundary: without it the suite would spend the user's refresh token. + guard AppEnv.allowsLiveLimitsFetch else { throw LimitsError.liveFetchNotPermitted } var request = URLRequest(url: AntigravityRateLimitsProvider.googleTokenURL, timeoutInterval: 10) request.httpMethod = "POST" request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") diff --git a/Sources/PokeTokenBar/Core/AppEnv.swift b/Sources/PokeTokenBar/Core/AppEnv.swift index a18e6b7f5..40083ddd5 100644 --- a/Sources/PokeTokenBar/Core/AppEnv.swift +++ b/Sources/PokeTokenBar/Core/AppEnv.swift @@ -8,4 +8,24 @@ enum AppEnv { static var isBundledApp: Bool { Bundle.main.bundleIdentifier != nil && Bundle.main.bundlePath.hasSuffix(".app") } + + /// `PTB_PARITY=1` — a QA/parity run where live endpoints are allowed (the same flag the parity + /// smoke tests already use). Only ever opens a gate that `isBundledApp` keeps closed. + static var isParityRun: Bool { + ProcessInfo.processInfo.environment["PTB_PARITY"] == "1" + } + + /// Lets a test drive a provider's real network code against a stubbed transport (URLProtocol, an + /// injected URLSession). Set it in the test and reset it in `defer`. + nonisolated(unsafe) static var allowLiveFetchForTesting = false + + /// Whether a limits provider may send the user's credentials to a live endpoint. Closed under + /// `swift test` and for a raw `swift build` binary: a test that does not inject a stub would + /// otherwise call Anthropic, Google or Cursor with the real login found on the machine + /// (`~/.claude/.credentials.json`, the Antigravity token file, Cursor's `state.vscdb`). + /// Put the check at the network boundary, not before the credential read, so the Keychain path + /// tests keep observing real reads. `LiveCredentialCallGateTests` requires it in every provider. + static var allowsLiveLimitsFetch: Bool { + isBundledApp || isParityRun || allowLiveFetchForTesting + } } diff --git a/Sources/PokeTokenBar/Core/CursorRateLimitsProvider.swift b/Sources/PokeTokenBar/Core/CursorRateLimitsProvider.swift index f2171ac74..4a7a41560 100644 --- a/Sources/PokeTokenBar/Core/CursorRateLimitsProvider.swift +++ b/Sources/PokeTokenBar/Core/CursorRateLimitsProvider.swift @@ -97,6 +97,8 @@ public struct CursorRateLimitsProvider: CursorLimitsProviding, Sendable { } private static func perform(_ request: URLRequest) async -> (Data, Int)? { + // The only real network boundary (usage and token refresh); tests inject `transportForTesting`. + guard AppEnv.allowsLiveLimitsFetch else { return nil } do { let (data, response) = try await session.data(for: request) let status = (response as? HTTPURLResponse)?.statusCode ?? 0 diff --git a/Sources/PokeTokenBar/Core/OAuthLimitsProvider.swift b/Sources/PokeTokenBar/Core/OAuthLimitsProvider.swift index 702c13d97..ebbf0ca53 100644 --- a/Sources/PokeTokenBar/Core/OAuthLimitsProvider.swift +++ b/Sources/PokeTokenBar/Core/OAuthLimitsProvider.swift @@ -3,6 +3,8 @@ import Security enum LimitsError: Error, Equatable { case keychainAccessDisabled + /// A live limits call outside the app bundle (`swift test`, a raw binary). See `AppEnv.allowsLiveLimitsFetch`. + case liveFetchNotPermitted case keychainUnavailable(OSStatus) case keychainInteractionNotAllowed case credentialFormat @@ -79,6 +81,7 @@ struct OAuthLimitsProvider: ClaudeLimitsProviding, Sendable { } private func fetchStatus(accessToken: String) async throws -> LimitStatus { + guard AppEnv.allowsLiveLimitsFetch else { throw LimitsError.liveFetchNotPermitted } var request = URLRequest(url: Self.usageURL, timeoutInterval: 15) request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") request.setValue("oauth-2025-04-20", forHTTPHeaderField: "anthropic-beta") @@ -135,6 +138,7 @@ actor OAuthProfileCache { private static let profileURL = URL(string: "https://api.anthropic.com/api/oauth/profile")! static func networkIdentity(accessToken: String) async -> AccountIdentity? { + guard AppEnv.allowsLiveLimitsFetch else { return nil } var request = URLRequest(url: profileURL, timeoutInterval: 15) request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") request.setValue("oauth-2025-04-20", forHTTPHeaderField: "anthropic-beta") diff --git a/Sources/PokeTokenBar/Core/SessionKeyLimitsProvider.swift b/Sources/PokeTokenBar/Core/SessionKeyLimitsProvider.swift index 70467451a..cf9d6c945 100644 --- a/Sources/PokeTokenBar/Core/SessionKeyLimitsProvider.swift +++ b/Sources/PokeTokenBar/Core/SessionKeyLimitsProvider.swift @@ -35,6 +35,8 @@ struct URLSessionSessionKeyClient: SessionKeyHTTPClient { "(KHTML, like Gecko) Version/17.0 Safari/605.1.15" func get(_ url: URL, sessionKey: String) async throws -> SessionKeyHTTPResponse { + // Tests inject their own `SessionKeyHTTPClient`, so this only stops real calls. + guard AppEnv.allowsLiveLimitsFetch else { throw LimitsError.liveFetchNotPermitted } var request = URLRequest(url: url, timeoutInterval: 15) // 쿠키를 헤더로 직접 넣는다. 공유 저장소가 개입하면 이 헤더를 덮어써 인증이 뒤바뀔 수 있다. request.httpShouldHandleCookies = false diff --git a/Sources/PokeTokenBar/Core/UsageStore.swift b/Sources/PokeTokenBar/Core/UsageStore.swift index c391141c2..b0c374611 100644 --- a/Sources/PokeTokenBar/Core/UsageStore.swift +++ b/Sources/PokeTokenBar/Core/UsageStore.swift @@ -1626,7 +1626,8 @@ final class UsageStore { return l.limitRefreshNoCredential case .credentialMissingAccountOAuth: return l.limitRefreshReauthNeeded - case .keychainInteractionNotAllowed, .keychainAccessDisabled: + // liveFetchNotPermitted never reaches the real app (the bundle opens the gate), so it gets the generic text. + case .keychainInteractionNotAllowed, .keychainAccessDisabled, .liveFetchNotPermitted: return l.limitRefreshGeneric case .sessionKeyMissing: return l.limitRefreshNoCredential diff --git a/Tests/PokeTokenBarTests/CredentialSwitchCacheTests.swift b/Tests/PokeTokenBarTests/CredentialSwitchCacheTests.swift index 47dcbaab1..ed55560f9 100644 --- a/Tests/PokeTokenBarTests/CredentialSwitchCacheTests.swift +++ b/Tests/PokeTokenBarTests/CredentialSwitchCacheTests.swift @@ -86,6 +86,9 @@ final class CredentialSwitchCacheTests: XCTestCase { /// valid, so a 401 never arrives and the in-memory cache used to answer the Refresh button /// forever. Restoring `bypassCache: false` on that path must fail this test. func testManualRefreshRereadsKeychainWhenCachedTokenIsStillValid() async throws { + // Drives the provider's real network code against a stubbed transport (see AppEnv.allowsLiveLimitsFetch). + AppEnv.allowLiveFetchForTesting = true + defer { AppEnv.allowLiveFetchForTesting = false } let file = tempDir.appendingPathComponent("credentials.json") try writeClaudeCredentials(to: file, token: "token-account-a", subscription: "team") let cache = OAuthAccessTokenCache(credentialsFileURL: file) @@ -250,6 +253,9 @@ final class CredentialSwitchCacheTests: XCTestCase { } func testExpiredFileRefreshesAndSubsequentPollRetainsRefreshedToken() async throws { + // Drives the provider's real network code against a stubbed transport (see AppEnv.allowsLiveLimitsFetch). + AppEnv.allowLiveFetchForTesting = true + defer { AppEnv.allowLiveFetchForTesting = false } let config = URLSessionConfiguration.ephemeral config.protocolClasses = [MockOAuthURLProtocol.self] let session = URLSession(configuration: config) @@ -308,6 +314,9 @@ final class CredentialSwitchCacheTests: XCTestCase { } func testExpiredFileRefreshFailsReturnsOriginalToken() async throws { + // Drives the provider's real network code against a stubbed transport (see AppEnv.allowsLiveLimitsFetch). + AppEnv.allowLiveFetchForTesting = true + defer { AppEnv.allowLiveFetchForTesting = false } let config = URLSessionConfiguration.ephemeral config.protocolClasses = [MockOAuthURLProtocol.self] let session = URLSession(configuration: config) diff --git a/Tests/PokeTokenBarTests/LiveCredentialCallGateTests.swift b/Tests/PokeTokenBarTests/LiveCredentialCallGateTests.swift new file mode 100644 index 000000000..14e8e655f --- /dev/null +++ b/Tests/PokeTokenBarTests/LiveCredentialCallGateTests.swift @@ -0,0 +1,80 @@ +import XCTest +@testable import PokeTokenBar + +/// The suite must never call a live limits endpoint with the real login found on the machine. +/// +/// `UsageStore.init` defaults every limits provider to the real one, so any test that does not inject +/// a stub runs the real provider. The automatic path reads no Keychain item, which made this look +/// harmless, but credentials are also plain files: with `~/.claude/.credentials.json`, the Antigravity +/// token file, or a Cursor login in `state.vscdb`, the no-prompt path succeeds and the suite calls +/// Anthropic, Google or Cursor with the user's account (spending its rate limit, and for Antigravity +/// its refresh token). The gate sits at the network boundary, not before the credential read, so +/// `KeychainAutoPathTests` still observes real reads. +final class LiveCredentialCallGateTests: XCTestCase { + /// Holds on every machine: the per-provider tests below skip where no credential exists (CI). + func testGateIsClosedUnderSwiftTest() throws { + if AppEnv.isParityRun { throw XCTSkip("PTB_PARITY=1 opens the gate on purpose") } + XCTAssertFalse(AppEnv.isBundledApp, "swift test is not an app bundle") + XCTAssertFalse(AppEnv.allowsLiveLimitsFetch) + } + + /// The automatic path (`allowKeychainPrompt: false`) is the real trigger and never raises a dialog. + func testClaudeLimitsFetchDoesNotReachTheNetwork() async throws { + if AppEnv.isParityRun { throw XCTSkip("PTB_PARITY=1 opens the gate on purpose") } + do { + _ = try await OAuthLimitsProvider().fetch(allowKeychainPrompt: false) + XCTFail("a live Claude limits call ran inside swift test") + } catch LimitsError.liveFetchNotPermitted { + // Expected: a credential was found and the call stopped at the gate. + } catch { + // No credential on this machine: the fetch ended before the gate, so there is nothing to + // check here. Counting that as a pass would overstate coverage. + throw XCTSkip("no credential available, the network boundary was not reached: \(error)") + } + } + + func testAntigravityLimitsFetchDoesNotReachTheNetwork() async throws { + if AppEnv.isParityRun { throw XCTSkip("PTB_PARITY=1 opens the gate on purpose") } + do { + _ = try await AntigravityRateLimitsProvider().fetch(allowKeychainPrompt: false) + XCTFail("a live Antigravity limits call ran inside swift test") + } catch LimitsError.liveFetchNotPermitted { + // Expected. + } catch { + throw XCTSkip("no credential available, the network boundary was not reached: \(error)") + } + } + + /// Keeps the next provider from shipping without the gate. Per-provider tests alone only cover the + /// providers someone remembered; this scan runs on every machine. + func testEveryNetworkingLimitsProviderIsGated() throws { + let core = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // PokeTokenBarTests + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // repo root + .appendingPathComponent("Sources/PokeTokenBar/Core") + var scanned: [String] = [] + var offenders: [String] = [] + + for name in try FileManager.default.contentsOfDirectory(atPath: core.path) + where name.hasSuffix("LimitsProvider.swift") { + let source = try String(contentsOf: core.appendingPathComponent(name), encoding: .utf8) + // Providers that never touch the network (Codex reads a local binary) are out of scope. + guard source.contains("URLSession") else { continue } + scanned.append(name) + // Comment lines do not count: a commented-out guard must not satisfy the scan. + let gated = source + .split(separator: "\n", omittingEmptySubsequences: false) + .map { $0.trimmingCharacters(in: .whitespaces) } + .contains { !$0.hasPrefix("//") && $0.contains("AppEnv.allowsLiveLimitsFetch") } + if !gated { offenders.append(name) } + } + + XCTAssertFalse(scanned.isEmpty, "nothing scanned — did the file naming convention change?") + XCTAssertTrue(offenders.isEmpty, """ + These limits providers can send the user's credentials to a live endpoint without a gate. \ + Add `guard AppEnv.allowsLiveLimitsFetch else { … }` right before the network call: \ + \(offenders.joined(separator: ", ")) + """) + } +} diff --git a/Tests/PokeTokenBarTests/UsageEnvironmentTests.swift b/Tests/PokeTokenBarTests/UsageEnvironmentTests.swift index 631f1c011..9c88bb54f 100644 --- a/Tests/PokeTokenBarTests/UsageEnvironmentTests.swift +++ b/Tests/PokeTokenBarTests/UsageEnvironmentTests.swift @@ -125,6 +125,7 @@ final class UsageEnvironmentTests: XCTestCase { /// - `CompanionStore`: `PTB_STATE_DIR` 은 개발/QA 격리용 /// - `OAuthLimitsProvider`: 의도적으로 프로세스 환경만 본다(자동 폴링 경로에서 셸 spawn 금지 — /// 해당 함수 주석 참조). 값이 필요한 사용량 스캔 쪽이 이미 셸 조회를 한다. + /// - `AppEnv`: the `PTB_PARITY` live-check flag — the same QA class as `PTB_STATE_DIR` above. func testNoProviderReadsUsageLocationEnvDirectly() throws { let allowed: Set = [ "UsageEnvironment.swift", @@ -132,6 +133,7 @@ final class UsageEnvironmentTests: XCTestCase { "CompanionStore.swift", "AppStatePaths.swift", "OAuthLimitsProvider.swift", + "AppEnv.swift", ] let sources = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() // PokeTokenBarTests From 746d8384c4187180c5298ac5db2abd9d47e6e3aa Mon Sep 17 00:00:00 2001 From: "Taeyang Jin (Theo)" Date: Fri, 25 Sep 2026 11:30:24 +0900 Subject: [PATCH 14/15] feat: pick Korean particles to match the name (#356) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Korean copy wrote both particle forms after runtime Pokémon names ("유니란이(가)", "듀란(으)로") because the name is not known when the string is translated. Pick the particle from the final syllable's coda instead, with ㄹ taking "로". - Add KoreanParticle (subject 이/가, object 을/를, direction 으로/로). Names that do not end in a Hangul syllable, such as Unown's "[A]" or a "#123" placeholder, keep the combined form. - Use it in the six Korean strings that wrote both forms. Other languages are unchanged. - Add the helper to the coverage gate's logic core. No new dependency: es-hangul is JavaScript, and the check is a few lines of Unicode arithmetic. Co-authored-by: Claude Opus 5.5 (1M context) --- .../PokeTokenBar/Core/KoreanParticle.swift | 39 +++++++++++++++ Sources/PokeTokenBar/Core/Localization.swift | 10 ++-- .../KoreanParticleTests.swift | 48 +++++++++++++++++++ scripts/test-gate.sh | 1 + 4 files changed, 93 insertions(+), 5 deletions(-) create mode 100644 Sources/PokeTokenBar/Core/KoreanParticle.swift create mode 100644 Tests/PokeTokenBarTests/KoreanParticleTests.swift diff --git a/Sources/PokeTokenBar/Core/KoreanParticle.swift b/Sources/PokeTokenBar/Core/KoreanParticle.swift new file mode 100644 index 000000000..17b44562b --- /dev/null +++ b/Sources/PokeTokenBar/Core/KoreanParticle.swift @@ -0,0 +1,39 @@ +import Foundation + +/// 한국어 조사 선택 — 런타임에 오는 이름(PokéAPI) 뒤에 받침에 맞는 조사를 붙인다. +/// 번역문에 "이(가)"처럼 병기하던 자리를 "피카츄가", "이상해씨가"처럼 자연스럽게 만든다. +/// +/// 판정은 마지막 글자가 한글 음절(U+AC00…U+D7A3)일 때만 한다. 그 밖(안농의 "[A]", 이름 로딩 전 +/// "#123" 등)은 받침을 알 수 없으므로 기존 병기 형태를 그대로 붙인다 — 틀린 조사보다 병기가 낫다. +enum KoreanParticle { + case subject // 이/가 + case object // 을/를 + case direction // 으로/로 + + func attach(to word: String) -> String { + guard let coda = Self.coda(of: word) else { return word + fallback } + switch self { + case .subject: return word + (coda == 0 ? "가" : "이") + case .object: return word + (coda == 0 ? "를" : "을") + // ㄹ 받침은 "으로"가 아니라 "로"(서울로·알로). + case .direction: return word + (coda == 0 || coda == Self.rieul ? "로" : "으로") + } + } + + private var fallback: String { + switch self { + case .subject: return "이(가)" + case .object: return "을(를)" + case .direction: return "(으)로" + } + } + + private static let syllables: ClosedRange = 0xAC00...0xD7A3 + private static let rieul: UInt32 = 8 + + /// 마지막 글자의 종성 인덱스(0 = 받침 없음). 한글 음절이 아니면 nil. + private static func coda(of word: String) -> UInt32? { + guard let last = word.unicodeScalars.last, syllables.contains(last.value) else { return nil } + return (last.value - syllables.lowerBound) % 28 + } +} diff --git a/Sources/PokeTokenBar/Core/Localization.swift b/Sources/PokeTokenBar/Core/Localization.swift index d1193e28a..caf19f86f 100644 --- a/Sources/PokeTokenBar/Core/Localization.swift +++ b/Sources/PokeTokenBar/Core/Localization.swift @@ -1067,15 +1067,15 @@ struct L { var statusFocus: String { t("지금은 집중 모드예요.", "In focus mode now.", "今は集中モードです。", "Ahora está en modo concentración.", "En mode concentration.", "Agora está em modo foco.", "Gerade voll konzentriert.") } var statusTired: String { t("한도에 가까워요. 잠깐 쉬어도 괜찮아요.", "Close to the limit. A short break is fine.", "上限が近いです。少し休んでも大丈夫。", "Está cerca del límite. Un pequeño descanso no vendría mal.", "Proche de la limite. Une petite pause ne fait pas de mal.", "Está perto do limite. Uma pausa cai bem.", "Fast am Limit. Eine kurze Pause tut gut.") } var statusSleep: String { t("지금은 자고 있어요.", "Sleeping now.", "今は眠っています。", "Ahora está durmiendo.", "En train de dormir.", "Agora está dormindo.", "Schläft gerade.") } - func statusEvolved(_ name: String) -> String { t("\(name)(으)로 진화했어요!", "Evolved into \(name)!", "\(name) に進化しました!", "¡Evolucionó a \(name)!", "A évolué en \(name) !", "Evoluiu para \(name)!", "Hat sich zu \(name) entwickelt!") } + func statusEvolved(_ name: String) -> String { t("\(KoreanParticle.direction.attach(to: name)) 진화했어요!", "Evolved into \(name)!", "\(name) に進化しました!", "¡Evolucionó a \(name)!", "A évolué en \(name) !", "Evoluiu para \(name)!", "Hat sich zu \(name) entwickelt!") } var statusGrew: String { t("성장했어요!", "It grew!", "成長しました!", "¡Ha crecido!", "Il a grandi !", "Cresceu!", "Ist gewachsen!") } // MARK: companion 이벤트 시스템 알림 var notifHatchTitle: String { t("🥚 부화!", "🥚 Hatched!", "🥚 孵化!", "🥚 ¡Eclosionó!", "🥚 Éclosion !", "🥚 Chocou!", "🥚 Geschlüpft!") } - func notifHatchBody(_ name: String) -> String { t("알에서 \(name)이(가) 나왔어요!", "\(name) hatched from the egg!", "タマゴから \(name) が生まれました!", "¡\(name) salió del huevo!", "\(name) est sorti de l'œuf !", "\(name) saiu do ovo!", "\(name) ist aus dem Ei geschlüpft!") } + func notifHatchBody(_ name: String) -> String { t("알에서 \(KoreanParticle.subject.attach(to: name)) 나왔어요!", "\(name) hatched from the egg!", "タマゴから \(name) が生まれました!", "¡\(name) salió del huevo!", "\(name) est sorti de l'œuf !", "\(name) saiu do ovo!", "\(name) ist aus dem Ei geschlüpft!") } var notifShinyHatchTitle: String { t("✨ 이로치 포켓몬!", "✨ Shiny Pokémon!", "✨ 色違いポケモン!", "✨ ¡Pokémon variocolor!", "✨ Pokémon chromatique !", "✨ Pokémon shiny!", "✨ Schillerndes Pokémon!") } /// 실제 판정에 쓴 분모를 받는다 — 부적이 있으면 64 가 아니다. - func notifShinyHatchBody(_ name: String, odds: UInt64) -> String { t("이로치 \(name)이(가) 태어났어요! (1/\(odds))", "A shiny \(name) hatched! (1 in \(odds))", "色違いの \(name) が生まれました!(1/\(odds))", "¡Nació un \(name) variocolor! (1 entre \(odds))", "Un \(name) chromatique est né ! (1 sur \(odds))", "Nasceu um \(name) shiny! (1 em \(odds))", "Ein schillerndes \(name) ist geschlüpft! (1/\(odds))") } + func notifShinyHatchBody(_ name: String, odds: UInt64) -> String { t("이로치 \(KoreanParticle.subject.attach(to: name)) 태어났어요! (1/\(odds))", "A shiny \(name) hatched! (1 in \(odds))", "色違いの \(name) が生まれました!(1/\(odds))", "¡Nació un \(name) variocolor! (1 entre \(odds))", "Un \(name) chromatique est né ! (1 sur \(odds))", "Nasceu um \(name) shiny! (1 em \(odds))", "Ein schillerndes \(name) ist geschlüpft! (1/\(odds))") } var eggImminent: String { t("곧 부화해요!", "About to hatch!", "もうすぐ孵化!", "¡Está a punto de eclosionar!", "Sur le point d'éclore !", "Está quase chocando!", "Schlüpft gleich!") } /// 첫 실행(아직 토큰 적립 0) 안내 — "왜 아무 일도 안 일어나지"를 방지. /// `amount` 는 성장 난이도가 반영된 부화 임계(포맷된 값) — 고정 5M 이 아니다. @@ -1088,7 +1088,7 @@ struct L { "Cresce com o uso das suas ferramentas locais de programação com IA. O ovo choca depois de uns \(amount) de tokens.", "Wächst mit der Nutzung deiner lokalen KI-Coding-Tools. Nach etwa \(amount) Tokens schlüpft dein Ei.") } var notifEvolveTitle: String { t("✨ 진화!", "✨ Evolved!", "✨ 進化!", "✨ ¡Evolucionó!", "✨ Évolution !", "✨ Evoluiu!", "✨ Entwicklung!") } - func notifEvolveBody(_ name: String) -> String { t("\(name)(으)로 진화했어요!", "Evolved into \(name)!", "\(name) に進化しました!", "¡Evolucionó a \(name)!", "A évolué en \(name) !", "Evoluiu para \(name)!", "Hat sich zu \(name) entwickelt!") } + func notifEvolveBody(_ name: String) -> String { t("\(KoreanParticle.direction.attach(to: name)) 진화했어요!", "Evolved into \(name)!", "\(name) に進化しました!", "¡Evolucionó a \(name)!", "A évolué en \(name) !", "Evoluiu para \(name)!", "Hat sich zu \(name) entwickelt!") } // 메타몽 위장 리빌 — 진화 못 하는 메타몽이 첫 진화 순간 정체를 드러낸다. var notifDittoRevealTitle: String { t("🎭 어라? 메타몽!", "🎭 Huh? It's Ditto!", "🎭 あれ?メタモン!", "🎭 ¿Eh? ¡Es Ditto!", "🎭 Hein ? C'est Métamorph !", "🎭 Ué? É um Ditto!", "🎭 Huch? Ditto!") } func notifDittoRevealBody(_ disguise: String) -> String { t("\(disguise)인 줄 알았는데 — 사실은 메타몽이었어요!", "You thought it was \(disguise) — it was Ditto all along!", "\(disguise) だと思ってた… 実はメタモンでした!", "Pensabas que era \(disguise) — ¡en realidad era Ditto!", "Tu croyais que c'était \(disguise) — c'était Métamorph depuis le début !", "Você achava que era \(disguise) — era um Ditto o tempo todo!", "Du dachtest, es wäre \(disguise) – dabei war es die ganze Zeit Ditto!") } @@ -1421,7 +1421,7 @@ struct L { return t("\(r) 이상 확정", "\(r) or better", "\(r) 以上確定", "\(r) o superior garantizado", "\(r) ou mieux garanti", "\(r) ou melhor garantido", "Garantiert \(r) oder besser") } func eggConfirm(_ monName: String, _ eggName: String) -> String { - t("\(monName)을(를) 놓아주고 \(eggName)(으)로 바꿀까요?", + t("\(KoreanParticle.object.attach(to: monName)) 놓아주고 \(KoreanParticle.direction.attach(to: eggName)) 바꿀까요?", "Send off \(monName) for the \(eggName)?", "\(monName) を手放して \(eggName) にしますか?", "¿Soltar a \(monName) y cambiarlo por \(eggName)?", diff --git a/Tests/PokeTokenBarTests/KoreanParticleTests.swift b/Tests/PokeTokenBarTests/KoreanParticleTests.swift new file mode 100644 index 000000000..2e25ba618 --- /dev/null +++ b/Tests/PokeTokenBarTests/KoreanParticleTests.swift @@ -0,0 +1,48 @@ +import XCTest +@testable import PokeTokenBar + +/// 한국어 조사 선택 — 이름의 마지막 음절 받침으로 고르고, 판정할 수 없으면 병기 형태를 유지한다. +final class KoreanParticleTests: XCTestCase { + + func testSubjectFollowsFinalConsonant() { + XCTAssertEqual(KoreanParticle.subject.attach(to: "유니란"), "유니란이") + XCTAssertEqual(KoreanParticle.subject.attach(to: "피카츄"), "피카츄가") + } + + func testObjectFollowsFinalConsonant() { + XCTAssertEqual(KoreanParticle.object.attach(to: "꼬부기"), "꼬부기를") + XCTAssertEqual(KoreanParticle.object.attach(to: "리자몽"), "리자몽을") + } + + /// "으로/로"는 받침 없음과 ㄹ 받침이 같은 쪽이다 — 이 분기가 빠지면 "알으로"가 된다. + func testDirectionTreatsRieulLikeNoFinalConsonant() { + XCTAssertEqual(KoreanParticle.direction.attach(to: "란쿨루스"), "란쿨루스로") + XCTAssertEqual(KoreanParticle.direction.attach(to: "듀란"), "듀란으로") + XCTAssertEqual(KoreanParticle.direction.attach(to: "희귀 알"), "희귀 알로") + XCTAssertEqual(KoreanParticle.direction.attach(to: "레트라"), "레트라로") + } + + /// 받침을 알 수 없는 끝(안농 글자, 이름 로딩 전 번호, 라틴 문자, 빈 문자열)은 병기 형태 그대로. + func testNonHangulEndingKeepsBothForms() { + XCTAssertEqual(KoreanParticle.subject.attach(to: "안농 [A]"), "안농 [A]이(가)") + XCTAssertEqual(KoreanParticle.object.attach(to: "#123"), "#123을(를)") + XCTAssertEqual(KoreanParticle.direction.attach(to: "Pikachu"), "Pikachu(으)로") + XCTAssertEqual(KoreanParticle.subject.attach(to: ""), "이(가)") + } + + /// 실제 문구 — 스크린샷으로 보고된 "유니란이(가)"·"듀란(으)로"·"란쿨루스(으)로"가 사라진다. + func testKoreanCopyUsesTheMatchingParticle() { + let l = L(.ko) + XCTAssertEqual(l.notifHatchBody("유니란"), "알에서 유니란이 나왔어요!") + XCTAssertEqual(l.notifShinyHatchBody("뚜벅쵸", odds: 48), "이로치 뚜벅쵸가 태어났어요! (1/48)") + XCTAssertEqual(l.notifEvolveBody("듀란"), "듀란으로 진화했어요!") + XCTAssertEqual(l.statusEvolved("란쿨루스"), "란쿨루스로 진화했어요!") + XCTAssertEqual(l.eggConfirm("레트라", l.eggName(.rare)), "레트라를 놓아주고 희귀 알로 바꿀까요?") + } + + /// 조사 선택은 한국어 문구에만 적용된다. + func testOtherLanguagesAreUntouched() { + XCTAssertEqual(L(.en).notifHatchBody("Unfezant"), "Unfezant hatched from the egg!") + XCTAssertEqual(L(.ja).notifEvolveBody("デスカーン"), "デスカーン に進化しました!") + } +} diff --git a/scripts/test-gate.sh b/scripts/test-gate.sh index 3981586eb..7d4614857 100755 --- a/scripts/test-gate.sh +++ b/scripts/test-gate.sh @@ -22,6 +22,7 @@ LOGIC_CORE=( "Sources/PokeTokenBar/Core/CompanionModel.swift" "Sources/PokeTokenBar/Core/UnownForm.swift" "Sources/PokeTokenBar/Core/CollectionWeight.swift" + "Sources/PokeTokenBar/Core/KoreanParticle.swift" "Sources/PokeTokenBar/Core/CompanionStore.swift" "Sources/PokeTokenBar/Core/PokemonProfile.swift" "Sources/PokeTokenBar/Core/PokemonNameLocalization.swift" From ae7bd9d37ff77862cce747e86eb54010890c6fbc Mon Sep 17 00:00:00 2001 From: Killian HERZER <114735716+KillianHzr@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:43:46 +0200 Subject: [PATCH 15/15] =?UTF-8?q?feat(dex):=20add=20search,=20sorting,=20a?= =?UTF-8?q?nd=20filters=20to=20Pok=C3=A9dex=20and=20Catch=20Log=20(#329)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(dex): add search, sorting, and filters to Pokedex and Catch Log - Add search bar matching by species name, # ID, and multilingual names - Add sorting options: Pokédex number asc/desc, name A-Z / Z-A, and rarity descending - Add Catch Log sorting options: most recent / oldest first, number asc/desc, name, and rarity - Add shiny-only filter toggle with visual indicator - Add empty search results state with reset filters button - Support all 7 application languages - Add comprehensive unit test suite in DexSearchAndFilterTests * test(dex): fix state initialization and ComparisonResult assertions in DexSearchAndFilterTests - Initialize CompanionStore via encoded file to avoid private state setter access - Use boolean comparison checks for ComparisonResult ordering - Remove unused store instance in localization test * fix(dex): share one case- and diacritic-insensitive search matcher Pokédex and Catch Log each matched names with localizedCaseInsensitiveContains, which keeps accents, so "flabebe" missed Flabébé. Both screens now use DexSearchMatcher. --------- Co-authored-by: Patrick.Park --- .../PokeTokenBar/Core/CompanionStore.swift | 221 ++++++++++++- Sources/PokeTokenBar/Core/Localization.swift | 32 ++ Sources/PokeTokenBar/UI/CompanionView.swift | 313 ++++++++++++++---- .../DexSearchAndFilterTests.swift | 271 +++++++++++++++ docs/reference/defect-log.md | 6 + 5 files changed, 775 insertions(+), 68 deletions(-) create mode 100644 Tests/PokeTokenBarTests/DexSearchAndFilterTests.swift diff --git a/Sources/PokeTokenBar/Core/CompanionStore.swift b/Sources/PokeTokenBar/Core/CompanionStore.swift index ce215b545..298c1637d 100644 --- a/Sources/PokeTokenBar/Core/CompanionStore.swift +++ b/Sources/PokeTokenBar/Core/CompanionStore.swift @@ -387,6 +387,28 @@ final class CompanionStore { /// 희귀도별 포획 로그 개수(요약 헤더용) — 개체 수 기준. 도감(종 단위)은 dexSpecies 를 쓴다. func dexCount(_ rarity: Rarity) -> Int { dexEntries.lazy.filter { $0.rarity == rarity }.count } + enum DexSortOption: String, CaseIterable, Identifiable, Sendable { + case numberAsc + case numberDesc + case nameAsc + case nameDesc + case rarityDesc + + var id: String { rawValue } + } + + enum CatchLogSortOption: String, CaseIterable, Identifiable, Sendable { + case recentFirst + case oldestFirst + case numberAsc + case numberDesc + case nameAsc + case nameDesc + case rarityDesc + + var id: String { rawValue } + } + /// 도감 한 칸 — 메인 목록은 종별, 안농 상세 목록은 폼별로 중복 기록을 합친다. /// **종 정보만 담는다** — 성격·획득 횟수처럼 개체에 딸린 것은 포획 로그가 개체 단위로 보여준다. struct DexSpecies: Sendable { @@ -397,6 +419,7 @@ final class CompanionStore { /// 이 종이 현재 키우는 개체의 **현재 형태**인가. 지나온 진화 단계에는 서지 않는다. let isRaising: Bool var unownForm: UnownForm? = nil + var names: [String: String]? = nil var hasNormal = false /// Species IDs remain Pokédex numbers; selection also includes the Unown letter. @@ -475,7 +498,203 @@ final class CompanionStore { rarity: a.rarity, isShiny: a.isShiny, isRaising: key.speciesID == state.active?.currentID && (!groupUnownForms || key.unownForm == currentUnownForm), - unownForm: key.unownForm, hasNormal: a.hasNormal) + unownForm: key.unownForm, + names: a.names, + hasNormal: a.hasNormal) + } + } + + /// Resolves the primary localized name of an individual final species for sorting and display. + func dexEntryName(_ entry: DexEntry) -> String { + let targetID = entry.finalID + if let stored = entry.names?[targetID], let localized = state.language.resolveName(stored) { + return localized + } + for id in entry.chainOrder.reversed() { + if let stored = entry.names?[id], let localized = state.language.resolveName(stored) { + return localized + } + } + return "#\(targetID)" + } + + /// The one search rule shared by the Pokédex grid and the Catch Log, so the two screens cannot + /// drift apart. A query matches a Pokédex number (`25` or `#25`) or part of any stored name + /// in any language, ignoring case and diacritics ("flabebe" finds Flabébé). + struct DexSearchMatcher: Sendable { + private let query: String + private let number: Int? + + /// `nil` for a blank query, which filters nothing out. + init?(_ rawQuery: String) { + let trimmed = rawQuery.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + query = trimmed + number = Int(trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed) + } + + func matches(_ species: DexSpecies) -> Bool { + matches(ids: [species.id], names: [species.name] + (species.names.map { Array($0.values) } ?? [])) + } + + /// An individual matches through any species of its chain, like its evolution line in the log row. + func matches(_ entry: DexEntry) -> Bool { + matches(ids: [entry.baseID, entry.finalID] + entry.chainOrder, + names: entry.names?.values.flatMap(\.values) ?? []) + } + + private func matches(ids: [Int], names: [String]) -> Bool { + if let number, ids.contains(number) { return true } + // Partial numbers keep working: "25" also finds #125, "#25" finds #25 and #250. + if ids.contains(where: { "#\($0)".contains(query) }) { return true } + return names.contains { $0.range(of: query, options: [.caseInsensitive, .diacriticInsensitive]) != nil } + } + } + + /// Filters and sorts DexSpecies for the Pokédex grid. + func filteredDexSpecies( + query: String = "", + rarity: Rarity? = nil, + shinyOnly: Bool = false, + sort: DexSortOption = .numberAsc + ) -> [DexSpecies] { + var result = dexSpecies + if let rarity { + result = result.filter { $0.rarity == rarity } + } + if shinyOnly { + result = result.filter { $0.isShiny } + } + if let matcher = DexSearchMatcher(query) { + result = result.filter { matcher.matches($0) } + } + return sortDexSpecies(result, by: sort) + } + + /// Sorts DexSpecies by the given option. + func sortDexSpecies(_ species: [DexSpecies], by sort: DexSortOption) -> [DexSpecies] { + switch sort { + case .numberAsc: + return species.sorted { $0.id < $1.id } + case .numberDesc: + return species.sorted { $0.id > $1.id } + case .nameAsc: + return species.sorted { + let cmp = $0.name.localizedCompare($1.name) + return cmp == .orderedAscending ? true : (cmp == .orderedSame ? $0.id < $1.id : false) + } + case .nameDesc: + return species.sorted { + let cmp = $0.name.localizedCompare($1.name) + return cmp == .orderedDescending ? true : (cmp == .orderedSame ? $0.id < $1.id : false) + } + case .rarityDesc: + return species.sorted { + if $0.rarity.sortRank != $1.rarity.sortRank { + return $0.rarity.sortRank > $1.rarity.sortRank + } + return $0.id < $1.id + } + } + } + + /// Filters and sorts DexEntry records for the Catch Log. + func filteredDexEntries( + query: String = "", + rarity: Rarity? = nil, + shinyOnly: Bool = false, + sort: CatchLogSortOption = .recentFirst + ) -> [DexEntry] { + var result = dexEntries + if let rarity { + result = result.filter { $0.rarity == rarity } + } + if shinyOnly { + result = result.filter { $0.isShiny } + } + if let matcher = DexSearchMatcher(query) { + result = result.filter { matcher.matches($0) } + } + return sortDexEntries(result, by: sort) + } + + /// Sorts DexEntry records by the given option. + func sortDexEntries(_ entries: [DexEntry], by sort: CatchLogSortOption) -> [DexEntry] { + switch sort { + case .recentFirst: + let active = entries.first(where: { isActiveDexEntry($0) }) + let graduated = entries.filter { !isActiveDexEntry($0) }.sorted { + let d0 = $0.caughtAt ?? .distantPast + let d1 = $1.caughtAt ?? .distantPast + if d0 != d1 { return d0 > d1 } + return $0.id < $1.id + } + return (active.map { [$0] } ?? []) + graduated + case .oldestFirst: + let active = entries.first(where: { isActiveDexEntry($0) }) + let graduated = entries.filter { !isActiveDexEntry($0) }.sorted { + let d0 = $0.caughtAt ?? .distantPast + let d1 = $1.caughtAt ?? .distantPast + if d0 != d1 { return d0 < d1 } + return $0.id < $1.id + } + return graduated + (active.map { [$0] } ?? []) + case .numberAsc: + return entries.sorted { + if $0.finalID != $1.finalID { + return $0.finalID < $1.finalID + } + let d0 = $0.caughtAt ?? .distantPast + let d1 = $1.caughtAt ?? .distantPast + if d0 != d1 { return d0 > d1 } + return $0.id < $1.id + } + case .numberDesc: + return entries.sorted { + if $0.finalID != $1.finalID { + return $0.finalID > $1.finalID + } + let d0 = $0.caughtAt ?? .distantPast + let d1 = $1.caughtAt ?? .distantPast + if d0 != d1 { return d0 > d1 } + return $0.id < $1.id + } + case .nameAsc: + return entries.sorted { + let name1 = dexEntryName($0) + let name2 = dexEntryName($1) + let cmp = name1.localizedCompare(name2) + if cmp != .orderedSame { + return cmp == .orderedAscending + } + let d0 = $0.caughtAt ?? .distantPast + let d1 = $1.caughtAt ?? .distantPast + if d0 != d1 { return d0 > d1 } + return $0.id < $1.id + } + case .nameDesc: + return entries.sorted { + let name1 = dexEntryName($0) + let name2 = dexEntryName($1) + let cmp = name1.localizedCompare(name2) + if cmp != .orderedSame { + return cmp == .orderedDescending + } + let d0 = $0.caughtAt ?? .distantPast + let d1 = $1.caughtAt ?? .distantPast + if d0 != d1 { return d0 > d1 } + return $0.id < $1.id + } + case .rarityDesc: + return entries.sorted { + if $0.rarity.sortRank != $1.rarity.sortRank { + return $0.rarity.sortRank > $1.rarity.sortRank + } + let d0 = $0.caughtAt ?? .distantPast + let d1 = $1.caughtAt ?? .distantPast + if d0 != d1 { return d0 > d1 } + return $0.id < $1.id + } } } diff --git a/Sources/PokeTokenBar/Core/Localization.swift b/Sources/PokeTokenBar/Core/Localization.swift index caf19f86f..e170527b0 100644 --- a/Sources/PokeTokenBar/Core/Localization.swift +++ b/Sources/PokeTokenBar/Core/Localization.swift @@ -998,6 +998,38 @@ struct L { var dexFilterHint: String { t("탭하면 이 희귀도만 보기 · 다시 탭하면 전체", "Tap to show only this rarity · tap again to clear", "タップでこの希少度のみ表示・再タップで全体", "Toca para ver solo esta rareza · toca de nuevo para ver todo", "Touche pour n'afficher que cette rareté · touche à nouveau pour tout afficher", "Toque para ver só esta raridade · toque de novo para ver tudo", "Tippe, um nur diese Seltenheit zu sehen · tippe erneut für alle") } /// 도감 칸의 ✨ 를 읽어주는 명사 — 이모지는 스크린리더가 일관되게 읽지 못한다. var dexShinyLabel: String { t("이로치", "Shiny", "色違い", "Variocolor", "Chromatique", "Shiny", "Schillernd") } + var dexSearchPlaceholder: String { t("이름 또는 #번호 검색…", "Search name or #…", "名前または#番号で検索…", "Buscar por nombre o #…", "Rechercher par nom ou #…", "Buscar por nome ou #…", "Nach Name oder # suchen…") } + var sortTitle: String { t("정렬", "Sort", "並び替え", "Ordenar", "Trier", "Ordenar", "Sortieren") } + var sortDexNumberAsc: String { t("도감 번호 (낮은 순)", "Number (Low to High)", "図鑑番号(昇順)", "Nº de Pokédex (asc.)", "N° de Pokédex (croissant)", "Nº da Pokédex (crescente)", "Nummer (aufsteigend)") } + var sortDexNumberDesc: String { t("도감 번호 (높은 순)", "Number (High to Low)", "図鑑番号(降順)", "Nº de Pokédex (desc.)", "N° de Pokédex (décroissant)", "Nº da Pokédex (decrescente)", "Nummer (absteigend)") } + var sortNameAsc: String { t("이름 (가나다·A-Z)", "Name (A–Z)", "名前(五十音・A-Z)", "Nombre (A–Z)", "Nom (A–Z)", "Nome (A–Z)", "Name (A–Z)") } + var sortNameDesc: String { t("이름 (역순)", "Name (Z–A)", "名前(逆順)", "Nombre (Z–A)", "Nom (Z–A)", "Nome (Z–A)", "Name (Z–A)") } + var sortRarity: String { t("희귀도 순", "Rarity (High to Low)", "希少度順", "Rareza (mayor a menor)", "Rareté (décroissante)", "Raridade (maior a menor)", "Seltenheit (absteigend)") } + var sortDateDesc: String { t("최근 잡은 순", "Most Recent", "最近捕獲", "Más recientes", "Plus récents", "Mais recentes", "Neueste zuerst") } + var sortDateAsc: String { t("오래된 순", "Oldest First", "古い順", "Más antiguos", "Plus anciens", "Mais antigos", "Älteste zuerst") } + var filterShinyOnly: String { t("이로치만", "Shiny only", "色違いのみ", "Solo variocolor", "Chromatique uniquement", "Apenas Shiny", "Nur schillernde") } + var noSearchResults: String { t("검색 결과가 없어요", "No Pokémon found", "見つかりませんでした", "No se encontraron Pokémon", "Aucun Pokémon trouvé", "Nenhum Pokémon encontrado", "Keine Pokémon gefunden") } + var clearFilters: String { t("필터 초기화", "Reset filters", "フィルターを解除", "Restablecer filtros", "Réinitialiser les filtres", "Redefinir filtros", "Filter zurücksetzen") } + func label(for option: CompanionStore.DexSortOption) -> String { + switch option { + case .numberAsc: return sortDexNumberAsc + case .numberDesc: return sortDexNumberDesc + case .nameAsc: return sortNameAsc + case .nameDesc: return sortNameDesc + case .rarityDesc: return sortRarity + } + } + func label(for option: CompanionStore.CatchLogSortOption) -> String { + switch option { + case .recentFirst: return sortDateDesc + case .oldestFirst: return sortDateAsc + case .numberAsc: return sortDexNumberAsc + case .numberDesc: return sortDexNumberDesc + case .nameAsc: return sortNameAsc + case .nameDesc: return sortNameDesc + case .rarityDesc: return sortRarity + } + } var dexNormalLabel: String { t("일반", "Normal", "通常", "Normal", "Normal", "Normal", "Normal") } var dexAppearance: String { t("모습", "Appearance", "姿", "Aspecto", "Apparence", "Aparência", "Aussehen") } var dexAppearancePreview: String { t("수집한 모습 · 종 정보", "Collected appearance · species reference", "収集した姿・種情報", "Aspecto coleccionado · datos de especie", "Apparence collectionnée · données de l’espèce", "Aparência coletada · dados da espécie", "Gesammeltes Aussehen · Speziesdaten") } diff --git a/Sources/PokeTokenBar/UI/CompanionView.swift b/Sources/PokeTokenBar/UI/CompanionView.swift index ee5ae62bf..2982c280e 100644 --- a/Sources/PokeTokenBar/UI/CompanionView.swift +++ b/Sources/PokeTokenBar/UI/CompanionView.swift @@ -788,76 +788,242 @@ struct DexSummaryHeader: View { struct CollectionView: View { let store: CompanionStore let navigation: PopoverNavigation - /// 로그 전용 희귀도 필터. 도감은 개수 단위가 종이라 자기 필터를 따로 갖는다(DexGridView). @State private var selectedRarity: Rarity? + @State private var searchText = "" + @State private var shinyOnly = false + @State private var dexSort: CompanionStore.DexSortOption = .numberAsc + @State private var logSort: CompanionStore.CatchLogSortOption = .recentFirst + @State private var detailCollectionID: String? /// 도감·로그 공통 높이 — 상점·가방과 같은 520. 세그먼트를 전환할 때도, 탭을 넘나들 때도 /// 팝오버가 리사이즈되지 않는다. - /// - /// 예산: 520 − 세그먼트 24 − 헤더 39 − 하단 줄 18 − 간격 24 = 격자 415. 6행 spacing 4 면 - /// 행이 65.8 이고, 칸 여백 6 과 이름 12 를 빼면 스프라이트에 47.8 이 남는다(현재 44). private static let contentHeight: CGFloat = 520 - /// 선택된 희귀도만 노출(없으면 전체). 상단 캡슐 토글로 설정. private var visibleEntries: [DexEntry] { - guard let r = selectedRarity else { return store.dexEntriesSorted } - return store.dexEntriesSorted.filter { $0.rarity == r } + store.filteredDexEntries( + query: searchText, + rarity: selectedRarity, + shinyOnly: shinyOnly, + sort: logSort + ) + } + + private var isCustomSortActive: Bool { + navigation.showingCollectionLog ? logSort != .recentFirst : dexSort != .numberAsc + } + + private func resetFilters() { + searchText = "" + shinyOnly = false + selectedRarity = nil + dexSort = .numberAsc + logSort = .recentFirst } var body: some View { @Bindable var nav = navigation if store.dexEntries.isEmpty { - emptyState // 둘 다 비어 있으니 세그먼트를 그리지 않는다 + emptyState + } else if let id = detailCollectionID, let species = store.dexSpecies.first(where: { $0.collectionID == id }) { + PokemonDetailView(store: store, species: species) { detailCollectionID = nil } + .id(species.collectionID) + .frame(height: Self.contentHeight) } else { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 6) { Picker("", selection: $nav.showingCollectionLog) { Text(store.l.dexTitle).tag(false) Text(store.l.catchLogTitle).tag(true) } .pickerStyle(.segmented) .labelsHidden() - if nav.showingCollectionLog { catchLog } else { DexGridView(store: store) } + + filterToolbar + + if nav.showingCollectionLog { + catchLog + } else { + DexGridView( + store: store, + searchText: searchText, + shinyOnly: shinyOnly, + sortOption: dexSort, + selectedRarity: $selectedRarity, + onSelectSpecies: { sp in detailCollectionID = sp.collectionID }, + onResetFilters: resetFilters + ) + } } .frame(height: Self.contentHeight) + .onChange(of: nav.showingCollectionLog) { + detailCollectionID = nil + } + } + } + + private var filterToolbar: some View { + HStack(spacing: 6) { + HStack(spacing: 4) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + .font(.system(size: 11)) + TextField(store.l.dexSearchPlaceholder, text: $searchText) + .textFieldStyle(.plain) + .font(.system(size: 11)) + if !searchText.isEmpty { + Button { + searchText = "" + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + .font(.system(size: 11)) + } + .buttonStyle(.plain) + .accessibilityLabel(store.l.clearFilters) + } + } + .padding(.horizontal, 6) + .padding(.vertical, 4) + .background(Color(nsColor: .controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .strokeBorder(Color.secondary.opacity(0.2), lineWidth: 0.5) + ) + + Button { + withAnimation(.easeInOut(duration: 0.15)) { + shinyOnly.toggle() + } + } label: { + Image(systemName: shinyOnly ? "sparkles" : "sparkle") + .font(.system(size: 11, weight: shinyOnly ? .bold : .regular)) + .foregroundStyle(shinyOnly ? Color.yellow : Color.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 4) + .background(shinyOnly ? Color.yellow.opacity(0.18) : Color(nsColor: .controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .strokeBorder(shinyOnly ? Color.yellow.opacity(0.6) : Color.secondary.opacity(0.2), lineWidth: 0.5) + ) + } + .buttonStyle(.plain) + .help(store.l.filterShinyOnly) + .accessibilityLabel(store.l.filterShinyOnly) + + Menu { + if navigation.showingCollectionLog { + ForEach(CompanionStore.CatchLogSortOption.allCases) { option in + Button { + logSort = option + } label: { + HStack { + Text(store.l.label(for: option)) + if logSort == option { + Image(systemName: "checkmark") + } + } + } + } + } else { + ForEach(CompanionStore.DexSortOption.allCases) { option in + Button { + dexSort = option + } label: { + HStack { + Text(store.l.label(for: option)) + if dexSort == option { + Image(systemName: "checkmark") + } + } + } + } + } + } label: { + Image(systemName: "arrow.up.arrow.down") + .font(.system(size: 11)) + .foregroundStyle(isCustomSortActive ? Color.accentColor : Color.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 4) + .background(isCustomSortActive ? Color.accentColor.opacity(0.12) : Color(nsColor: .controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .strokeBorder(isCustomSortActive ? Color.accentColor.opacity(0.5) : Color.secondary.opacity(0.2), lineWidth: 0.5) + ) + } + .menuStyle(.borderlessButton) + .fixedSize() + .help(store.l.sortTitle) + .accessibilityLabel(store.l.sortTitle) } } /// 포획 로그 — 개체 단위 기록. 필터(요약 헤더)는 고정, 목록만 스크롤한다 - /// (아래로 내리는 중에도 희귀도 필터를 토글할 수 있다). private var catchLog: some View { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 6) { DexSummaryHeader(store: store, selected: selectedRarity) { r in withAnimation(.easeInOut(duration: 0.15)) { selectedRarity = (selectedRarity == r) ? nil : r } } - // maxHeight 는 팝오버 재오픈 시 ScrollView fitting size 가 작게 잡혀 크기가 줄어드는 - // 문제가 있어, 바깥 VStack 을 height 로 고정해 스크롤 영역이 나머지를 채우게 한다. - ScrollViewReader { proxy in - ScrollView { - // 로그는 계속 쌓인다. 화면 밖 행까지 생성하면 진화 라인의 스프라이트 로딩과 - // 레이아웃도 전부 진입 시 실행되므로, 보이는 행부터 생성한다. - LazyVStack(alignment: .leading, spacing: 8) { - Color.clear.frame(height: 0).id("dexTop") // 스크롤 최상단 앵커 - ForEach(visibleEntries) { entry in - DexEntryRow(store: store, entry: entry) + if visibleEntries.isEmpty { + emptySearchResults + } else { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 8) { + Color.clear.frame(height: 0).id("dexTop") + ForEach(visibleEntries) { entry in + DexEntryRow(store: store, entry: entry) + } } + .reservesScrollerLane() + } + .frame(maxHeight: .infinity) + // 필터·검색·정렬 변경 시 새 결과를 처음부터 보여준다. + .onChange(of: selectedRarity) { + withAnimation(.easeInOut(duration: 0.2)) { proxy.scrollTo("dexTop", anchor: .top) } + } + .onChange(of: searchText) { + withAnimation(.easeInOut(duration: 0.2)) { proxy.scrollTo("dexTop", anchor: .top) } + } + .onChange(of: shinyOnly) { + withAnimation(.easeInOut(duration: 0.2)) { proxy.scrollTo("dexTop", anchor: .top) } + } + .onChange(of: logSort) { + withAnimation(.easeInOut(duration: 0.2)) { proxy.scrollTo("dexTop", anchor: .top) } } - .reservesScrollerLane() } - .frame(maxHeight: .infinity) - // 필터 토글 시 목록 최상단으로 — 이전 스크롤 위치가 새 필터 결과 밖이어도 처음부터 보이게. - .onChange(of: selectedRarity) { - withAnimation(.easeInOut(duration: 0.2)) { proxy.scrollTo("dexTop", anchor: .top) } + } + } + } + + private var emptySearchResults: some View { + VStack(spacing: 8) { + Spacer() + Image(systemName: "magnifyingglass") + .font(.system(size: 24)) + .foregroundStyle(.tertiary) + Text(store.l.noSearchResults) + .font(.callout.weight(.medium)) + .foregroundStyle(.secondary) + Button(store.l.clearFilters) { + withAnimation(.easeInOut(duration: 0.15)) { + resetFilters() } } + .buttonStyle(.bordered) + .controlSize(.small) + Spacer() } + .frame(maxWidth: .infinity, maxHeight: .infinity) } /// 빈 도감 — 안내 마스코트(피카츄, PokéAPI) + 포켓몬을 모으라는 문구. private var emptyState: some View { VStack(spacing: 10) { - SpriteView(speciesID: 25, size: 96, animated: true) // 피카츄(움직임) + SpriteView(speciesID: 25, size: 96, animated: true) Text(store.l.dexEmptyTitle).font(.callout.weight(.semibold)) Text(store.l.dexEmptyHint) .font(.caption).foregroundStyle(.secondary) @@ -894,20 +1060,19 @@ struct RepresentativeFooterButton: View { } /// 도감 — 보유 종만 도감 번호순으로, 한 페이지 24칸(4열×6행) 고정 격자. -/// -/// 페이지식이라 ScrollView 를 쓰지 않는다 — 팝오버 재오픈 시 fitting size 가 줄어드는 기존 결함을 -/// 우회(고정 높이 + maxHeight)가 아니라 회피로 피한다. 페이지 크기가 고정이라 모든 칸이 항상 -/// 렌더되므로 지연 격자(LazyVGrid)도 필요 없다 — 평범한 VStack/HStack 으로 동기 렌더한다. -/// 미보유 종은 아예 그리지 않는다(물음표·실루엣 칸 없음). @MainActor private struct DexGridView: View { let store: CompanionStore - @State private var selectedRarity: Rarity? - @State private var page = 0 + let searchText: String + let shinyOnly: Bool + let sortOption: CompanionStore.DexSortOption + @Binding var selectedRarity: Rarity? + let onSelectSpecies: (CompanionStore.DexSpecies) -> Void + let onResetFilters: () -> Void + @State private var page = 0 /// 선택한 칸 — 하단 줄에 희귀도를 띄우고, 이로치를 잡은 종이면 스프라이트를 그 색으로 바꾼다. @State private var selectedID: String? - @State private var detailCollectionID: String? private static let columns = 4 private static let rows = 6 @@ -915,38 +1080,58 @@ private struct DexGridView: View { private static let spacing: CGFloat = 4 var body: some View { - // 종별 집계는 한 번만 훑고 하위로 넘긴다 — 칸마다 재집계하면 도감이 O(칸×도감) 이 된다. let all = store.dexSpecies - let visible = selectedRarity.map { r in all.filter { $0.rarity == r } } ?? all + let visible = store.filteredDexSpecies( + query: searchText, + rarity: selectedRarity, + shinyOnly: shinyOnly, + sort: sortOption + ) let pageCount = max(1, (visible.count + Self.pageSize - 1) / Self.pageSize) - let current = min(page, pageCount - 1) // 보유 종이 줄어든 경우(필터 등) 범위 방어 + let current = min(page, pageCount - 1) let slice = Array(visible.dropFirst(current * Self.pageSize).prefix(Self.pageSize)) - Group { - if let id = detailCollectionID, let species = all.first(where: { $0.collectionID == id }) { - PokemonDetailView(store: store, species: species) { detailCollectionID = nil } - .id(species.collectionID) + + VStack(alignment: .leading, spacing: 6) { + header(all) + if visible.isEmpty { + emptySearchResults } else { - VStack(alignment: .leading, spacing: 8) { - header(all) - grid(slice) - footer(slice, current: current, pageCount: pageCount) - } + grid(slice) + footer(slice, current: current, pageCount: pageCount) } } - // 이름이 저장돼 있지 않은 구버전 졸업분을 채운다 — 격자는 저장분만 읽으므로 이게 없으면 - // 칸이 `#41` 로 남는다. 저장된 항목은 조회하지 않으므로 채워진 뒤로는 아무 일도 하지 않는다. + .onChange(of: searchText) { page = 0; selectedID = nil } + .onChange(of: shinyOnly) { page = 0; selectedID = nil } + .onChange(of: sortOption) { page = 0; selectedID = nil } + .onChange(of: selectedRarity) { page = 0; selectedID = nil } .task { await store.backfillMissingDexNames() } } - /// 희귀도 필터 — 로그와 같은 RarityTally 를 쓰되 개수는 **종 단위**다. - /// (DexSummaryHeader 는 개체 수 dexCount 를 내부에서 직접 부르므로 재사용하려면 시그니처를 바꿔 - /// 로그 경로까지 건드려야 한다. 캡슐 4개짜리 헤더라 여기서는 인라인으로 둔다.) + private var emptySearchResults: some View { + VStack(spacing: 8) { + Spacer() + Image(systemName: "magnifyingglass") + .font(.system(size: 24)) + .foregroundStyle(.tertiary) + Text(store.l.noSearchResults) + .font(.callout.weight(.medium)) + .foregroundStyle(.secondary) + Button(store.l.clearFilters) { + withAnimation(.easeInOut(duration: 0.15)) { + onResetFilters() + } + } + .buttonStyle(.bordered) + .controlSize(.small) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + private func header(_ all: [CompanionStore.DexSpecies]) -> some View { VStack(alignment: .leading, spacing: 5) { HStack(spacing: 6) { Text(store.l.dexTitle).font(.callout.weight(.semibold)) - // 총계는 필터와 무관한 전체 종 수 — 로그 헤더(dexTotal)와 같은 규칙. - // 필터 중인 희귀도의 개수는 아래 캡슐이 이미 보여준다. Text(store.l.dexSpeciesTotal(all.count)).font(.caption2).foregroundStyle(.secondary) } HStack(spacing: 4) { @@ -955,24 +1140,21 @@ private struct DexGridView: View { Button { withAnimation(.easeInOut(duration: 0.15)) { selectedRarity = (selectedRarity == r) ? nil : r - page = 0 // 필터가 바뀌면 페이지 범위도 바뀐다 — 항상 첫 페이지부터 - selectedID = nil // 선택한 칸이 필터 밖으로 나가면 하단 줄이 유령 정보를 남긴다 + page = 0 + selectedID = nil } } label: { RarityTally(label: store.l.rarityLabel(r), count: count, color: rarityColor(r), isSelected: selectedRarity == r) } .buttonStyle(.plain) - .disabled(count == 0) // 0종 희귀도는 필터 불가 + .disabled(count == 0) .help(store.l.dexFilterHint) } } } } - /// 고정 격자 — 남는 칸은 투명(테두리·물음표 없이 정렬만 유지). - /// 모든 행에 maxHeight 를 걸어 6행이 높이를 균등 분할하게 한다 — 빈 칸의 Color 는 유연 크기라, - /// 행마다 안 걸면 빈 행이 늘어나 채워진 행을 짓누른다(보유 종이 적을 때 첫 줄이 찌그러짐). private func grid(_ slice: [CompanionStore.DexSpecies]) -> some View { let unownFormCount = store.state.collectedUnownForms.count return VStack(spacing: Self.spacing) { @@ -987,7 +1169,7 @@ private struct DexGridView: View { isRepresentative: store.isRepresentative(sp), unownFormCount: unownFormCount) { selectedID = sp.collectionID - detailCollectionID = sp.collectionID + onSelectSpecies(sp) } .frame(maxWidth: .infinity) } else { @@ -1001,9 +1183,6 @@ private struct DexGridView: View { .frame(maxHeight: .infinity) } - /// 하단 한 줄 — 왼쪽은 선택한 칸의 희귀도, 오른쪽은 페이저. - /// 페이저가 1페이지라 안 보일 때도 이 줄을 **항상** 예약한다 — 페이지 수나 선택 여부에 따라 - /// 격자 높이가 흔들리지 않게. private func footer(_ slice: [CompanionStore.DexSpecies], current: Int, pageCount: Int) -> some View { HStack(spacing: 8) { @@ -1387,7 +1566,7 @@ private struct DexSpeciesCell: View { /// 로그(56)보다 작다 — 24칸 격자에 이름까지 담아야 한다. 원본 96×96 픽셀아트를 /// interpolation(.none) 으로 축소하므로 이 크기에서도 식별에 문제없다. - private static let thumb: CGFloat = 44 + private static let thumb: CGFloat = 40 var body: some View { Button(action: onTap) { @@ -1425,7 +1604,7 @@ private struct DexSpeciesCell: View { .accessibilityLabel(store.l.dexShinyLabel) } } - .padding(3) + .padding(2) // 대표 = 영속적인 accent 배경, 방금 클릭한 칸 = 기존 accent 테두리. // 서로 다른 카드여도 같은 강조 두 개가 선택된 것처럼 보이지 않는다. .background(isRepresentative ? Color.accentColor.opacity(0.16) diff --git a/Tests/PokeTokenBarTests/DexSearchAndFilterTests.swift b/Tests/PokeTokenBarTests/DexSearchAndFilterTests.swift new file mode 100644 index 000000000..d192f840f --- /dev/null +++ b/Tests/PokeTokenBarTests/DexSearchAndFilterTests.swift @@ -0,0 +1,271 @@ +import XCTest +@testable import PokeTokenBar + +private struct MockPokeProvider: PokeProviding { + func line(baseSpeciesID: Int) async throws -> EvoLine { throw URLError(.notConnectedToInternet) } + func baseSpeciesIndex() async throws -> [BaseSpecies] { [] } + func baseSpecies(id: Int) async throws -> BaseSpecies? { nil } +} + +@MainActor +final class DexSearchAndFilterTests: XCTestCase { + private let now = Date(timeIntervalSince1970: 1_700_000_000) + + private func createTestStore() -> CompanionStore { + let entryBulbasaur = DexEntry( + id: "entry-1", + baseID: 1, + finalID: 3, + chainOrder: [1, 2, 3], + rarity: .common, + caughtAt: Date(timeIntervalSince1970: 1_700_000_100), + isShiny: false, + names: [ + 1: ["ko": "이상해씨", "en": "Bulbasaur", "fr": "Bulbizarre"], + 2: ["ko": "이상해풀", "en": "Ivysaur", "fr": "Herbizarre"], + 3: ["ko": "이상해꽃", "en": "Venusaur", "fr": "Florizarre"] + ] + ) + let entryPikachu = DexEntry( + id: "entry-2", + baseID: 25, + finalID: 26, + chainOrder: [25, 26], + rarity: .rare, + caughtAt: Date(timeIntervalSince1970: 1_700_000_200), + isShiny: true, + names: [ + 25: ["ko": "피카츄", "en": "Pikachu", "fr": "Pikachu"], + 26: ["ko": "라이츄", "en": "Raichu", "fr": "Raichu"] + ] + ) + let entryMewtwo = DexEntry( + id: "entry-3", + baseID: 150, + finalID: 150, + chainOrder: [150], + rarity: .legendary, + caughtAt: Date(timeIntervalSince1970: 1_700_000_300), + isShiny: false, + names: [ + 150: ["ko": "뮤츠", "en": "Mewtwo", "fr": "Mewtwo"] + ] + ) + + return makeStore(dex: [entryBulbasaur, entryPikachu, entryMewtwo]) + } + + private func makeStore(dex: [DexEntry]) -> CompanionStore { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("dex-search-\(UUID().uuidString).json") + var state = CompanionState() + state.dex = dex + state.language = .en + try? JSONEncoder().encode(state).write(to: url) + + return CompanionStore(provider: MockPokeProvider(), clock: { self.now }, fileURL: url, rng: SeededRNG(seed: 42)) + } + + func testDexSpeciesSearchByIDAndName() { + let store = createTestStore() + + // Search by number (exact and with # prefix) + let byID = store.filteredDexSpecies(query: "25") + XCTAssertTrue(byID.contains(where: { $0.id == 25 })) + XCTAssertFalse(byID.contains(where: { $0.id == 1 })) + + let byHashID = store.filteredDexSpecies(query: "#150") + XCTAssertEqual(byHashID.count, 1) + XCTAssertEqual(byHashID.first?.id, 150) + + // Partial number, with or without the # prefix + XCTAssertEqual(store.filteredDexSpecies(query: "15").map(\.id), [150]) + XCTAssertEqual(store.filteredDexEntries(query: "#15").map(\.id), ["entry-3"]) + + // Search by English name + let byName = store.filteredDexSpecies(query: "Venusaur") + XCTAssertEqual(byName.count, 1) + XCTAssertEqual(byName.first?.id, 3) + + // Case-insensitive + let byCaseInsensitive = store.filteredDexSpecies(query: "pika") + XCTAssertTrue(byCaseInsensitive.contains(where: { $0.id == 25 })) + + // Multilingual search (French name for Bulbasaur) + let byFrenchName = store.filteredDexSpecies(query: "Bulbizarre") + XCTAssertTrue(byFrenchName.contains(where: { $0.id == 1 })) + + // Korean name + let byKoreanName = store.filteredDexSpecies(query: "뮤츠") + XCTAssertEqual(byKoreanName.count, 1) + XCTAssertEqual(byKoreanName.first?.id, 150) + + // No match + let noMatch = store.filteredDexSpecies(query: "MissingNo") + XCTAssertTrue(noMatch.isEmpty) + + // Empty query matches all + let all = store.filteredDexSpecies(query: "") + XCTAssertEqual(all.count, store.dexSpecies.count) + } + + /// Regression: both screens used `localizedCaseInsensitiveContains`, which keeps accents, so + /// "flabebe" missed Flabébé although the PR promised diacritic-insensitive name search. + func testNameSearchIgnoresCaseAndDiacriticsInPokedexAndCatchLog() { + let flabebe = DexEntry( + id: "entry-flabebe", + baseID: 669, + finalID: 669, + chainOrder: [669], + rarity: .common, + caughtAt: Date(timeIntervalSince1970: 1_700_000_400), + names: [669: ["ko": "플라베베", "en": "Flabébé", "fr": "Flabébé"]] + ) + let pikachu = DexEntry( + id: "entry-pikachu", + baseID: 25, + finalID: 25, + chainOrder: [25], + rarity: .rare, + caughtAt: Date(timeIntervalSince1970: 1_700_000_500), + names: [25: ["ko": "피카츄", "en": "Pikachu", "fr": "Pikachu"]] + ) + let store = makeStore(dex: [flabebe, pikachu]) + + for query in ["flabebe", "FLABEBE", "Flabébé", "flabébe"] { + XCTAssertEqual(store.filteredDexSpecies(query: query).map(\.id), [669], "Pokédex: \(query)") + XCTAssertEqual(store.filteredDexEntries(query: query).map(\.id), ["entry-flabebe"], "Catch Log: \(query)") + } + } + + func testDexSpeciesFilterByShinyAndRarity() { + let store = createTestStore() + + // Shiny only + let shinyOnly = store.filteredDexSpecies(shinyOnly: true) + XCTAssertTrue(shinyOnly.allSatisfy { $0.isShiny }) + XCTAssertTrue(shinyOnly.contains(where: { $0.id == 25 })) + XCTAssertFalse(shinyOnly.contains(where: { $0.id == 150 })) + + // Rarity only + let legendaries = store.filteredDexSpecies(rarity: .legendary) + XCTAssertEqual(legendaries.count, 1) + XCTAssertEqual(legendaries.first?.id, 150) + + let commons = store.filteredDexSpecies(rarity: .common) + XCTAssertEqual(commons.count, 3) // 1, 2, 3 + + // Combined shiny + rarity + let shinyRare = store.filteredDexSpecies(rarity: .rare, shinyOnly: true) + XCTAssertEqual(shinyRare.count, 2) // 25, 26 + + let shinyLegendary = store.filteredDexSpecies(rarity: .legendary, shinyOnly: true) + XCTAssertTrue(shinyLegendary.isEmpty) + } + + func testDexSpeciesSorting() { + let store = createTestStore() + + // Number Asc + let numAsc = store.filteredDexSpecies(sort: .numberAsc) + let numAscIDs = numAsc.map(\.id) + XCTAssertEqual(numAscIDs, numAscIDs.sorted()) + + // Number Desc + let numDesc = store.filteredDexSpecies(sort: .numberDesc) + let numDescIDs = numDesc.map(\.id) + XCTAssertEqual(numDescIDs, numDescIDs.sorted(by: >)) + + // Name Asc + let nameAsc = store.filteredDexSpecies(sort: .nameAsc) + let names = nameAsc.map(\.name) + for i in 0..<(names.count - 1) { + let result = names[i].localizedCompare(names[i + 1]) + XCTAssertTrue(result == .orderedAscending || result == .orderedSame) + } + + // Name Desc + let nameDesc = store.filteredDexSpecies(sort: .nameDesc) + let descNames = nameDesc.map(\.name) + for i in 0..<(descNames.count - 1) { + let result = descNames[i].localizedCompare(descNames[i + 1]) + XCTAssertTrue(result == .orderedDescending || result == .orderedSame) + } + + // Rarity Desc + let rarityDesc = store.filteredDexSpecies(sort: .rarityDesc) + for i in 0..<(rarityDesc.count - 1) { + XCTAssertGreaterThanOrEqual(rarityDesc[i].rarity.sortRank, rarityDesc[i + 1].rarity.sortRank) + } + } + + func testCatchLogSearchAndFilters() { + let store = createTestStore() + + // Search by chain species ID + let matchChain = store.filteredDexEntries(query: "25") + XCTAssertEqual(matchChain.count, 1) + XCTAssertEqual(matchChain.first?.baseID, 25) + + // Search by name + let matchName = store.filteredDexEntries(query: "Raichu") + XCTAssertEqual(matchName.count, 1) + XCTAssertEqual(matchName.first?.baseID, 25) + + // Shiny only + let shinies = store.filteredDexEntries(shinyOnly: true) + XCTAssertEqual(shinies.count, 1) + XCTAssertEqual(shinies.first?.baseID, 25) + + // Rarity + let legendaryEntries = store.filteredDexEntries(rarity: .legendary) + XCTAssertEqual(legendaryEntries.count, 1) + XCTAssertEqual(legendaryEntries.first?.baseID, 150) + } + + func testCatchLogSorting() { + let store = createTestStore() + + // Recent first + let recent = store.filteredDexEntries(sort: .recentFirst) + XCTAssertEqual(recent.first?.finalID, 150) // caughtAt 300 is most recent + XCTAssertEqual(recent.last?.finalID, 3) // caughtAt 100 is oldest + + // Oldest first + let oldest = store.filteredDexEntries(sort: .oldestFirst) + XCTAssertEqual(oldest.first?.finalID, 3) + XCTAssertEqual(oldest.last?.finalID, 150) + + // Number asc + let numAsc = store.filteredDexEntries(sort: .numberAsc) + XCTAssertEqual(numAsc.map(\.finalID), [3, 26, 150]) + + // Number desc + let numDesc = store.filteredDexEntries(sort: .numberDesc) + XCTAssertEqual(numDesc.map(\.finalID), [150, 26, 3]) + + // Rarity desc + let rarityDesc = store.filteredDexEntries(sort: .rarityDesc) + XCTAssertEqual(rarityDesc.map(\.rarity), [.legendary, .rare, .common]) + } + + func testSortOptionLabelsExistInAllLanguages() { + let languages: [AppLanguage] = [.ko, .en, .ja, .es, .fr, .pt, .de] + + for lang in languages { + let l = L(lang) + for option in CompanionStore.DexSortOption.allCases { + let label = l.label(for: option) + XCTAssertFalse(label.isEmpty, "Missing DexSortOption label for \(option) in \(lang)") + } + for option in CompanionStore.CatchLogSortOption.allCases { + let label = l.label(for: option) + XCTAssertFalse(label.isEmpty, "Missing CatchLogSortOption label for \(option) in \(lang)") + } + XCTAssertFalse(l.dexSearchPlaceholder.isEmpty) + XCTAssertFalse(l.sortTitle.isEmpty) + XCTAssertFalse(l.filterShinyOnly.isEmpty) + XCTAssertFalse(l.noSearchResults.isEmpty) + XCTAssertFalse(l.clearFilters.isEmpty) + } + } +} diff --git a/docs/reference/defect-log.md b/docs/reference/defect-log.md index adbe4d115..81c832c08 100644 --- a/docs/reference/defect-log.md +++ b/docs/reference/defect-log.md @@ -730,6 +730,12 @@ read_when: 리터럴 안의 `//` 를 주석으로 오인하면 진짜 결함을 놓친다(역검증에서 이 케이스로 반증함). 새 프로바이더 UI 를 붙일 땐 같은 부류의 형제 문구(여기선 `claudeAuthExpiredTitle/Hint`)를 먼저 찾아 문안 구조까지 맞춘다 — 문구만 새로 지으면 같은 화면에서 두 안내가 다른 말투로 갈린다. +- **`localizedCaseInsensitiveContains` ignores case, not diacritics.** Pokédex and Catch Log search + promised accent-insensitive names but each screen had its own matcher built on it, so "flabebe" + missed Flabébé; the tests only searched ASCII names. Name search goes through the one shared + `CompanionStore.DexSearchMatcher` (`.caseInsensitive` + `.diacriticInsensitive`), and any new + searchable list reuses it instead of adding a matcher. Guard: + `testNameSearchIgnoresCaseAndDiacriticsInPokedexAndCatchLog` queries both screens. - **팝오버 세로 `ScrollView` 는 콘텐츠 안쪽에 스크롤러 레인을 비운다 — `.reservesScrollerLane()`.** 홈 탭을 `ScrollView` + 고정 520pt 로 감싸자(상점·가방·도감과 같은 높이), 오른쪽 끝에 붙은 수치(오늘 비용·Peak·추이 막대