From 2d5c5c01b0eb07763403556ede0ebaed11fa7b28 Mon Sep 17 00:00:00 2001 From: LeeTaek <28972501+LeeTaek@users.noreply.github.com> Date: Tue, 6 Oct 2026 09:47:30 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20fetch=20version-specific=20Pok?= =?UTF-8?q?=C3=A9dex=20entries=20per=20species=20and=20language?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One GraphQL query returns the requested language's entries with localized version names. Any GraphQL failure (transport, HTTP status, an `errors` payload, an undecodable body) falls back to REST; that result carries slug labels, so it is marked degraded and never cached. A language with no entries at all (PokéAPI has no `pt`) is answered in English. PokemonFlavorTextProviding is its own protocol, like PokemonDetailProviding, so existing test doubles stay untouched. The client's transport is injectable for tests. Co-Authored-By: Claude Opus 5.5 --- Sources/PokeTokenBar/Core/PokeAPIClient.swift | 8 +- .../PokeTokenBar/Core/PokemonFlavorText.swift | 205 ++++++++++++++++ .../DexFlavorTextTests.swift | 227 ++++++++++++++++++ 3 files changed, 439 insertions(+), 1 deletion(-) create mode 100644 Sources/PokeTokenBar/Core/PokemonFlavorText.swift create mode 100644 Tests/PokeTokenBarTests/DexFlavorTextTests.swift diff --git a/Sources/PokeTokenBar/Core/PokeAPIClient.swift b/Sources/PokeTokenBar/Core/PokeAPIClient.swift index 2b8814bca..d16cf5f9d 100644 --- a/Sources/PokeTokenBar/Core/PokeAPIClient.swift +++ b/Sources/PokeTokenBar/Core/PokeAPIClient.swift @@ -23,7 +23,7 @@ protocol PokemonDetailProviding: Sendable { /// PokéAPI 클라이언트 — 종/진화체인을 런타임 fetch + 파싱. 포켓몬 데이터는 레포에 번들하지 않는다. /// species 응답은 actor 캐시(다국어 이름 재사용). -actor PokeAPIClient: PokeProviding, PokemonDetailProviding { +actor PokeAPIClient: PokeProviding, PokemonDetailProviding, PokemonFlavorTextProviding { static let shared = PokeAPIClient() private let base = URL(string: "https://pokeapi.co/api/v2")! static var langCodes: [String] { AppLanguage.allCases.flatMap(\.apiCodes) } @@ -81,6 +81,12 @@ actor PokeAPIClient: PokeProviding, PokemonDetailProviding { } } + /// Lets `CompanionStore` pick up flavor text from its default provider the same way it picks up + /// details, while stub providers in tests stay offline. + func flavorTexts(speciesID: Int, language: AppLanguage) async throws -> DexEntries { + try await PokemonFlavorTextClient.shared.flavorTexts(speciesID: speciesID, language: language) + } + /// Reduce PokéAPI's cross-generation move history at the trust boundary, before it reaches /// either memory or disk. Moves absent from the supported version group are omitted entirely. static func normalizedMoves(_ moves: [PokemonMoveDTO]) -> [PokemonMoveOption] { diff --git a/Sources/PokeTokenBar/Core/PokemonFlavorText.swift b/Sources/PokeTokenBar/Core/PokemonFlavorText.swift new file mode 100644 index 000000000..7263e9a20 --- /dev/null +++ b/Sources/PokeTokenBar/Core/PokemonFlavorText.swift @@ -0,0 +1,205 @@ +import Foundation + +/// One Pokédex entry: the game version it appeared in and its text. +struct DexFlavorText: Sendable, Equatable, Identifiable { + /// PokéAPI `version.name` — stable across languages and refetches. + let versionKey: String + /// PokéAPI `version.id`, which follows release order. + let versionID: Int + /// Localized version name, or the formatted slug when none is available. + let versionLabel: String + /// One paragraph, without the line breaks that fit the games' text boxes. + let text: String + var id: String { versionKey } +} + +/// The entries for one species and the language that actually filled them. +struct DexEntries: Sendable, Equatable { + /// Oldest version first, newest last. + let entries: [DexFlavorText] + /// English when the requested language has no entries at all (PokéAPI has no `pt`). + let language: AppLanguage + /// Served by the REST fallback, whose version labels are slugs. Shown, but never treated as final. + var isDegraded = false +} + +/// Flavor text is the first per-language PokéAPI request: everything else caches every language at +/// once. A response therefore belongs to a species *and* a language and must never land under another. +struct FlavorTextRequest: Hashable, Sendable { + let speciesID: Int + let language: AppLanguage +} + +/// Separate from `PokeProviding` so existing evolution-only test doubles stay small. +protocol PokemonFlavorTextProviding: Sendable { + func flavorTexts(speciesID: Int, language: AppLanguage) async throws -> DexEntries +} + +/// A source-independent entry (GraphQL row or REST entry) before collapsing. +struct FlavorRow: Sendable { + let versionKey: String + let versionID: Int + let languageCode: String + let text: String + /// Language code → localized version name. Empty on the REST fallback. + let labels: [String: String] +} + +/// Version-specific Pokédex entries. One GraphQL query returns the entries and the localized version +/// names. Any GraphQL failure — transport, HTTP status, an `errors` payload without `data`, or a body +/// that does not decode — falls back to REST, whose slug-labelled result is never cached. +actor PokemonFlavorTextClient: PokemonFlavorTextProviding { + static let shared = PokemonFlavorTextClient() + + private static let graphQLURL = URL(string: "https://graphql.pokeapi.co/v1beta2")! + private let fetch: @Sendable (URLRequest) async throws -> (Data, Int) + private var cache: [FlavorTextRequest: DexEntries] = [:] + + init(fetch: @escaping @Sendable (URLRequest) async throws -> (Data, Int) = PokemonFlavorTextClient.download) { + self.fetch = fetch + } + + static func download(_ request: URLRequest) async throws -> (Data, Int) { + let (data, response) = try await URLSession.shared.data(for: request) + return (data, (response as? HTTPURLResponse)?.statusCode ?? 0) + } + + func flavorTexts(speciesID: Int, language: AppLanguage) async throws -> DexEntries { + let request = FlavorTextRequest(speciesID: speciesID, language: language) + if let cached = cache[request] { return cached } + var result = try await load(speciesID: speciesID, language: language) + if Self.needsEnglishFallback(entries: result.entries, language: language) { + // An empty REST answer is as conclusive as an empty GraphQL one (REST carries every + // language), so only the English labels decide whether the result is degraded. + result = try await load(speciesID: speciesID, language: .en) + } + if !result.isDegraded { cache[request] = result } + return result + } + + /// Falls back only when the requested language has nothing at all. A version missing in that + /// language is dropped instead — filling it from English would mix two languages on one card. + /// English has nowhere further to go. + static func needsEnglishFallback(entries: [DexFlavorText], language: AppLanguage) -> Bool { + entries.isEmpty && language != .en + } + + private func load(speciesID: Int, language: AppLanguage) async throws -> DexEntries { + do { + var request = URLRequest(url: Self.graphQLURL) + request.httpMethod = "POST" + request.timeoutInterval = 15 + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data( + withJSONObject: ["query": Self.query(speciesID: speciesID, language: language)]) + let (data, status) = try await fetch(request) + return DexEntries(entries: try Self.parseGraphQL(data, status: status, language: language), + language: language) + } catch { + AppLog.write("dex flavor text (GraphQL) failed for #\(speciesID) — REST fallback: \(error)") + var request = URLRequest(url: URL(string: "https://pokeapi.co/api/v2/pokemon-species/\(speciesID)")!) + request.timeoutInterval = 15 + let (data, status) = try await fetch(request) + return DexEntries(entries: try Self.parseREST(data, status: status, language: language), + language: language, isDegraded: true) + } + } + + /// Filtered to the requested language server-side, with version names joined in the same query. + static func query(speciesID: Int, language: AppLanguage) -> String { + let codes = language.apiCodes.map { "\"\($0)\"" }.joined(separator: ",") + return """ + { pokemonspeciesflavortext(where: {pokemon_species_id: {_eq: \(speciesID)}, \ + language: {name: {_in: [\(codes)]}}}, order_by: {version_id: asc}) \ + { flavor_text language { name } \ + version { id name versionnames(where: {language: {name: {_in: [\(codes)]}}}) { name language { name } } } } } + """ + } + + private struct GraphQLResponse: Decodable { + struct Language: Decodable { let name: String } + struct VersionName: Decodable { + let name: String + let language: Language + } + struct Version: Decodable { + let id: Int + let name: String + let versionnames: [VersionName] + } + struct Row: Decodable { + let flavor_text: String + let language: Language + let version: Version + } + struct DataBox: Decodable { let pokemonspeciesflavortext: [Row] } + let data: DataBox + } + + static func parseGraphQL(_ data: Data, status: Int, language: AppLanguage) throws -> [DexFlavorText] { + guard status == 200 else { throw URLError(.badServerResponse) } + let rows = try JSONDecoder().decode(GraphQLResponse.self, from: data).data.pokemonspeciesflavortext.map { + FlavorRow(versionKey: $0.version.name, versionID: $0.version.id, languageCode: $0.language.name, + text: $0.flavor_text, + labels: $0.version.versionnames.reduce(into: [:]) { $0[$1.language.name] = $1.name }) + } + return collapse(rows, language: language) + } + + private struct SpeciesFlavorDTO: Decodable { + /// Unlike `NamedRef`, the URL is required: it is the only source of the version's release order. + struct Ref: Decodable { + let name: String + let url: String + } + struct Entry: Decodable { + let flavor_text: String + let language: Ref + let version: Ref + } + let flavor_text_entries: [Entry] + } + + /// Decoded on its own rather than through `SpeciesDTO`, so the species cache that every hatch + /// and name backfill fills does not also hold every language's entries. + static func parseREST(_ data: Data, status: Int, language: AppLanguage) throws -> [DexFlavorText] { + guard status == 200 else { throw URLError(.badServerResponse) } + let rows = try JSONDecoder().decode(SpeciesFlavorDTO.self, from: data).flavor_text_entries.map { + FlavorRow(versionKey: $0.version.name, versionID: PokeAPIClient.id(from: $0.version.url), + languageCode: $0.language.name, text: $0.flavor_text, labels: [:]) + } + return collapse(rows, language: language) + } + + /// One entry per version, oldest first. Japanese arrives twice (`ja-hrkt`, `ja`); within a version + /// the earlier `apiCodes` candidate wins whatever order the rows came in. + static func collapse(_ rows: [FlavorRow], language: AppLanguage) -> [DexFlavorText] { + let codes = language.apiCodes.map(PokemonNameLocalization.languageCode) + var best: [String: (rank: Int, row: FlavorRow)] = [:] + for row in rows { + // The REST body carries every language; drop the ones that were not requested. + guard let rank = codes.firstIndex(of: PokemonNameLocalization.languageCode(row.languageCode)) else { continue } + if let current = best[row.versionKey], current.rank <= rank { continue } + best[row.versionKey] = (rank, row) + } + return best.values.map(\.row).sorted { $0.versionID < $1.versionID }.map { row in + let labels = row.labels.reduce(into: [String: String]()) { + $0[PokemonNameLocalization.languageCode($1.key)] = $1.value + } + return DexFlavorText(versionKey: row.versionKey, versionID: row.versionID, + versionLabel: codes.lazy.compactMap { labels[$0] }.first + ?? PokemonNameLocalization.identifier(row.versionKey), + text: sanitize(row.text)) + } + } + + /// Line and page breaks only fit the games' text boxes, so they become spaces; soft hyphens go. + /// Japanese ideographic spaces (U+3000) are intended spacing and stay. + static func sanitize(_ raw: String) -> String { + var text = raw.replacingOccurrences(of: "\u{00AD}", with: "") + for control in ["\n", "\r", "\u{000C}"] { + text = text.replacingOccurrences(of: control, with: " ") + } + return text.split(separator: " ", omittingEmptySubsequences: true).joined(separator: " ") + } +} diff --git a/Tests/PokeTokenBarTests/DexFlavorTextTests.swift b/Tests/PokeTokenBarTests/DexFlavorTextTests.swift new file mode 100644 index 000000000..1e25632fb --- /dev/null +++ b/Tests/PokeTokenBarTests/DexFlavorTextTests.swift @@ -0,0 +1,227 @@ +import Foundation +import XCTest +@testable import PokeTokenBar + +// Identifiers (language codes, version names, the error body) are copied from live PokéAPI responses. +// Entry text is synthetic: the repository does not commit Pokémon data. It keeps what the tests rely on — +// line and page breaks, Japanese ideographic spaces, and kana vs. kanji. +private let kanaX = "かなの\u{3000}せつめい\u{3000}ぶん\nつぎの\u{3000}ぎょう" +private let kanjiX = "漢字の\u{3000}説明\u{3000}文\n次の\u{3000}行" +/// The body PokéAPI GraphQL returns, with HTTP 200, for a query it rejects. +private let graphQLErrorBody = #"{"errors":[{"message":"field 'x' not found in type: 'query_root'","extensions":{"path":"$.selectionSet.x","code":"validation-failed"}}]}"# + +private struct GraphQLRow { + let versionID: Int + let version: String + let language: String + let text: String + var labels: [String: String] = [:] +} + +private func graphQLBody(_ rows: [GraphQLRow]) -> Data { + let json: [String: Any] = ["data": ["pokemonspeciesflavortext": rows.map { row in + ["flavor_text": row.text, "language": ["name": row.language], + "version": ["id": row.versionID, "name": row.version, + "versionnames": row.labels.map { ["name": $0.value, "language": ["name": $0.key]] }]] + }]] + return try! JSONSerialization.data(withJSONObject: json) +} + +private func restBody(_ rows: [GraphQLRow]) -> Data { + let json: [String: Any] = ["flavor_text_entries": rows.map { row in + ["flavor_text": row.text, + "language": ["name": row.language, "url": "https://pokeapi.co/api/v2/language/1/"], + "version": ["name": row.version, "url": "https://pokeapi.co/api/v2/version/\(row.versionID)/"]] + }] + return try! JSONSerialization.data(withJSONObject: json) +} + +private actor RequestLog { + private(set) var graphQLBodies: [String] = [] + private(set) var restCount = 0 + func record(_ request: URLRequest) { + if request.url?.host == "graphql.pokeapi.co" { + graphQLBodies.append(String(data: request.httpBody ?? Data(), encoding: .utf8) ?? "") + } else { + restCount += 1 + } + } +} + +private enum TransportFailure: Error { case offline } + +/// Routes GraphQL and REST to separate handlers; the GraphQL handler sees the request body. +private func client(log: RequestLog, + graphQL: @escaping @Sendable (String) throws -> (Data, Int), + rest: @escaping @Sendable () throws -> (Data, Int)) -> PokemonFlavorTextClient { + PokemonFlavorTextClient { request in + await log.record(request) + if request.url?.host == "graphql.pokeapi.co" { + return try graphQL(String(data: request.httpBody ?? Data(), encoding: .utf8) ?? "") + } + return try rest() + } +} + +final class DexFlavorTextSanitizeTests: XCTestCase { + func testCollapsesGameLineBreaksAndPageFeeds() { + XCTAssertEqual(PokemonFlavorTextClient.sanitize("First line\nsecond line\u{000C}third part\rfourth part"), + "First line second line third part fourth part") + } + + func testRemovesSoftHyphen() { + XCTAssertEqual(PokemonFlavorTextClient.sanitize("electri\u{00AD}city"), "electricity") + } + + func testKeepsIdeographicSpaceWhileCollapsingNewlines() { + XCTAssertEqual(PokemonFlavorTextClient.sanitize(kanaX), "かなの\u{3000}せつめい\u{3000}ぶん つぎの\u{3000}ぎょう") + } + + func testCollapsesRunsAndTrimsEdges() { + XCTAssertEqual(PokemonFlavorTextClient.sanitize(" a \n\n b "), "a b") + } +} + +final class DexFlavorTextCollapseTests: XCTestCase { + private func rows(_ graphQL: [GraphQLRow]) -> [FlavorRow] { + graphQL.map { FlavorRow(versionKey: $0.version, versionID: $0.versionID, languageCode: $0.language, + text: $0.text, labels: $0.labels) } + } + + /// GraphQL orders by version only, so `ja` and `ja-hrkt` rows of one version arrive in either order. + func testJapaneseFollowsAppLanguagePriorityWhicheverRowArrivesFirst() { + let kana = GraphQLRow(versionID: 23, version: "x", language: "ja-hrkt", text: kanaX) + let kanji = GraphQLRow(versionID: 23, version: "x", language: "ja", text: kanjiX) + XCTAssertEqual(AppLanguage.ja.apiCodes.first, "ja-hrkt") + for order in [[kana, kanji], [kanji, kana]] { + let result = PokemonFlavorTextClient.collapse(rows(order), language: .ja) + XCTAssertEqual(result.map(\.text), [PokemonFlavorTextClient.sanitize(kanaX)]) + } + } + + func testVersionLabelUsesTheRequestedLanguageAndFallsBackToTheSlug() { + let localized = GraphQLRow(versionID: 25, version: "omega-ruby", language: "ja-hrkt", text: "a", + labels: ["ja-hrkt": "オメガルビー"]) + XCTAssertEqual(PokemonFlavorTextClient.collapse(rows([localized]), language: .ja).first?.versionLabel, "オメガルビー") + let unlabeled = GraphQLRow(versionID: 25, version: "omega-ruby", language: "ja-hrkt", text: "a") + XCTAssertEqual(PokemonFlavorTextClient.collapse(rows([unlabeled]), language: .ja).first?.versionLabel, "Omega Ruby") + } + + func testDropsLanguagesThatWereNotRequested() { + let result = PokemonFlavorTextClient.collapse(rows([ + GraphQLRow(versionID: 23, version: "x", language: "en", text: "english"), + GraphQLRow(versionID: 23, version: "x", language: "ko", text: "한국어"), + GraphQLRow(versionID: 24, version: "y", language: "fr", text: "français"), + ]), language: .ko) + XCTAssertEqual(result.map(\.text), ["한국어"]) + } + + func testOrdersVersionsByReleaseID() { + let result = PokemonFlavorTextClient.collapse(rows([ + GraphQLRow(versionID: 33, version: "sword", language: "en", text: "c"), + GraphQLRow(versionID: 1, version: "red", language: "en", text: "a"), + GraphQLRow(versionID: 23, version: "x", language: "en", text: "b"), + ]), language: .en) + XCTAssertEqual(result.map(\.versionKey), ["red", "x", "sword"]) + } + + func testGraphQLAndRESTYieldTheSameVersionsAndText() throws { + let fixture = [GraphQLRow(versionID: 23, version: "x", language: "ko", text: "첫 번째\n설명", + labels: ["ko": "X"]), + GraphQLRow(versionID: 27, version: "sun", language: "ko", text: "두 번째\n설명", + labels: ["ko": "썬"])] + let graphQL = try PokemonFlavorTextClient.parseGraphQL(graphQLBody(fixture), status: 200, language: .ko) + let rest = try PokemonFlavorTextClient.parseREST(restBody(fixture), status: 200, language: .ko) + XCTAssertEqual(graphQL.map(\.versionKey), rest.map(\.versionKey)) + XCTAssertEqual(graphQL.map(\.versionID), rest.map(\.versionID)) + XCTAssertEqual(graphQL.map(\.text), rest.map(\.text)) + XCTAssertEqual(graphQL.map(\.versionLabel), ["X", "썬"]) + XCTAssertEqual(rest.map(\.versionLabel), ["X", "Sun"], "REST has no localized version names") + } +} + +final class DexFlavorTextFallbackDecisionTests: XCTestCase { + private let one = [DexFlavorText(versionKey: "x", versionID: 23, versionLabel: "X", text: "t")] + + func testEmptyNonEnglishLanguageFallsBackToEnglish() { + XCTAssertTrue(PokemonFlavorTextClient.needsEnglishFallback(entries: [], language: .pt)) + XCTAssertTrue(PokemonFlavorTextClient.needsEnglishFallback(entries: [], language: .ko)) + } + + func testEmptyEnglishDoesNotFallBack() { + XCTAssertFalse(PokemonFlavorTextClient.needsEnglishFallback(entries: [], language: .en)) + } + + func testPartialCoverageKeepsTheRequestedLanguage() { + XCTAssertFalse(PokemonFlavorTextClient.needsEnglishFallback(entries: one, language: .ko)) + } +} + +final class DexFlavorTextClientTests: XCTestCase { + private let koRows = [GraphQLRow(versionID: 23, version: "x", language: "ko", text: "한국어 설명", + labels: ["ko": "X"])] + + func testGraphQLResultIsLocalizedAndServedFromCacheAfterwards() async throws { + let log = RequestLog() + let body = graphQLBody(koRows) + let sut = client(log: log, graphQL: { _ in (body, 200) }, rest: { XCTFail("REST must not run"); throw TransportFailure.offline }) + let first = try await sut.flavorTexts(speciesID: 25, language: .ko) + let second = try await sut.flavorTexts(speciesID: 25, language: .ko) + XCTAssertFalse(first.isDegraded) + XCTAssertEqual(first.entries.map(\.versionLabel), ["X"]) + XCTAssertEqual(second, first) + let graphQLCount = await log.graphQLBodies.count + XCTAssertEqual(graphQLCount, 1) + } + + /// Transport errors, HTTP errors, GraphQL `errors` payloads and undecodable bodies all recover + /// through REST. The REST result is shown as degraded and never cached. + func testEveryGraphQLFailureFallsBackToRESTWithoutCaching() async throws { + let failures: [(String, @Sendable (String) throws -> (Data, Int))] = [ + ("transport", { _ in throw TransportFailure.offline }), + ("http status", { _ in (Data("{}".utf8), 503) }), + ("errors payload", { _ in (Data(graphQLErrorBody.utf8), 200) }), + ("undecodable body", { _ in (Data("".utf8), 200) }), + ] + let rest = restBody(koRows) + for (name, graphQL) in failures { + let log = RequestLog() + let sut = client(log: log, graphQL: graphQL, rest: { (rest, 200) }) + let first = try await sut.flavorTexts(speciesID: 25, language: .ko) + XCTAssertTrue(first.isDegraded, name) + XCTAssertEqual(first.entries.map(\.text), ["한국어 설명"], name) + _ = try await sut.flavorTexts(speciesID: 25, language: .ko) + let graphQLCount = await log.graphQLBodies.count + let restCount = await log.restCount + XCTAssertEqual(graphQLCount, 2, "\(name): a degraded result must not be cached") + XCTAssertEqual(restCount, 2, name) + } + } + + func testRESTFailureAfterGraphQLFailureThrows() async { + let sut = client(log: RequestLog(), graphQL: { _ in throw TransportFailure.offline }, + rest: { (Data(), 500) }) + do { + _ = try await sut.flavorTexts(speciesID: 25, language: .ko) + XCTFail("both sources failed") + } catch {} + } + + /// PokéAPI has no `pt`: the request comes back empty and the client asks again in English. + func testLanguageWithoutEntriesFallsBackToEnglish() async throws { + let log = RequestLog() + let english = graphQLBody([GraphQLRow(versionID: 1, version: "red", language: "en", + text: "English entry", labels: ["en": "Red"])]) + let empty = graphQLBody([]) + let sut = client(log: log, graphQL: { body in (body.contains("\\\"pt\\\"") ? empty : english, 200) }, + rest: { throw TransportFailure.offline }) + let result = try await sut.flavorTexts(speciesID: 25, language: .pt) + XCTAssertEqual(result.language, .en) + XCTAssertEqual(result.entries.map(\.text), ["English entry"]) + XCTAssertFalse(result.isDegraded) + let bodies = await log.graphQLBodies + XCTAssertEqual(bodies.count, 2) + XCTAssertTrue(bodies[0].contains("\\\"pt\\\""), bodies[0]) + XCTAssertTrue(bodies[1].contains("\\\"en\\\""), bodies[1]) + } +} From dc24beacaeb503b47a75cfc6ad3996bdefcff533 Mon Sep 17 00:00:00 2001 From: LeeTaek <28972501+LeeTaek@users.noreply.github.com> Date: Tue, 6 Oct 2026 09:47:30 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20load=20Pok=C3=A9dex=20entries=20in?= =?UTF-8?q?=20the=20store=20keyed=20by=20species=20and=20language?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Results, failures and in-flight tasks are keyed by FlavorTextRequest (species ID + language). The request is captured before the first await and re-checked before a success or a failure is stored, so a response for a language that is no longer selected is dropped. The store owns the in-flight task: a detail page that closes does not cancel it, and reopening joins it. Degraded results stay visible and are fetched again on the next visit. Co-Authored-By: Claude Opus 5.5 --- .../PokeTokenBar/Core/CompanionStore.swift | 45 ++++ .../DexFlavorTextStoreTests.swift | 229 ++++++++++++++++++ 2 files changed, 274 insertions(+) create mode 100644 Tests/PokeTokenBarTests/DexFlavorTextStoreTests.swift diff --git a/Sources/PokeTokenBar/Core/CompanionStore.swift b/Sources/PokeTokenBar/Core/CompanionStore.swift index 3acf1b834..ae29d3dd2 100644 --- a/Sources/PokeTokenBar/Core/CompanionStore.swift +++ b/Sources/PokeTokenBar/Core/CompanionStore.swift @@ -47,6 +47,13 @@ final class CompanionStore { private(set) var pokemonDetailsByID: [Int: PokemonDetails] = [:] private(set) var loadingPokemonDetailIDs: Set = [] private(set) var failedPokemonDetailIDs: Set = [] + private let flavorProvider: (any PokemonFlavorTextProviding)? + /// Keyed by species *and* language: switching languages never shows the other language's entries. + private(set) var flavorTextsByRequest: [FlavorTextRequest: DexEntries] = [:] + private(set) var failedFlavorTextRequests: Set = [] + /// Store-owned so a detail page closing mid-request does not cancel it: reopening joins the + /// same request instead of finding it "in flight" and waiting on one that never lands. + private var flavorTextTasks: [FlavorTextRequest: Task] = [:] private let defaults: UserDefaults /// 세션 내 활성 개체 교체 감지용. await 뒤 이전 개체의 결과가 새 개체를 덮지 않게 한다. private var activeGeneration = 0 @@ -67,6 +74,7 @@ final class CompanionStore { init(provider: any PokeProviding = PokeAPIClient.shared, detailProvider: (any PokemonDetailProviding)? = nil, + flavorProvider: (any PokemonFlavorTextProviding)? = nil, clock: @escaping () -> Date = Date.init, fileURL: URL? = nil, rng: any RandomNumberGenerator = SystemRandomNumberGenerator(), @@ -74,6 +82,7 @@ final class CompanionStore { defaults: UserDefaults = .standard) { self.provider = provider self.detailProvider = detailProvider ?? (provider as? any PokemonDetailProviding) + self.flavorProvider = flavorProvider ?? (provider as? any PokemonFlavorTextProviding) self.clock = clock self.fileURL = fileURL ?? Self.defaultURL() self.rng = rng @@ -1934,6 +1943,42 @@ final class CompanionStore { } } + /// The request the detail page shows: the species in the current app language. + func flavorTextRequest(speciesID: Int) -> FlavorTextRequest { + FlavorTextRequest(speciesID: speciesID, language: state.language) + } + + func isLoadingFlavorTexts(_ request: FlavorTextRequest) -> Bool { flavorTextTasks[request] != nil } + + /// Loads the species' Pokédex entries in the current language, independently of battle details. + /// + /// The request outlives the page that started it, so the language it was made for is captured + /// before the first await and checked again before success *or* failure is stored. A degraded + /// (REST) result stays visible but is fetched again on the next visit. + func loadFlavorTexts(speciesID: Int) async { + let request = flavorTextRequest(speciesID: speciesID) + if let running = flavorTextTasks[request] { return await running.value } + if let loaded = flavorTextsByRequest[request], !loaded.isDegraded { return } + guard let flavorProvider else { return } + failedFlavorTextRequests.remove(request) + let task = Task { + defer { flavorTextTasks[request] = nil } + do { + let entries = try await flavorProvider.flavorTexts(speciesID: request.speciesID, + language: request.language) + guard state.language == request.language else { return } + flavorTextsByRequest[request] = entries + } catch { + guard state.language == request.language else { return } + failedFlavorTextRequests.insert(request) + AppLog.write("dex flavor text fetch failed id=\(request.speciesID) " + + "lang=\(request.language.rawValue): \(error)") + } + } + flavorTextTasks[request] = task + await task.value + } + /// Startup/background warmup for the only profile needed before a detail page is opened. func preparePokemonProfiles() async { guard let speciesID = state.active?.currentID else { return } diff --git a/Tests/PokeTokenBarTests/DexFlavorTextStoreTests.swift b/Tests/PokeTokenBarTests/DexFlavorTextStoreTests.swift new file mode 100644 index 000000000..a1788ae6f --- /dev/null +++ b/Tests/PokeTokenBarTests/DexFlavorTextStoreTests.swift @@ -0,0 +1,229 @@ +import Foundation +import XCTest +@testable import PokeTokenBar + +private struct OfflineLineProvider: PokeProviding { + func line(baseSpeciesID: Int) async throws -> EvoLine { throw URLError(.notConnectedToInternet) } + func baseSpeciesIndex() async throws -> [BaseSpecies] { [] } + func baseSpecies(id: Int) async throws -> BaseSpecies? { nil } +} + +private enum FlavorTestError: Error { case unavailable } + +private func flavorEntries(_ text: String, language: AppLanguage, label: String = "X", + degraded: Bool = false) -> DexEntries { + DexEntries(entries: [DexFlavorText(versionKey: "x", versionID: 23, versionLabel: label, text: text)], + language: language, isDegraded: degraded) +} + +/// Holds every request until the test resumes it, so completion order is chosen by the test. +/// Like URLSession, a cancelled caller gets `CancellationError` — a store that let the page's +/// cancellation reach the request would lose it here. +private actor ControlledFlavorProvider: PokemonFlavorTextProviding { + private var pending: [FlavorTextRequest: CheckedContinuation] = [:] + private(set) var calls: [FlavorTextRequest] = [] + + func flavorTexts(speciesID: Int, language: AppLanguage) async throws -> DexEntries { + let request = FlavorTextRequest(speciesID: speciesID, language: language) + calls.append(request) + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + if Task.isCancelled { + continuation.resume(throwing: CancellationError()) + } else { + pending[request] = continuation + } + } + } onCancel: { + Task { await self.resume(request, with: .failure(CancellationError())) } + } + } + + func isPending(_ request: FlavorTextRequest) -> Bool { pending[request] != nil } + + func resume(_ request: FlavorTextRequest, with result: Result) { + pending.removeValue(forKey: request)?.resume(with: result) + } +} + +@MainActor +final class DexFlavorTextStoreTests: XCTestCase { + private var files: [URL] = [] + + override func tearDown() { + files.forEach { try? FileManager.default.removeItem(at: $0) } + files = [] + super.tearDown() + } + + private func makeStore(_ provider: ControlledFlavorProvider, language: AppLanguage = .ko) -> CompanionStore { + let file = FileManager.default.temporaryDirectory.appendingPathComponent("flavor-store-\(UUID()).json") + files.append(file) + let store = CompanionStore(provider: OfflineLineProvider(), flavorProvider: provider, fileURL: file) + store.setLanguage(language) + return store + } + + /// Waits for the request to reach the provider; the language may only change after that point, + /// otherwise the test cannot tell which language the request captured. + private func waitUntilPending(_ provider: ControlledFlavorProvider, _ request: FlavorTextRequest, + file: StaticString = #filePath, line: UInt = #line) async { + let deadline = ContinuousClock.now + .seconds(2) + while ContinuousClock.now < deadline { + if await provider.isPending(request) { return } + try? await Task.sleep(for: .milliseconds(1)) + } + XCTFail("request never reached the provider: \(request)", file: file, line: line) + } + + private let ko = FlavorTextRequest(speciesID: 25, language: .ko) + private let en = FlavorTextRequest(speciesID: 25, language: .en) + + // MARK: Landing guard + + func testStaleLanguageSuccessIsDroppedWhicheverResponseArrivesFirst() async { + for staleFirst in [false, true] { + let provider = ControlledFlavorProvider() + let store = makeStore(provider) + let koLoad = Task { await store.loadFlavorTexts(speciesID: 25) } + await waitUntilPending(provider, ko) + store.setLanguage(.en) + let enLoad = Task { await store.loadFlavorTexts(speciesID: 25) } + await waitUntilPending(provider, en) + + if staleFirst { + await provider.resume(ko, with: .success(flavorEntries("한국어", language: .ko))) + await koLoad.value + await provider.resume(en, with: .success(flavorEntries("English", language: .en))) + await enLoad.value + } else { + await provider.resume(en, with: .success(flavorEntries("English", language: .en))) + await enLoad.value + await provider.resume(ko, with: .success(flavorEntries("한국어", language: .ko))) + await koLoad.value + } + + let shown = store.flavorTextsByRequest[store.flavorTextRequest(speciesID: 25)] + XCTAssertEqual(shown?.entries.first?.text, "English", "staleFirst=\(staleFirst)") + XCTAssertNil(store.flavorTextsByRequest[ko], "a response for a language no longer selected must not be stored") + XCTAssertFalse(store.isLoadingFlavorTexts(ko)) + XCTAssertFalse(store.isLoadingFlavorTexts(en)) + } + } + + func testStaleLanguageFailureIsDropped() async { + let provider = ControlledFlavorProvider() + let store = makeStore(provider) + let koLoad = Task { await store.loadFlavorTexts(speciesID: 25) } + await waitUntilPending(provider, ko) + store.setLanguage(.en) + await provider.resume(ko, with: .failure(FlavorTestError.unavailable)) + await koLoad.value + + XCTAssertFalse(store.failedFlavorTextRequests.contains(ko)) + XCTAssertFalse(store.isLoadingFlavorTexts(ko)) + } + + // MARK: Language-keyed results + + func testEachLanguageKeepsItsOwnEntriesAcrossSwitches() async { + let provider = ControlledFlavorProvider() + let store = makeStore(provider) + let koLoad = Task { await store.loadFlavorTexts(speciesID: 25) } + await waitUntilPending(provider, ko) + await provider.resume(ko, with: .success(flavorEntries("한국어", language: .ko))) + await koLoad.value + + store.setLanguage(.en) + let enLoad = Task { await store.loadFlavorTexts(speciesID: 25) } + await waitUntilPending(provider, en) + await provider.resume(en, with: .success(flavorEntries("English", language: .en))) + await enLoad.value + + store.setLanguage(.ko) + await store.loadFlavorTexts(speciesID: 25) + + let calls = await provider.calls + XCTAssertEqual(calls, [ko, en], "returning to Korean reuses its entries; English was really requested") + XCTAssertEqual(store.flavorTextsByRequest[store.flavorTextRequest(speciesID: 25)]?.entries.first?.text, "한국어") + XCTAssertEqual(store.flavorTextsByRequest[en]?.entries.first?.text, "English") + } + + // MARK: In-flight identity + + func testAnotherLanguageStartsItsOwnRequestWhileTheFirstIsPending() async { + let provider = ControlledFlavorProvider() + let store = makeStore(provider) + let koLoad = Task { await store.loadFlavorTexts(speciesID: 25) } + await waitUntilPending(provider, ko) + store.setLanguage(.en) + let enLoad = Task { await store.loadFlavorTexts(speciesID: 25) } + await waitUntilPending(provider, en) + + XCTAssertTrue(store.isLoadingFlavorTexts(ko)) + XCTAssertTrue(store.isLoadingFlavorTexts(en)) + await provider.resume(en, with: .success(flavorEntries("English", language: .en))) + await provider.resume(ko, with: .success(flavorEntries("한국어", language: .ko))) + await enLoad.value + await koLoad.value + } + + /// Closing the detail page cancels its `.task`. Reopening right away must join the request the + /// closed page started rather than skip it as "in flight" and wait on nothing. + func testReopeningJoinsTheRequestTheClosedPageStarted() async { + let provider = ControlledFlavorProvider() + let store = makeStore(provider) + let closedPage = Task { await store.loadFlavorTexts(speciesID: 25) } + await waitUntilPending(provider, ko) + closedPage.cancel() + let reopenedPage = Task { await store.loadFlavorTexts(speciesID: 25) } + for _ in 0..<20 { await Task.yield() } + + XCTAssertTrue(store.isLoadingFlavorTexts(ko)) + let stillPending = await provider.isPending(ko) + XCTAssertTrue(stillPending, "the closed page's cancellation must not reach the request") + await provider.resume(ko, with: .success(flavorEntries("한국어", language: .ko))) + await reopenedPage.value + await closedPage.value + + let calls = await provider.calls + XCTAssertEqual(calls, [ko]) + XCTAssertEqual(store.flavorTextsByRequest[ko]?.entries.first?.text, "한국어") + XCTAssertFalse(store.isLoadingFlavorTexts(ko)) + XCTAssertTrue(store.failedFlavorTextRequests.isEmpty) + } + + // MARK: Degraded (REST) results + + func testDegradedEntriesStayThroughAFailedRefetchUntilARetrySucceeds() async { + let provider = ControlledFlavorProvider() + let store = makeStore(provider) + let degraded = flavorEntries("한국어 설명", language: .ko, label: "X", degraded: true) + + let firstVisit = Task { await store.loadFlavorTexts(speciesID: 25) } + await waitUntilPending(provider, ko) + await provider.resume(ko, with: .success(degraded)) + await firstVisit.value + XCTAssertEqual(store.flavorTextsByRequest[ko], degraded, "a degraded result is still shown") + + let reentry = Task { await store.loadFlavorTexts(speciesID: 25) } + await waitUntilPending(provider, ko) + XCTAssertEqual(store.flavorTextsByRequest[ko], degraded, "the refetch keeps the shown entries") + await provider.resume(ko, with: .failure(FlavorTestError.unavailable)) + await reentry.value + XCTAssertEqual(store.flavorTextsByRequest[ko], degraded) + XCTAssertTrue(store.failedFlavorTextRequests.contains(ko)) + + let retry = Task { await store.loadFlavorTexts(speciesID: 25) } + await waitUntilPending(provider, ko) + await provider.resume(ko, with: .success(flavorEntries("한국어 설명", language: .ko, label: "엑스"))) + await retry.value + XCTAssertEqual(store.flavorTextsByRequest[ko]?.entries.first?.versionLabel, "엑스") + XCTAssertFalse(store.flavorTextsByRequest[ko]?.isDegraded ?? true) + XCTAssertFalse(store.failedFlavorTextRequests.contains(ko)) + + await store.loadFlavorTexts(speciesID: 25) + let calls = await provider.calls + XCTAssertEqual(calls.count, 3, "a localized result is final; the next visit does not refetch") + } +} From 1d10c668468d27c063e41b08129f29b5df54f9ca Mon Sep 17 00:00:00 2001 From: LeeTaek <28972501+LeeTaek@users.noreply.github.com> Date: Tue, 6 Oct 2026 09:47:30 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20add=20Pok=C3=A9dex=20entries=20to?= =?UTF-8?q?=20the=20species=20card=20with=20a=20page=20for=20every=20versi?= =?UTF-8?q?on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The species data card shows the newest entry and an "All Pokédex entries" button. The button opens every version's entry, oldest first, on a page laid over the detail page, so Back returns to the same individual and scroll position. Loading, failure and retry stay inside the description area, and a failed refetch keeps the entry already shown. Entries load in their own task, alongside battle details. Co-Authored-By: Claude Opus 5.5 --- Sources/PokeTokenBar/Core/Localization.swift | 7 + Sources/PokeTokenBar/UI/CompanionView.swift | 138 ++++++- .../DexFlavorTextRenderingTests.swift | 359 ++++++++++++++++++ 3 files changed, 499 insertions(+), 5 deletions(-) create mode 100644 Tests/PokeTokenBarTests/DexFlavorTextRenderingTests.swift diff --git a/Sources/PokeTokenBar/Core/Localization.swift b/Sources/PokeTokenBar/Core/Localization.swift index 3a4088c43..4f8cc7128 100644 --- a/Sources/PokeTokenBar/Core/Localization.swift +++ b/Sources/PokeTokenBar/Core/Localization.swift @@ -1153,6 +1153,13 @@ struct L { var weight: String { t("몸무게", "Weight", "重さ", "Peso", "Poids", "Peso", "Gewicht", "Вес") } var baseStatTotal: String { t("합계", "Base total", "合計", "Total base", "Total de base", "Total base", "Basiswertsumme", "Сумма базовых") } var possibleAbilities: String { t("가능한 특성", "Possible abilities", "可能な特性", "Habilidades posibles", "Talents possibles", "Habilidades possíveis", "Mögliche Fähigkeiten", "Возможные способности") } + var dexFlavorTitle: String { t("도감 설명", "Pokédex entries", "図鑑説明", "Descripciones de la Pokédex", "Descriptions du Pokédex", "Descrições da Pokédex", "Pokédex-Einträge", "Записи Покедекса") } + var dexFlavorShowAll: String { t("도감 설명 전체 보기", "All Pokédex entries", "図鑑説明をすべて見る", "Ver todas las descripciones", "Voir toutes les descriptions", "Ver todas as descrições", "Alle Pokédex-Einträge", "Все записи Покедекса") } + var dexFlavorLoading: String { t("도감 설명 불러오는 중…", "Loading Pokédex entries…", "図鑑説明を読み込み中…", "Cargando descripciones de la Pokédex…", "Chargement des descriptions du Pokédex…", "Carregando descrições da Pokédex…", "Pokédex-Einträge werden geladen…", "Загрузка записей Покедекса…") } + var dexFlavorFailed: String { t("도감 설명을 불러오지 못했어요", "Couldn't load Pokédex entries", "図鑑説明を読み込めませんでした", "No se pudieron cargar las descripciones de la Pokédex", "Impossible de charger les descriptions du Pokédex", "Não foi possível carregar as descrições da Pokédex", "Pokédex-Einträge konnten nicht geladen werden", "Не удалось загрузить записи Покедекса") } + /// PokéAPI has no entries in this language at all (e.g. `pt`, `ru`); species names fall back the same way. + var dexFlavorEnglishFallback: String { t("이 언어로 제공되는 설명이 없어 영어로 표시합니다", "No entries in this language — showing English", "この言語の説明がないため英語で表示します", "No hay descripciones en este idioma — se muestra en inglés", "Aucune description dans cette langue — affichage en anglais", "Sem descrições neste idioma — exibindo em inglês", "Keine Einträge in dieser Sprache — Anzeige auf Englisch", "Записей на этом языке нет — показаны на английском") } + var dexFlavorEmpty: String { t("도감 설명이 없어요", "No Pokédex entries", "図鑑説明がありません", "No hay descripciones de la Pokédex", "Aucune description du Pokédex", "Sem descrições da Pokédex", "Keine Pokédex-Einträge", "Нет записей Покедекса") } func completeMoveList(_ count: Int) -> String { t("전체 기술 목록 \(count)개", "Complete move list · \(count)", "全技リスト・\(count)", "Lista completa · \(count)", "Liste complète · \(count)", "Lista completa · \(count)", "Vollständige Attackenliste · \(count)", "Все приёмы · \(count)") } func genderLabel(_ gender: PokemonGender?) -> String { switch gender { diff --git a/Sources/PokeTokenBar/UI/CompanionView.swift b/Sources/PokeTokenBar/UI/CompanionView.swift index aeaf2fbbe..549987273 100644 --- a/Sources/PokeTokenBar/UI/CompanionView.swift +++ b/Sources/PokeTokenBar/UI/CompanionView.swift @@ -1510,6 +1510,7 @@ struct PokemonDetailView: View { @State private var selectedInstanceID = "" @State private var selectedUnownForm: UnownForm? @State private var selectedShiny: Bool? + @State private var showingAllFlavorTexts = false init(store: CompanionStore, species: CompanionStore.DexSpecies, onBack: @escaping () -> Void, selectedShiny: Bool? = nil, @@ -1548,6 +1549,31 @@ struct PokemonDetailView: View { } var body: some View { + // Laid over the detail page rather than replacing it, so Back returns to the same individual, + // appearance and scroll position. + ZStack(alignment: .top) { + detailPage + .opacity(showingAllFlavorTexts ? 0 : 1) + .allowsHitTesting(!showingAllFlavorTexts) + .accessibilityHidden(showingAllFlavorTexts) + // The button only appears once entries exist, and they are never removed afterwards. + if showingAllFlavorTexts, + let entries = store.flavorTextsByRequest[store.flavorTextRequest(speciesID: species.id)] { + PokedexEntriesPage(store: store, species: species, entries: entries) { showingAllFlavorTexts = false } + } + } + .task { + if selectedUnownForm == nil { selectedUnownForm = displayedSpecies.unownForm } + if selectedInstanceID.isEmpty { selectedInstanceID = individuals.first?.id ?? "" } + await store.loadPokemonDetails(speciesID: species.id) + } + // A separate task, so flavor text never waits behind the battle-details request. + .task(id: store.flavorTextRequest(speciesID: species.id)) { + await store.loadFlavorTexts(speciesID: species.id) + } + } + + private var detailPage: some View { VStack(alignment: .leading, spacing: 8) { HStack { Button(action: onBack) { @@ -1589,11 +1615,6 @@ struct PokemonDetailView: View { .reservesScrollerLane() } } - .task { - if selectedUnownForm == nil { selectedUnownForm = displayedSpecies.unownForm } - if selectedInstanceID.isEmpty { selectedInstanceID = individuals.first?.id ?? "" } - await store.loadPokemonDetails(speciesID: species.id) - } } private var unownFormPicker: some View { @@ -1771,6 +1792,7 @@ struct PokemonDetailView: View { private func speciesSection(_ details: PokemonDetails) -> some View { VStack(alignment: .leading, spacing: 7) { detailTitle(store.l.speciesData) + flavorSummary HStack(spacing: 14) { valuePair(store.l.height, String(format: "%.1f m", Double(details.height) / 10)) valuePair(store.l.weight, String(format: "%.1f kg", Double(details.weight) / 10)) @@ -1786,6 +1808,36 @@ struct PokemonDetailView: View { .detailCard() } + /// The newest Pokédex entry (shared by every Unown letter) and the way into every version's entry. + /// Loading and failure stay inside this block; a failed refetch keeps the entry already shown. + private var flavorSummary: some View { + let request = store.flavorTextRequest(speciesID: species.id) + let result = store.flavorTextsByRequest[request] + return VStack(alignment: .leading, spacing: 4) { + if let result { + if result.language != request.language { FlavorFallbackNotice(store: store) } + if let newest = result.entries.last { + Text(newest.versionLabel).font(.system(size: 11, weight: .bold)).foregroundStyle(Color.accentColor) + Text(newest.text).font(.callout).fixedSize(horizontal: false, vertical: true) + HStack { + Spacer() + Button { showingAllFlavorTexts = true } label: { + HStack(spacing: 2) { + Text(store.l.dexFlavorShowAll) + Image(systemName: "chevron.right") + } + } + .buttonStyle(.borderless) + .font(.callout) + } + } else { + Text(store.l.dexFlavorEmpty).font(.callout).foregroundStyle(.secondary) + } + } + FlavorTextStatusRow(store: store, speciesID: species.id) + } + } + private func movesSection(_ details: PokemonDetails) -> some View { LazyVStack(alignment: .leading, spacing: 6) { detailTitle(store.l.completeMoveList(details.moves.count)) @@ -1816,6 +1868,82 @@ struct PokemonDetailView: View { } +/// Every version's Pokédex entry for one species, oldest first — opened from the species data card. +@MainActor +private struct PokedexEntriesPage: View { + let store: CompanionStore + let species: CompanionStore.DexSpecies + let entries: DexEntries + let onBack: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Button(action: onBack) { + Label(store.l.back, systemImage: "chevron.left") + } + .buttonStyle(.borderless) + Spacer() + Text("#\(species.id)").font(.callout).foregroundStyle(.secondary) + } + VStack(alignment: .leading, spacing: 2) { + Text(species.name).font(.title3.weight(.bold)) + Text(store.l.dexFlavorTitle).font(.callout.weight(.semibold)).foregroundStyle(.secondary) + } + if entries.language != store.flavorTextRequest(speciesID: species.id).language { + FlavorFallbackNotice(store: store) + } + FlavorTextStatusRow(store: store, speciesID: species.id) + ScrollView { + LazyVStack(alignment: .leading, spacing: 10) { + ForEach(entries.entries) { entry in + VStack(alignment: .leading, spacing: 2) { + Text(entry.versionLabel).font(.system(size: 11, weight: .bold)) + .foregroundStyle(Color.accentColor) + Text(entry.text).font(.callout).fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + .padding(.bottom, 8) + .reservesScrollerLane() + } + } + } +} + +/// PokéAPI has no entries at all in some languages (`pt`); say why the text is English. +@MainActor +private struct FlavorFallbackNotice: View { + let store: CompanionStore + var body: some View { + Text(store.l.dexFlavorEnglishFallback).font(.system(size: 11)).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } +} + +/// Loading and failure rows shared by the species card and the entries page. +@MainActor +private struct FlavorTextStatusRow: View { + let store: CompanionStore + let speciesID: Int + + var body: some View { + let request = store.flavorTextRequest(speciesID: speciesID) + if store.failedFlavorTextRequests.contains(request) { + HStack { + Text(store.l.dexFlavorFailed).foregroundStyle(.secondary) + Spacer() + Button(store.l.retry) { Task { await store.loadFlavorTexts(speciesID: speciesID) } } + } + .font(.callout) + } else if store.flavorTextsByRequest[request] == nil { + HStack(spacing: 6) { ProgressView().controlSize(.small); Text(store.l.dexFlavorLoading) } + .font(.callout).foregroundStyle(.secondary) + } + } +} + private extension View { func detailCard() -> some View { self.padding(9) diff --git a/Tests/PokeTokenBarTests/DexFlavorTextRenderingTests.swift b/Tests/PokeTokenBarTests/DexFlavorTextRenderingTests.swift new file mode 100644 index 000000000..152dd90d2 --- /dev/null +++ b/Tests/PokeTokenBarTests/DexFlavorTextRenderingTests.swift @@ -0,0 +1,359 @@ +import AppKit +import ImageIO +import SwiftUI +import UniformTypeIdentifiers +import Vision +import XCTest +@testable import PokeTokenBar + +private struct OfflineLines: PokeProviding { + func line(baseSpeciesID: Int) async throws -> EvoLine { throw URLError(.notConnectedToInternet) } + func baseSpeciesIndex() async throws -> [BaseSpecies] { [] } + func baseSpecies(id: Int) async throws -> BaseSpecies? { nil } +} + +private enum RenderingTestError: Error { case unavailable } + +/// No types, abilities or moves: those rows fetch translated names from PokéAPI, and these tests +/// must stay offline. The individual, nature, stat and move sections still render their headings. +private let pikachuDetails = PokemonDetails( + speciesID: 25, name: "pikachu", height: 4, weight: 60, baseExperience: 112, genderRate: 4, types: [], + baseStats: ["hp": 35, "attack": 55, "defense": 40, "special-attack": 50, "special-defense": 50, "speed": 90], + abilities: [], moves: []) + +private struct LoadedDetails: PokemonDetailProviding { + func pokemonDetails(speciesID: Int) async throws -> PokemonDetails { pikachuDetails } +} + +private struct FailingDetails: PokemonDetailProviding { + func pokemonDetails(speciesID: Int) async throws -> PokemonDetails { throw RenderingTestError.unavailable } +} + +private actor StalledDetails: PokemonDetailProviding { + private var continuation: CheckedContinuation? + func pokemonDetails(speciesID: Int) async throws -> PokemonDetails { + await withCheckedContinuation { continuation = $0 } + } + func release() { + continuation?.resume(returning: pikachuDetails) + continuation = nil + } +} + +private actor StalledFlavor: PokemonFlavorTextProviding { + private var continuation: CheckedContinuation? + func flavorTexts(speciesID: Int, language: AppLanguage) async throws -> DexEntries { + await withCheckedContinuation { continuation = $0 } + } + func release() { + continuation?.resume(returning: DexEntries(entries: [], language: .en)) + continuation = nil + } +} + +/// Answers each call with the next scripted result. +private actor ScriptedFlavor: PokemonFlavorTextProviding { + private var script: [Result] + init(_ script: [Result]) { self.script = script } + func flavorTexts(speciesID: Int, language: AppLanguage) async throws -> DexEntries { + try script.removeFirst().get() + } +} + +private let flavorSentence = "Sample entry for the newest version" + +@MainActor +final class DexFlavorTextRenderingTests: XCTestCase { + private var cleanup: [URL] = [] + private var spriteDirectories: [URL] = [] + + /// Like the other rendering tests, leave the shared sprite cache as it was: its entries are keyed + /// by file path, and these temporary paths would otherwise stay behind in its 64 slots. + override func tearDown() { + for directory in spriteDirectories { + for animated in [false, true] { + let file = SpriteStore.cacheKey(speciesID: 25, animated: animated, shiny: false) + (animated ? ".gif" : ".png") + SpriteLoader.imageCache.removeObject(forKey: directory.appendingPathComponent(file).path as NSString) + } + } + spriteDirectories = [] + cleanup.forEach { try? FileManager.default.removeItem(at: $0) } + cleanup = [] + super.tearDown() + } + + private func makeStore(language: AppLanguage = .en, details: any PokemonDetailProviding, + flavor: any PokemonFlavorTextProviding) throws -> CompanionStore { + let file = FileManager.default.temporaryDirectory.appendingPathComponent("flavor-render-\(UUID()).json") + cleanup.append(file) + var state = CompanionState() + state.language = language + state.dex = [DexEntry(baseID: 25, finalID: 25, chainOrder: [25], rarity: .common, + caughtAt: Date(timeIntervalSince1970: 1_700_000_000), nature: .adamant, + names: [25: ["en": "Pikachu", "pt": "Pikachu"]])] + try JSONEncoder().encode(state).write(to: file) + return CompanionStore(provider: OfflineLines(), detailProvider: details, flavorProvider: flavor, fileURL: file) + } + + /// Seeds the header sprite so the detail page never downloads one. + private func offlineSprites() throws -> SpriteStore { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("flavor-sprites-\(UUID())") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + cleanup.append(directory) + spriteDirectories.append(directory) + let image = NSImage(size: NSSize(width: 96, height: 96)) + image.lockFocus() + NSColor.gray.setFill() + NSRect(x: 0, y: 0, width: 96, height: 96).fill() + image.unlockFocus() + let cgImage = try XCTUnwrap(image.cgImage(forProposedRect: nil, context: nil, hints: nil)) + try XCTUnwrap(NSBitmapImageRep(cgImage: cgImage).representation(using: .png, properties: [:])) + .write(to: directory.appendingPathComponent(SpriteStore.cacheKey(speciesID: 25, animated: false, shiny: false) + ".png")) + let gif = NSMutableData() + let destination = try XCTUnwrap(CGImageDestinationCreateWithData(gif, UTType.gif.identifier as CFString, 1, nil)) + CGImageDestinationAddImage(destination, cgImage, nil) + XCTAssertTrue(CGImageDestinationFinalize(destination)) + try (gif as Data).write(to: directory.appendingPathComponent(SpriteStore.cacheKey(speciesID: 25, animated: true, shiny: false) + ".gif")) + return SpriteStore(directory: directory) + } + + /// Letters and digits only, case- and accent-folded, so OCR punctuation and accents do not matter. + private func normalized(_ text: String) -> String { + text.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: nil) + .unicodeScalars.filter(CharacterSet.alphanumerics.contains).map(String.init).joined() + } + + /// Mounts the real detail page in an offscreen window — by default tall enough to show every section. + private func mount(_ store: CompanionStore, height: CGFloat = 1500) throws -> (NSHostingView, NSWindow) { + let species = try XCTUnwrap(store.dexSpecies.first) + let size = NSSize(width: PopoverMetrics.contentWidth, height: height) + let view = PokemonDetailView(store: store, species: species, onBack: {}, spriteStore: try offlineSprites()) + .frame(width: size.width, height: size.height) + .background(Color.white) + .environment(\.colorScheme, .light) + .environment(\.locale, store.language.displayLocale) + let host = NSHostingView(rootView: view) + host.frame = NSRect(origin: .zero, size: size) + let window = NSWindow(contentRect: NSRect(origin: NSPoint(x: -10000, y: -10000), size: size), + styleMask: .borderless, backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.appearance = NSAppearance(named: .aqua) + window.contentView = host + window.orderFront(nil) + return (host, window) + } + + /// Reads the page back with OCR until every expected string is on screen and every absent one is + /// gone, or five seconds pass. Returns the last text and what still did not match. + private func waitForScreen(_ host: NSView, expecting expected: [String], absent: [String] = [], + languages: [String] = ["en-US"]) async throws -> (text: String, mismatched: [String]) { + let deadline = ContinuousClock.now + .seconds(5) + var text = "" + var mismatched = expected + absent + repeat { + try await Task.sleep(for: .milliseconds(50)) + host.layoutSubtreeIfNeeded() + let bitmap = try XCTUnwrap(host.bitmapImageRepForCachingDisplay(in: host.bounds)) + host.cacheDisplay(in: host.bounds, to: bitmap) + let request = VNRecognizeTextRequest() + request.recognitionLevel = .accurate + request.recognitionLanguages = languages + try VNImageRequestHandler(cgImage: XCTUnwrap(bitmap.cgImage)).perform([request]) + text = request.results?.compactMap { $0.topCandidates(1).first?.string }.joined(separator: " ") ?? "" + let screen = normalized(text) + mismatched = expected.filter { !screen.contains(normalized($0)) } + + absent.filter { screen.contains(normalized($0)) } + } while !mismatched.isEmpty && ContinuousClock.now < deadline + return (text, mismatched) + } + + private func screenText(of store: CompanionStore, expecting expected: [String], + languages: [String] = ["en-US"]) async throws -> (text: String, missing: [String]) { + let (host, window) = try mount(store) + defer { window.close() } + let screen = try await waitForScreen(host, expecting: expected, languages: languages) + return (screen.text, screen.mismatched) + } + + private func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) } + + /// Center of the first OCR line containing `text`, in the host's (window's) coordinates. + private func locate(_ text: String, in host: NSView) throws -> NSPoint? { + host.layoutSubtreeIfNeeded() + let bitmap = try XCTUnwrap(host.bitmapImageRepForCachingDisplay(in: host.bounds)) + host.cacheDisplay(in: host.bounds, to: bitmap) + let request = VNRecognizeTextRequest() + request.recognitionLevel = .accurate + try VNImageRequestHandler(cgImage: XCTUnwrap(bitmap.cgImage)).perform([request]) + let box = request.results?.first { + normalized($0.topCandidates(1).first?.string ?? "") == normalized(text) + }?.boundingBox + return box.map { NSPoint(x: $0.midX * host.bounds.width, y: $0.midY * host.bounds.height) } + } + + /// Some controls track the mouse inside mouseDown and pull the mouseUp from the event queue (a + /// borderless button); others expect it as a separate event (a bordered one). Queue the mouseUp + /// first so a tracking loop can finish, then deliver it directly if nothing took it — that also + /// leaves no event behind for a later test. + private func click(_ window: NSWindow, at point: NSPoint) async throws { + func event(_ type: NSEvent.EventType) throws -> NSEvent { + try XCTUnwrap(NSEvent.mouseEvent( + with: type, location: point, modifierFlags: [], timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, context: nil, eventNumber: 0, clickCount: 1, pressure: 1)) + } + NSApp.postEvent(try event(.leftMouseUp), atStart: false) + window.sendEvent(try event(.leftMouseDown)) + if let unclaimed = NSApp.nextEvent(matching: .leftMouseUp, until: nil, inMode: .default, dequeue: true) { + window.sendEvent(unclaimed) + } + try await Task.sleep(for: .milliseconds(30)) + } + + private func entries(language: AppLanguage = .en, degraded: Bool = false) -> DexEntries { + DexEntries(entries: [DexFlavorText(versionKey: "sword", versionID: 33, versionLabel: "Sword", + text: flavorSentence)], + language: language, isDegraded: degraded) + } + + private func waitUntil(_ condition: () -> Bool) async throws { + let deadline = ContinuousClock.now + .seconds(5) + while !condition(), ContinuousClock.now < deadline { try await Task.sleep(for: .milliseconds(20)) } + } + + func testFlavorFailureKeepsEveryExistingDetailSection() async throws { + let store = try makeStore(details: LoadedDetails(), + flavor: ScriptedFlavor([.failure(RenderingTestError.unavailable)])) + let l = store.l + // OCR reads the count's "0" as "O"; the heading without it is enough. + let moveList = l.completeMoveList(0).replacingOccurrences(of: "0", with: "") + let screen = try await screenText(of: store, expecting: [ + l.pokemonIndividual, l.nature, PokemonNature.adamant.name(.en), l.ability, l.actualStats, + l.activeMoves, l.speciesData, l.possibleAbilities, moveList, l.dexFlavorFailed, l.retry, + ]) + XCTAssertEqual(screen.missing, [], screen.text) + // The failure stays inside the species data card, above the move list. + let text = normalized(screen.text) + let positions = [l.speciesData, l.dexFlavorFailed, moveList].compactMap { + text.range(of: normalized($0))?.lowerBound + } + XCTAssertEqual(positions.count, 3, screen.text) + XCTAssertEqual(positions, positions.sorted(), screen.text) + } + + /// A stalled entries request shows its loading row inside the card, not an empty page. + func testBattleDetailsDoNotWaitForPokedexEntries() async throws { + let flavor = StalledFlavor() + let store = try makeStore(details: LoadedDetails(), flavor: flavor) + let screen = try await screenText(of: store, expecting: [ + store.l.actualStats, store.l.speciesData, store.l.dexFlavorLoading, + ]) + await flavor.release() + XCTAssertEqual(screen.missing, [], screen.text) + } + + /// The entries are requested in their own task, so they are ready while battle details still load + /// and appear together with the species card. + func testEntriesLoadAlongsideBattleDetails() async throws { + let details = StalledDetails() + let store = try makeStore(details: details, flavor: ScriptedFlavor([.success(entries())])) + let request = store.flavorTextRequest(speciesID: 25) + let (host, window) = try mount(store) + defer { window.close() } + try await waitUntil { store.flavorTextsByRequest[request] != nil } + XCTAssertNotNil(store.flavorTextsByRequest[request], "entries must not wait for battle details") + XCTAssertNil(store.pokemonDetailsByID[25]) + await details.release() + let screen = try await waitForScreen(host, expecting: [flavorSentence, store.l.dexFlavorShowAll]) + XCTAssertEqual(screen.mismatched, [], screen.text) + } + + /// REST entry shown → revisit refetches and fails → the entry stays beside retry → retry succeeds and + /// the localized version name replaces the slug. + func testFailedRefetchKeepsTheDegradedEntryUntilRetryRestoresLocalizedLabels() async throws { + let localized = DexEntries(entries: [DexFlavorText(versionKey: "sword", versionID: 33, + versionLabel: "Localized Sword", text: flavorSentence)], + language: .en) + let store = try makeStore(details: LoadedDetails(), flavor: ScriptedFlavor([ + .success(entries(degraded: true)), .failure(RenderingTestError.unavailable), .success(localized), + ])) + await store.loadFlavorTexts(speciesID: 25) + let l = store.l + let (host, window) = try mount(store) + defer { window.close() } + var screen = try await waitForScreen(host, expecting: [ + "Sword", flavorSentence, l.dexFlavorShowAll, l.dexFlavorFailed, l.retry, + ]) + XCTAssertEqual(screen.mismatched, [], screen.text) + XCTAssertTrue(store.failedFlavorTextRequests.contains(store.flavorTextRequest(speciesID: 25)), + "the page must have refetched the degraded result on entry") + + try await click(window, at: XCTUnwrap(locate(l.retry, in: host))) + screen = try await waitForScreen(host, expecting: ["Localized Sword", flavorSentence], + absent: [l.dexFlavorFailed]) + XCTAssertEqual(screen.mismatched, [], screen.text) + XCTAssertFalse(store.flavorTextsByRequest[store.flavorTextRequest(speciesID: 25)]?.isDegraded ?? true) + } + + /// PokéAPI has no Portuguese entries; both the card and the entries page say why the text is English. + func testEnglishFallbackIsAnnouncedOnTheCardAndThePage() async throws { + let store = try makeStore(language: .pt, details: LoadedDetails(), + flavor: ScriptedFlavor([.success(entries(language: .en))])) + let l = store.l + let languages = ["pt-BR", "en-US"] + let (host, window) = try mount(store) + defer { window.close() } + var screen = try await waitForScreen(host, expecting: [l.dexFlavorEnglishFallback, flavorSentence, l.dexFlavorShowAll], + languages: languages) + XCTAssertEqual(screen.mismatched, [], screen.text) + + try await click(window, at: XCTUnwrap(locate(l.dexFlavorShowAll, in: host))) + screen = try await waitForScreen(host, expecting: [l.dexFlavorTitle, l.dexFlavorEnglishFallback, flavorSentence], + absent: [l.speciesData], languages: languages) + XCTAssertEqual(screen.mismatched, [], screen.text) + } + + /// "All Pokédex entries" opens every version oldest first; Back returns to the same detail page — + /// still mounted, at the same scroll position. + func testAllEntriesPageListsEveryVersionOldestFirstAndBackRestoresTheDetail() async throws { + let texts = ["The oldest entry text", "The middle entry text", "The newest entry text"] + let all = DexEntries(entries: [ + DexFlavorText(versionKey: "red", versionID: 1, versionLabel: "Red", text: texts[0]), + DexFlavorText(versionKey: "x", versionID: 23, versionLabel: "X", text: texts[1]), + DexFlavorText(versionKey: "sword", versionID: 33, versionLabel: "Sword", text: texts[2]), + ], language: .en) + let store = try makeStore(details: LoadedDetails(), flavor: ScriptedFlavor([.success(all)])) + let l = store.l + let (host, window) = try mount(store, height: 520) + defer { window.close() } + try await waitUntil { + store.pokemonDetailsByID[25] != nil && store.flavorTextsByRequest[store.flavorTextRequest(speciesID: 25)] != nil + } + + // The species card sits below the fold of the real 520pt page; scroll to it like a user would. + host.layoutSubtreeIfNeeded() + let detailScroll = try XCTUnwrap(descendants(host).compactMap { $0 as? NSScrollView }.first) + let bottom = (detailScroll.documentView?.frame.height ?? 0) - detailScroll.contentView.bounds.height + XCTAssertGreaterThan(bottom, 0, "the detail page must be taller than the popover") + detailScroll.contentView.scroll(to: NSPoint(x: 0, y: bottom)) + detailScroll.reflectScrolledClipView(detailScroll.contentView) + var screen = try await waitForScreen(host, expecting: [texts[2], l.dexFlavorShowAll], + absent: [texts[0], texts[1]]) + XCTAssertEqual(screen.mismatched, [], "the card shows only the newest entry: \(screen.text)") + let offset = detailScroll.documentVisibleRect.origin.y + + try await click(window, at: XCTUnwrap(locate(l.dexFlavorShowAll, in: host))) + screen = try await waitForScreen(host, expecting: texts + [l.dexFlavorTitle], + absent: [l.speciesData, l.actualStats]) + XCTAssertEqual(screen.mismatched, [], screen.text) + let page = normalized(screen.text) + let positions = texts.compactMap { page.range(of: normalized($0))?.lowerBound } + XCTAssertEqual(positions.count, 3, screen.text) + XCTAssertEqual(positions, positions.sorted(), "oldest first: \(screen.text)") + + try await click(window, at: XCTUnwrap(locate(l.back, in: host))) + screen = try await waitForScreen(host, expecting: [l.speciesData, texts[2]], absent: [texts[0], texts[1]]) + XCTAssertEqual(screen.mismatched, [], screen.text) + XCTAssertNotNil(detailScroll.window, "the detail page stays mounted under the entries page") + XCTAssertEqual(detailScroll.documentVisibleRect.origin.y, offset, accuracy: 1) + } +} From 6268b69275a64dcba8c2596b9c853de4eef6ba37 Mon Sep 17 00:00:00 2001 From: LeeTaek <28972501+LeeTaek@users.noreply.github.com> Date: Tue, 6 Oct 2026 09:47:30 +0900 Subject: [PATCH 4/7] docs: note what changes when a request moves from view state to the store Co-Authored-By: Claude Opus 5.5 --- docs/reference/defect-log.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/reference/defect-log.md b/docs/reference/defect-log.md index 37653bb0e..446b69837 100644 --- a/docs/reference/defect-log.md +++ b/docs/reference/defect-log.md @@ -670,6 +670,28 @@ read_when: (출발할 때 봐야 할 시계를 도착해서 보는 격). 가드를 넣을 땐 그 함수 위의 await 까지 거슬러 확인하고, 회귀 테스트도 **그 await 를 실제로 지나는 진입점**으로 써라 — `hatch(baseID:)` 경로 테스트는 `chooseBase()` 를 안 지나 통과하면서 아무것도 지키지 않았다(`testImportDuringSpeciesRollDiscardsTheHatch`). +- **Moving a request from view state into the store changes what is reused and what is cancelled — + decide both again, and name the request's identity.** The first Pokédex-entry design (#161) kept the + result in view `@State` behind `.task(id: species.id)`; neither the design nor its tests said what + identifies a request or when a response may be applied (they passed one language and never changed + it). There the gap was latent: changing the language means opening Settings, which unmounts the page. + Moving the state into `CompanionStore` — so a failure stays inside the description area and a revisit + reuses the result — made it reachable: the retry button's `Task {}` outlives the page, so a Korean response can + land after the switch to English. The move also changed (1) reuse: the client never cached REST + (slug-labelled) results, but a store dictionary would have served them forever; (2) cancellation: the + page's `.task` cancelled the request, the client's catch-all turned that into a REST fallback, and a + store that skips "in flight" requests leaves a reopened page waiting on a cancelled one. Rules: key the + request by every input that changes the response (species + language), capture it before the first + await and re-check it before storing success *and* failure; let the store own the in-flight task so + pages join it instead of cancelling it (the `dexNameRequests` precedent); carry a flag on results that + must not be final (`DexEntries.isDegraded`). One out-of-order test is not enough — with the landing + guard in place, a species-only cache key still passes it — so each mechanism has its own test, each + checked by fault injection: `testStaleLanguageSuccessIsDroppedWhicheverResponseArrivesFirst`, + `testStaleLanguageFailureIsDropped`, `testEachLanguageKeepsItsOwnEntriesAcrossSwitches`, + `testAnotherLanguageStartsItsOwnRequestWhileTheFirstIsPending`, + `testReopeningJoinsTheRequestTheClosedPageStarted`. Sweep: flavor text is the only per-language + PokéAPI request; species/metadata names cache every language, and `DexEntryRow` already keys its task + by language. ## 프로세스·인스턴스 From 95e1a4b7beac34e422692c28c87c278c8a61a37c Mon Sep 17 00:00:00 2001 From: LeeTaek <28972501+LeeTaek@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:21:36 +0900 Subject: [PATCH 5/7] test: keep flavor-text test teardown safe for CI's Swift 6.1 CI (Xcode 16.4, Swift 6.1.2) runs a synchronous tearDown override of a @MainActor XCTestCase as nonisolated, so the main-actor arrays and the shared sprite cache it touched failed to compile there; local Swift 6.2 accepted them. Temporary files now live under one `let` directory that tearDown removes, and the sprite-cache cleanup moved into the test body, as other rendering tests already do. Recorded in the defect log. Co-Authored-By: Claude Opus 5.5 --- .../DexFlavorTextRenderingTests.swift | 50 +++++++++---------- .../DexFlavorTextStoreTests.swift | 14 +++--- docs/reference/defect-log.md | 9 ++++ 3 files changed, 41 insertions(+), 32 deletions(-) diff --git a/Tests/PokeTokenBarTests/DexFlavorTextRenderingTests.swift b/Tests/PokeTokenBarTests/DexFlavorTextRenderingTests.swift index 152dd90d2..c165dc705 100644 --- a/Tests/PokeTokenBarTests/DexFlavorTextRenderingTests.swift +++ b/Tests/PokeTokenBarTests/DexFlavorTextRenderingTests.swift @@ -64,28 +64,30 @@ private let flavorSentence = "Sample entry for the newest version" @MainActor final class DexFlavorTextRenderingTests: XCTestCase { - private var cleanup: [URL] = [] - private var spriteDirectories: [URL] = [] - - /// Like the other rendering tests, leave the shared sprite cache as it was: its entries are keyed - /// by file path, and these temporary paths would otherwise stay behind in its 64 slots. - override func tearDown() { - for directory in spriteDirectories { - for animated in [false, true] { - let file = SpriteStore.cacheKey(speciesID: 25, animated: animated, shiny: false) + (animated ? ".gif" : ".png") - SpriteLoader.imageCache.removeObject(forKey: directory.appendingPathComponent(file).path as NSString) - } + /// Every temporary file lives here. CI's Swift 6.1 runs the synchronous tearDown nonisolated, so it + /// may only touch Sendable constants (see defect-log); the main-actor sprite cache is cleaned in `close(_:)`. + private let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("flavor-render-\(UUID().uuidString)", isDirectory: true) + private var spriteDirectory: URL { directory.appendingPathComponent("sprites", isDirectory: true) } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: directory) + } + + /// Closes the window and, like the other rendering tests, leaves the shared sprite cache as it was: + /// its entries are keyed by file path, and these temporary paths would otherwise stay in its 64 slots. + private func close(_ window: NSWindow) { + window.close() + for animated in [false, true] { + let file = SpriteStore.cacheKey(speciesID: 25, animated: animated, shiny: false) + (animated ? ".gif" : ".png") + SpriteLoader.imageCache.removeObject(forKey: spriteDirectory.appendingPathComponent(file).path as NSString) } - spriteDirectories = [] - cleanup.forEach { try? FileManager.default.removeItem(at: $0) } - cleanup = [] - super.tearDown() } private func makeStore(language: AppLanguage = .en, details: any PokemonDetailProviding, flavor: any PokemonFlavorTextProviding) throws -> CompanionStore { - let file = FileManager.default.temporaryDirectory.appendingPathComponent("flavor-render-\(UUID()).json") - cleanup.append(file) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let file = directory.appendingPathComponent("\(UUID().uuidString).json") var state = CompanionState() state.language = language state.dex = [DexEntry(baseID: 25, finalID: 25, chainOrder: [25], rarity: .common, @@ -97,10 +99,8 @@ final class DexFlavorTextRenderingTests: XCTestCase { /// Seeds the header sprite so the detail page never downloads one. private func offlineSprites() throws -> SpriteStore { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent("flavor-sprites-\(UUID())") + let directory = spriteDirectory try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - cleanup.append(directory) - spriteDirectories.append(directory) let image = NSImage(size: NSSize(width: 96, height: 96)) image.lockFocus() NSColor.gray.setFill() @@ -170,7 +170,7 @@ final class DexFlavorTextRenderingTests: XCTestCase { private func screenText(of store: CompanionStore, expecting expected: [String], languages: [String] = ["en-US"]) async throws -> (text: String, missing: [String]) { let (host, window) = try mount(store) - defer { window.close() } + defer { close(window) } let screen = try await waitForScreen(host, expecting: expected, languages: languages) return (screen.text, screen.mismatched) } @@ -258,7 +258,7 @@ final class DexFlavorTextRenderingTests: XCTestCase { let store = try makeStore(details: details, flavor: ScriptedFlavor([.success(entries())])) let request = store.flavorTextRequest(speciesID: 25) let (host, window) = try mount(store) - defer { window.close() } + defer { close(window) } try await waitUntil { store.flavorTextsByRequest[request] != nil } XCTAssertNotNil(store.flavorTextsByRequest[request], "entries must not wait for battle details") XCTAssertNil(store.pokemonDetailsByID[25]) @@ -279,7 +279,7 @@ final class DexFlavorTextRenderingTests: XCTestCase { await store.loadFlavorTexts(speciesID: 25) let l = store.l let (host, window) = try mount(store) - defer { window.close() } + defer { close(window) } var screen = try await waitForScreen(host, expecting: [ "Sword", flavorSentence, l.dexFlavorShowAll, l.dexFlavorFailed, l.retry, ]) @@ -301,7 +301,7 @@ final class DexFlavorTextRenderingTests: XCTestCase { let l = store.l let languages = ["pt-BR", "en-US"] let (host, window) = try mount(store) - defer { window.close() } + defer { close(window) } var screen = try await waitForScreen(host, expecting: [l.dexFlavorEnglishFallback, flavorSentence, l.dexFlavorShowAll], languages: languages) XCTAssertEqual(screen.mismatched, [], screen.text) @@ -324,7 +324,7 @@ final class DexFlavorTextRenderingTests: XCTestCase { let store = try makeStore(details: LoadedDetails(), flavor: ScriptedFlavor([.success(all)])) let l = store.l let (host, window) = try mount(store, height: 520) - defer { window.close() } + defer { close(window) } try await waitUntil { store.pokemonDetailsByID[25] != nil && store.flavorTextsByRequest[store.flavorTextRequest(speciesID: 25)] != nil } diff --git a/Tests/PokeTokenBarTests/DexFlavorTextStoreTests.swift b/Tests/PokeTokenBarTests/DexFlavorTextStoreTests.swift index a1788ae6f..ee99797bc 100644 --- a/Tests/PokeTokenBarTests/DexFlavorTextStoreTests.swift +++ b/Tests/PokeTokenBarTests/DexFlavorTextStoreTests.swift @@ -48,17 +48,17 @@ private actor ControlledFlavorProvider: PokemonFlavorTextProviding { @MainActor final class DexFlavorTextStoreTests: XCTestCase { - private var files: [URL] = [] + /// A Sendable constant: CI's Swift 6.1 runs the synchronous tearDown nonisolated (see defect-log). + private let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("flavor-store-\(UUID().uuidString)", isDirectory: true) - override func tearDown() { - files.forEach { try? FileManager.default.removeItem(at: $0) } - files = [] - super.tearDown() + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: directory) } private func makeStore(_ provider: ControlledFlavorProvider, language: AppLanguage = .ko) -> CompanionStore { - let file = FileManager.default.temporaryDirectory.appendingPathComponent("flavor-store-\(UUID()).json") - files.append(file) + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let file = directory.appendingPathComponent("\(UUID().uuidString).json") let store = CompanionStore(provider: OfflineLineProvider(), flavorProvider: provider, fileURL: file) store.setLanguage(language) return store diff --git a/docs/reference/defect-log.md b/docs/reference/defect-log.md index 446b69837..b645c3404 100644 --- a/docs/reference/defect-log.md +++ b/docs/reference/defect-log.md @@ -548,6 +548,15 @@ read_when: 안에 보관한다. 객체 동일성 검증과 동시 요청은 유지하며, Sendable 우회 선언을 추가하지 않는다. 회귀 가드: `SpriteImageCacheTests` 의 두 동시 로드 테스트와 `macos-15` CI의 테스트 컴파일. (CI 실패: 2026-09-10.) +- **`@MainActor` XCTestCase 의 동기 `setUp`/`tearDown` 은 CI(Xcode 16.4/Swift 6.1.2)에서 nonisolated 다.** + #161 의 도감 설명 테스트가 `override func tearDown()` 에서 메인 액터 `var` 배열과 `SpriteLoader.imageCache` + 를 건드려 로컬 Swift 6.2.4 에서는 통과했지만 CI 테스트 컴파일이 실패했다. **왜 못 걸렀나:** 위 항목과 같다 — + 로컬 최신 도구체인 검증은 CI 격리 규칙 검증이 아니고, 테스트 코드를 쓸 때 이 섹션을 대조하지 않았다. + → tearDown 은 `let` Sendable 상수만 쓰고(`UsageRecapRenderingTests` 의 `let url`), 상태가 꼭 필요하면 + `nonisolated(unsafe)` 로 둔다(`RareCandyTests`). 메인 액터 정리는 테스트 본문의 `defer` 에서 한다 + (`DexColorRenderingTests.withSprites`, `DexFlavorTextRenderingTests.close(_:)`). 스윕: 다른 `@MainActor` + 테스트의 동기 setUp/tearDown 은 이미 이 둘 중 하나만 쓴다. 회귀 가드: `macos-15` CI의 테스트 컴파일. + (CI 실패: 2026-10-06.) - **SwiftUI `View`/`App` 경계는 `@MainActor` 를 명시한다.** Swift 6.3 은 `body` 밖의 `@ViewBuilder` helper· 동기 클로저를 nonisolated 로 검사해, `@MainActor` `@Observable` store 접근이 수십 개의 오류로 연쇄된다. 개별 프로퍼티에 `MainActor.assumeIsolated` 를 흩뿌리지 말고 UI 타입 선언 한 곳에 격리를 둔다. From 6c603d59ab150f9851e57eec69a3b0b8f1d23308 Mon Sep 17 00:00:00 2001 From: LeeTaek <28972501+LeeTaek@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:41:17 +0900 Subject: [PATCH 6/7] test: make flavor-text rendering checks tolerate CI's OCR misreads On the macOS 15 runner, Vision read body-size text as "Samole" or "Pokedey" and three rendering tests failed although the page was right; locally (Retina, newer Vision) the reads were exact. Capturing at a higher scale alone does not help: cacheDisplay resamples text that was already rasterized at the window's scale. OCR now runs on a 3x enlargement with the expected words as custom words, and text that must be present may differ by up to 15% of its letters, while text that must be gone is still matched exactly. The entries-page fixture uses different first words so a tolerant match cannot confuse entries. With a capture degraded to 0.8x, 1x and 2x all six tests pass (0.6x: 5 of 6, from 1 of 6 before), and each of the seven injected view defects still fails its test. Recorded in the defect log. Co-Authored-By: Claude Opus 5.5 --- .../DexFlavorTextRenderingTests.swift | 88 ++++++++++++++----- docs/reference/defect-log.md | 8 ++ 2 files changed, 74 insertions(+), 22 deletions(-) diff --git a/Tests/PokeTokenBarTests/DexFlavorTextRenderingTests.swift b/Tests/PokeTokenBarTests/DexFlavorTextRenderingTests.swift index c165dc705..200890c4c 100644 --- a/Tests/PokeTokenBarTests/DexFlavorTextRenderingTests.swift +++ b/Tests/PokeTokenBarTests/DexFlavorTextRenderingTests.swift @@ -143,6 +143,60 @@ final class DexFlavorTextRenderingTests: XCTestCase { return (host, window) } + /// OCR misreads body-size text at low resolution ("Sample" as "Samole"), and CI renders at a lower + /// scale than a Retina Mac, so its reads are worse. Capture at 2×, enlarge before OCR, prime Vision with + /// the words a test looks for, and let text that must be present differ by a few misread letters. + private static let captureScale: CGFloat = 2 + private static let ocrScale: CGFloat = 3 + + private func capture(_ host: NSView) throws -> CGImage { + host.layoutSubtreeIfNeeded() + let bitmap = try XCTUnwrap(NSBitmapImageRep( + bitmapDataPlanes: nil, pixelsWide: Int(host.bounds.width * Self.captureScale), + pixelsHigh: Int(host.bounds.height * Self.captureScale), bitsPerSample: 8, samplesPerPixel: 4, + hasAlpha: true, isPlanar: false, colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0)) + bitmap.size = host.bounds.size + host.cacheDisplay(in: host.bounds, to: bitmap) + let image = try XCTUnwrap(bitmap.cgImage) + let width = Int(host.bounds.width * Self.ocrScale), height = Int(host.bounds.height * Self.ocrScale) + let context = try XCTUnwrap(CGContext( + data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: 0, + space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)) + context.interpolationQuality = .high + context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) + return try XCTUnwrap(context.makeImage()) + } + + private func recognize(_ host: NSView, words: [String], languages: [String]) throws -> [VNRecognizedTextObservation] { + let request = VNRecognizeTextRequest() + request.recognitionLevel = .accurate + request.recognitionLanguages = languages + request.usesLanguageCorrection = true + request.customWords = words.flatMap { $0.split { !$0.isLetter }.map(String.init) } + try VNImageRequestHandler(cgImage: capture(host)).perform([request]) + return request.results ?? [] + } + + /// End offset of the closest match of `needle` inside `haystack` (both normalized), if at most 15% of + /// its characters differ: OCR slips such as "Samole" still match, different strings do not. + private func fuzzyMatch(_ needle: String, in haystack: String) -> Int? { + let n = Array(needle), h = Array(haystack) + guard !n.isEmpty, !h.isEmpty else { return nil } + let budget = max(1, n.count * 15 / 100) + var previous = [Int](repeating: 0, count: h.count + 1) + for i in 1...n.count { + var current = [Int](repeating: i, count: h.count + 1) + for j in 1...h.count { + current[j] = min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + (n[i - 1] == h[j - 1] ? 0 : 1)) + } + previous = current + } + guard let best = previous.indices.min(by: { previous[$0] < previous[$1] }), previous[best] <= budget else { + return nil + } + return best + } + /// Reads the page back with OCR until every expected string is on screen and every absent one is /// gone, or five seconds pass. Returns the last text and what still did not match. private func waitForScreen(_ host: NSView, expecting expected: [String], absent: [String] = [], @@ -152,16 +206,11 @@ final class DexFlavorTextRenderingTests: XCTestCase { var mismatched = expected + absent repeat { try await Task.sleep(for: .milliseconds(50)) - host.layoutSubtreeIfNeeded() - let bitmap = try XCTUnwrap(host.bitmapImageRepForCachingDisplay(in: host.bounds)) - host.cacheDisplay(in: host.bounds, to: bitmap) - let request = VNRecognizeTextRequest() - request.recognitionLevel = .accurate - request.recognitionLanguages = languages - try VNImageRequestHandler(cgImage: XCTUnwrap(bitmap.cgImage)).perform([request]) - text = request.results?.compactMap { $0.topCandidates(1).first?.string }.joined(separator: " ") ?? "" + let observations = try recognize(host, words: expected + absent, languages: languages) + text = observations.compactMap { $0.topCandidates(1).first?.string }.joined(separator: " ") let screen = normalized(text) - mismatched = expected.filter { !screen.contains(normalized($0)) } + // Text that must be present tolerates misread letters; text that must be gone is matched exactly. + mismatched = expected.filter { fuzzyMatch(normalized($0), in: screen) == nil } + absent.filter { screen.contains(normalized($0)) } } while !mismatched.isEmpty && ContinuousClock.now < deadline return (text, mismatched) @@ -178,15 +227,9 @@ final class DexFlavorTextRenderingTests: XCTestCase { private func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) } /// Center of the first OCR line containing `text`, in the host's (window's) coordinates. - private func locate(_ text: String, in host: NSView) throws -> NSPoint? { - host.layoutSubtreeIfNeeded() - let bitmap = try XCTUnwrap(host.bitmapImageRepForCachingDisplay(in: host.bounds)) - host.cacheDisplay(in: host.bounds, to: bitmap) - let request = VNRecognizeTextRequest() - request.recognitionLevel = .accurate - try VNImageRequestHandler(cgImage: XCTUnwrap(bitmap.cgImage)).perform([request]) - let box = request.results?.first { - normalized($0.topCandidates(1).first?.string ?? "") == normalized(text) + private func locate(_ text: String, in host: NSView, languages: [String] = ["en-US"]) throws -> NSPoint? { + let box = try recognize(host, words: [text], languages: languages).first { + fuzzyMatch(normalized(text), in: normalized($0.topCandidates(1).first?.string ?? "")) != nil }?.boundingBox return box.map { NSPoint(x: $0.midX * host.bounds.width, y: $0.midY * host.bounds.height) } } @@ -234,7 +277,7 @@ final class DexFlavorTextRenderingTests: XCTestCase { // The failure stays inside the species data card, above the move list. let text = normalized(screen.text) let positions = [l.speciesData, l.dexFlavorFailed, moveList].compactMap { - text.range(of: normalized($0))?.lowerBound + fuzzyMatch(normalized($0), in: text) } XCTAssertEqual(positions.count, 3, screen.text) XCTAssertEqual(positions, positions.sorted(), screen.text) @@ -306,7 +349,7 @@ final class DexFlavorTextRenderingTests: XCTestCase { languages: languages) XCTAssertEqual(screen.mismatched, [], screen.text) - try await click(window, at: XCTUnwrap(locate(l.dexFlavorShowAll, in: host))) + try await click(window, at: XCTUnwrap(locate(l.dexFlavorShowAll, in: host, languages: languages))) screen = try await waitForScreen(host, expecting: [l.dexFlavorTitle, l.dexFlavorEnglishFallback, flavorSentence], absent: [l.speciesData], languages: languages) XCTAssertEqual(screen.mismatched, [], screen.text) @@ -315,7 +358,8 @@ final class DexFlavorTextRenderingTests: XCTestCase { /// "All Pokédex entries" opens every version oldest first; Back returns to the same detail page — /// still mounted, at the same scroll position. func testAllEntriesPageListsEveryVersionOldestFirstAndBackRestoresTheDetail() async throws { - let texts = ["The oldest entry text", "The middle entry text", "The newest entry text"] + // Different first words, so a tolerant match cannot mistake one entry for another. + let texts = ["Amber entry text", "Cobalt entry text", "Jade entry text"] let all = DexEntries(entries: [ DexFlavorText(versionKey: "red", versionID: 1, versionLabel: "Red", text: texts[0]), DexFlavorText(versionKey: "x", versionID: 23, versionLabel: "X", text: texts[1]), @@ -346,7 +390,7 @@ final class DexFlavorTextRenderingTests: XCTestCase { absent: [l.speciesData, l.actualStats]) XCTAssertEqual(screen.mismatched, [], screen.text) let page = normalized(screen.text) - let positions = texts.compactMap { page.range(of: normalized($0))?.lowerBound } + let positions = texts.compactMap { fuzzyMatch(normalized($0), in: page) } XCTAssertEqual(positions.count, 3, screen.text) XCTAssertEqual(positions, positions.sorted(), "oldest first: \(screen.text)") diff --git a/docs/reference/defect-log.md b/docs/reference/defect-log.md index b645c3404..41a4e7d28 100644 --- a/docs/reference/defect-log.md +++ b/docs/reference/defect-log.md @@ -557,6 +557,14 @@ read_when: (`DexColorRenderingTests.withSprites`, `DexFlavorTextRenderingTests.close(_:)`). 스윕: 다른 `@MainActor` 테스트의 동기 setUp/tearDown 은 이미 이 둘 중 하나만 쓴다. 회귀 가드: `macos-15` CI의 테스트 컴파일. (CI 실패: 2026-10-06.) +- **CI 의 OCR 은 로컬보다 부정확하다 — Vision 으로 화면을 읽는 테스트는 오독을 견디게 쓴다.** #161 의 + 도감 설명 렌더링 테스트가 CI(macOS 15 러너)에서 본문 크기 글자를 "Sample"→"Samole", "Pokédex"→"Pokedey" + 로 읽어 실패했다. **왜 못 걸렀나:** 로컬 Retina·최신 Vision 에서는 오독이 없었다. 캡처를 0.6배로 낮추면 + 로컬에서도 같은 오독이 재현된다(6개 중 1개만 통과). 캡처 배율만 올리는 건 해결이 아니다 — + `cacheDisplay` 는 창 배율로 이미 래스터화된 글자를 다시 샘플링할 뿐이다. → OCR 전에 3배로 확대하고, + `customWords` 로 찾는 단어를 알려 주고, "있어야 할" 문자열만 글자 15% 오차를 허용한다("없어야 할" 문자열은 + 정확 일치). 픽스처 문장은 첫 단어부터 서로 다르게 둔다. 이렇게 바꾼 뒤 0.6배 5/6, 0.8·1·2배 6/6 통과, + 화면 결함 주입 7종은 여전히 모두 실패함을 확인했다(`DexFlavorTextRenderingTests`). (CI 실패: 2026-10-06.) - **SwiftUI `View`/`App` 경계는 `@MainActor` 를 명시한다.** Swift 6.3 은 `body` 밖의 `@ViewBuilder` helper· 동기 클로저를 nonisolated 로 검사해, `@MainActor` `@Observable` store 접근이 수십 개의 오류로 연쇄된다. 개별 프로퍼티에 `MainActor.assumeIsolated` 를 흩뿌리지 말고 UI 타입 선언 한 곳에 격리를 둔다. From f3ee6cbeee4571bda0b4a6c016ed2cca6319745c Mon Sep 17 00:00:00 2001 From: LeeTaek <28972501+LeeTaek@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:41:56 +0900 Subject: [PATCH 7/7] test: check the degraded entry's slug label through the store Under heavy load Vision dropped the short accent-colored "Sword" label while reading every other line of the card, failing the refetch test although the card was right. The entry text and the retry row still prove the entry survived the failed refetch; the store now confirms which label it carries. Injecting a failed refetch that hides the entry still fails the test. Co-Authored-By: Claude Opus 5.5 --- .../DexFlavorTextRenderingTests.swift | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Tests/PokeTokenBarTests/DexFlavorTextRenderingTests.swift b/Tests/PokeTokenBarTests/DexFlavorTextRenderingTests.swift index 200890c4c..25b1fa6fa 100644 --- a/Tests/PokeTokenBarTests/DexFlavorTextRenderingTests.swift +++ b/Tests/PokeTokenBarTests/DexFlavorTextRenderingTests.swift @@ -323,18 +323,22 @@ final class DexFlavorTextRenderingTests: XCTestCase { let l = store.l let (host, window) = try mount(store) defer { close(window) } + // The slug label alone is a short accent-colored word that OCR drops under load; the entry text + // and the retry row are what show the entry survived, and the store says which label it carries. var screen = try await waitForScreen(host, expecting: [ - "Sword", flavorSentence, l.dexFlavorShowAll, l.dexFlavorFailed, l.retry, + flavorSentence, l.dexFlavorShowAll, l.dexFlavorFailed, l.retry, ]) XCTAssertEqual(screen.mismatched, [], screen.text) - XCTAssertTrue(store.failedFlavorTextRequests.contains(store.flavorTextRequest(speciesID: 25)), + let request = store.flavorTextRequest(speciesID: 25) + XCTAssertTrue(store.failedFlavorTextRequests.contains(request), "the page must have refetched the degraded result on entry") + XCTAssertEqual(store.flavorTextsByRequest[request]?.entries.last?.versionLabel, "Sword") try await click(window, at: XCTUnwrap(locate(l.retry, in: host))) screen = try await waitForScreen(host, expecting: ["Localized Sword", flavorSentence], absent: [l.dexFlavorFailed]) XCTAssertEqual(screen.mismatched, [], screen.text) - XCTAssertFalse(store.flavorTextsByRequest[store.flavorTextRequest(speciesID: 25)]?.isDegraded ?? true) + XCTAssertFalse(store.flavorTextsByRequest[request]?.isDegraded ?? true) } /// PokéAPI has no Portuguese entries; both the card and the entries page say why the text is English.