diff --git a/Sources/PokeTokenBar/Core/CompanionStore.swift b/Sources/PokeTokenBar/Core/CompanionStore.swift index 5cc470fd..87a8ab98 100644 --- a/Sources/PokeTokenBar/Core/CompanionStore.swift +++ b/Sources/PokeTokenBar/Core/CompanionStore.swift @@ -1539,29 +1539,190 @@ final class CompanionStore { /// 상황에서 되돌릴 대상이 사라진다. 불러올 때마다 새 슬롯을 쓰고 오래된 것부터 정리한다. @discardableResult private func backupStateBeforeImport() throws -> URL { + try backupState(prefix: SaveTransfer.backupFilePrefix, fileName: SaveTransfer.backupFileName, + failureLogReason: "save import aborted") + } + + /// Shared routine for saving the state beside itself just before an overwrite or trade commit + /// — the caller only decides the prefix and filename rule. With a single slot, a second backup + /// would overwrite the **original**, so in exactly the situation the backup exists for ("this + /// went wrong, undo it") there would be nothing left to undo to. Write a new slot every time + /// and prune the oldest. + private func backupState(prefix: String, fileName: (Date) -> String, failureLogReason: String) throws -> URL { guard let data = try? JSONEncoder().encode(state) else { throw SaveTransferError.backupFailed } - let dir = fileURL.deletingLastPathComponent() - let backup = dir.appendingPathComponent(SaveTransfer.backupFileName(date: clock())) + let dir = stateDirectory + let backup = dir.appendingPathComponent(fileName(clock())) do { try data.write(to: backup, options: .atomic) } catch { - AppLog.write("save import aborted — backup write failed: \(error)") + AppLog.write("\(failureLogReason) — backup write failed: \(error)") throw SaveTransferError.backupFailed } - pruneImportBackups(in: dir) + pruneBackups(prefix: prefix, in: dir) return backup } /// 최근 N 개만 남기고 오래된 백업을 지운다. 파일명이 `yyyy-MM-dd-HHmmss` 라 사전순 = 시간순이다. - private func pruneImportBackups(in dir: URL) { + private func pruneBackups(prefix: String, in dir: URL) { guard let names = try? FileManager.default.contentsOfDirectory(atPath: dir.path) else { return } - let backups = names.filter { $0.hasPrefix(SaveTransfer.backupFilePrefix) }.sorted() + let backups = names.filter { $0.hasPrefix(prefix) }.sorted() guard backups.count > SaveTransfer.backupsToKeep else { return } for stale in backups.dropLast(SaveTransfer.backupsToKeep) { try? FileManager.default.removeItem(at: dir.appendingPathComponent(stale)) } } + // MARK: P2P Trade + + /// Directory the backup files land in — used only by `backupState`. The UI's "Open in Finder" + /// does not use this value; it selects the file directly from the backup path + /// `applyTradeCommit` returns (`lastBackupURL`). + private var stateDirectory: URL { fileURL.deletingLastPathComponent() } + + /// For the trade screen's first paint — the best name available right now without the network. + /// Our own in-progress mon resolves to an exact name immediately when the already-loaded + /// `currentLine` is the same line. Everything else (the partner's item, our own mon when the + /// line isn't loaded yet) starts as `TradeItem.displayName`'s `#id` and is filled in by + /// `resolveTradeItemName`. + func cachedTradeItemName(for item: TradeItem) -> String { + if case .activeMon(let mon) = item, let line = currentLine, line.baseID == mon.baseID { + return line.localizedName(mon.currentID, state.language) + } + return item.displayName(language: state.language) + } + + /// The exact name of a trade item — for our own item and the partner's alike. The partner's + /// dex entry resolves without the network from the `names` carried in the payload; everything + /// else (every in-progress mon, and dex entries with no names) looks the species line up via + /// `dexNameLine` — even for the partner's species, the PokeAPI lookup is something we can do + /// on our side. Offline, or when the lookup fails, it falls back to `TradeItem.displayName`'s + /// `#id`. + func resolveTradeItemName(for item: TradeItem) async -> String { + if case .dexEntry(let entry) = item, let names = entry.names, + let resolved = state.language.resolveName(names[entry.finalID] ?? [:]) { + return resolved + } + guard let line = try? await dexNameLine(baseID: item.displayBaseID) else { + return item.displayName(language: state.language) + } + return line.localizedName(item.displaySpeciesID, state.language) + } + + /// Dex entries that may be offered in a trade. Released entries stay in `state.dex` so the + /// species keeps its Pokédex page, but they record a mon we let go rather than one we hold — + /// there is nothing there to hand over. + var tradeOfferableDexEntries: [DexEntry] { + state.dex.filter { !$0.isReleased } + } + + /// Warning that something we currently hold is about to be destroyed by receiving the + /// partner's in-progress mon — nil when no warning is needed (receiving a dex entry destroys + /// nothing). If a mon is being raised, that mon is what goes; if not, a paid egg guarantee + /// (eggTier) or hatching progress (eggUsage) goes instead — a guarantee can be a purchase + /// worth billions of tokens, so it must not disappear silently. + func tradeOverwriteWarning(forReceiving item: TradeItem) -> String? { + guard item.isActiveMon else { return nil } + if let active = state.active { + let percent = Int((Double(active.usedAtStage) / Double(max(1, stageThreshold(for: active)))) * 100) + let name = currentLine?.localizedName(active.currentID, state.language) ?? "#\(active.currentID)" + return l.tradeOverwriteWarning(name: name, percent: percent) + } + guard state.eggTier != nil || state.eggUsage > 0 else { return nil } + return l.tradeOverwriteEggProgressWarning(guaranteeTierLabel: state.eggTier.map { l.rarityLabel($0) }) + } + + /// Applies a trade commit — back up, remove what we gave, add what we received. If the backup + /// can't be written it throws **without applying** (same principle as SaveTransfer.applySave: + /// the confirmation screen promised a way back, and proceeding without keeping that promise + /// costs the user their progress with no way to undo). + /// Returns the backup file path actually used, so the caller (UI) can point at it exactly + /// instead of guessing the filename. + /// `sending:` must be this device's local offer, never a value the partner sent. + /// `removeTradedItem` uses it as-is to drive `dex.removeAll`/`active = nil`, so passing the + /// partner's echo here would open a path to deleting arbitrary items with no normalization. + /// Only `receiving:` is trust-boundary data, and it goes through `sanitized()`. + @discardableResult + func applyTradeCommit(sending sentItem: TradeItem, receiving receivedItem: TradeItem) throws -> URL { + let backupURL = try backupStateBeforeTrade() + let sanitizedReceived = receivedItem.sanitized() + removeTradedItem(sentItem) + addTradedItem(sanitizedReceived) + state.reconcileRepresentativeSelection() + if sentItem.isActiveMon || sanitizedReceived.isActiveMon { + invalidateActiveMonPresentation() + } + save() + if state.active != nil { Task { await loadCurrentLine() } } + // Log the backup filename too — leaving the Trade tab right after a commit takes the + // on-screen backup notice with it, and only the log remains. + AppLog.write("trade committed — sent=\(sentItem.rarity.rawValue) received=\(sanitizedReceived.rarity.rawValue) backup=\(backupURL.lastPathComponent)") + return backupURL + } + + /// Needed for the same reason as the invalidation block at the top of `applySave` — when a + /// trade changes `active`, any line load or animation started for the previous mon must be + /// invalidated, or an in-flight load overwrites the new mon (receiving side) or the screen + /// keeps showing a mon that is already gone (giving side). + private func invalidateActiveMonPresentation() { + activeGeneration += 1 + currentLine = nil + prefetchedLineID = nil + displayState = state.active != nil ? .idle : .egg + } + + private func removeTradedItem(_ item: TradeItem) { + switch item { + case .dexEntry(let entry): + state.dex.removeAll { $0.id == entry.id } + case .activeMon: + // We gave our mon away, so a fresh egg is needed — leaving the guarantee (eggTier) + // behind would hand it to the next free egg (the same concern as in + // SaveTransfer.sanitized). + state.active = nil + state.eggTier = nil + state.pendingHatchID = nil + state.pendingUnownForm = nil + state.eggUsage = 0 + } + } + + private func addTradedItem(_ item: TradeItem) { + switch item { + case .dexEntry(var entry): + // The spec allows a one-sided commit (covered by the backup), so the same id can + // legitimately exist on both devices; trading that item back would put a duplicate id + // in our dex, and `removeTradedItem`'s `removeAll { $0.id == }` would then delete two + // mons in a single trade. The id is a local identifier with no display meaning, so + // reissuing it costs nothing. + // `profile.instanceID` is where a graduated entry's id comes from, so the two must stay + // equal — and two individuals must never share an instanceID. Reissuing only `id` would + // leave both invariants broken on the traded copy. + // The instanceID is checked too: a graduated entry's id comes from it, so a twin cloned + // from one of ours collides on both. Checking only the id leaves two individuals sharing + // an instanceID when the ids happen to differ. + let incomingInstanceID = entry.profile?.instanceID + let collides = state.dex.contains { + $0.id == entry.id || ($0.profile?.instanceID != nil && $0.profile?.instanceID == incomingInstanceID) + } + if collides { + entry.id = UUID().uuidString + entry.profile?.instanceID = entry.id + } + state.dex.append(entry) + case .activeMon(let mon): + state.active = mon + state.eggTier = nil + state.pendingHatchID = nil + state.pendingUnownForm = nil + state.eggUsage = 0 + } + } + + private func backupStateBeforeTrade() throws -> URL { + try backupState(prefix: SaveTransfer.tradeBackupFilePrefix, fileName: SaveTransfer.tradeBackupFileName, + failureLogReason: "trade commit aborted") + } + // MARK: Pokémon combat profiles / details /// Exact current/final individuals for a Pokédex species. Earlier evolution stages remain diff --git a/Sources/PokeTokenBar/Core/Localization.swift b/Sources/PokeTokenBar/Core/Localization.swift index be0e78c3..c4be9647 100644 --- a/Sources/PokeTokenBar/Core/Localization.swift +++ b/Sources/PokeTokenBar/Core/Localization.swift @@ -694,6 +694,76 @@ struct L { var dexEmptyTitle: String { t("아직 잡은 포켓몬이 없어요!", "No Pokémon caught yet!", "まだ捕まえたポケモンがいません!", "¡Todavía no has capturado ningún Pokémon!", "Aucun Pokémon capturé pour l'instant !", "Você ainda não capturou nenhum Pokémon!", "Du hast noch kein Pokémon gefangen!") } var dexEmptyHint: String { t("토큰을 써서 첫 포켓몬을 부화시켜 보세요.", "Spend tokens to hatch your first Pokémon.", "トークンを使って最初のポケモンを孵化させましょう。", "Usa tokens para eclosionar tu primer Pokémon.", "Dépense des tokens pour faire éclore ton premier Pokémon.", "Use tokens para chocar seu primeiro Pokémon.", "Verwende Tokens, damit dein erstes Pokémon schlüpft.") } + // MARK: P2P Trade + func tradeOverwriteWarning(name: String, percent: Int) -> String { + t("상대방이 육성중인 개체를 받으면 지금 키우는 \(name)(진행도 \(percent)%)가 사라집니다.", + "Receiving the other trainer's Pokémon in training will delete your current \(name) (progress \(percent)%).", + "相手が育成中のポケモンを受け取ると、今育てている\(name)(進捗\(percent)%)が消えます。", + "Si recibes el Pokémon en crianza del otro entrenador, se eliminará tu \(name) actual (progreso \(percent)%).", + "Si tu reçois le Pokémon en cours d'élevage de l'autre dresseur, ton \(name) actuel (progression \(percent)%) sera supprimé.", + "Se você receber o Pokémon em treinamento do outro treinador, seu \(name) atual (progresso \(percent)%) será apagado.", + "Wenn du das im Training befindliche Pokémon des anderen Trainers erhältst, wird dein aktuelles \(name) (Fortschritt \(percent)%) gelöscht.") + } + /// Warning shown when receiving an in-progress mon while nothing is being raised but a paid + /// egg guarantee or hatching progress would still be lost. + func tradeOverwriteEggProgressWarning(guaranteeTierLabel: String?) -> String { + if let guaranteeTierLabel { + return t("상대방이 육성중인 개체를 받으면 지금 가진 \(guaranteeTierLabel) 알 보증과 부화 진행이 사라집니다.", + "Receiving the other trainer's Pokémon in training will delete your current \(guaranteeTierLabel) egg guarantee and hatching progress.", + "相手が育成中のポケモンを受け取ると、今持っている\(guaranteeTierLabel)タマゴの保証と孵化の進捗が消えます。", + "Si recibes el Pokémon en crianza del otro entrenador, se eliminará tu garantía de huevo \(guaranteeTierLabel) y el progreso de incubación.", + "Si tu reçois le Pokémon en cours d'élevage de l'autre dresseur, ta garantie d'œuf \(guaranteeTierLabel) et ta progression d'incubation seront supprimées.", + "Se você receber o Pokémon em treinamento do outro treinador, sua garantia de ovo \(guaranteeTierLabel) e o progresso de incubação serão apagados.", + "Wenn du das im Training befindliche Pokémon des anderen Trainers erhältst, werden deine \(guaranteeTierLabel)-Ei-Garantie und dein Ausbrütfortschritt gelöscht.") + } + return t("상대방이 육성중인 개체를 받으면 지금까지 쌓은 알 부화 진행이 사라집니다.", + "Receiving the other trainer's Pokémon in training will delete your current egg-hatching progress.", + "相手が育成中のポケモンを受け取ると、今までのタマゴの孵化の進捗が消えます。", + "Si recibes el Pokémon en crianza del otro entrenador, se eliminará tu progreso actual de incubación del huevo.", + "Si tu reçois le Pokémon en cours d'élevage de l'autre dresseur, ta progression d'incubation actuelle sera supprimée.", + "Se você receber o Pokémon em treinamento do outro treinador, seu progresso atual de incubação será apagado.", + "Wenn du das im Training befindliche Pokémon des anderen Trainers erhältst, wird dein aktueller Ausbrütfortschritt gelöscht.") + } + var tradeProposalTitle: String { t("교환 제안", "Trade proposal", "交換の提案", "Propuesta de intercambio", "Proposition d'échange", "Proposta de troca", "Tauschvorschlag") } + var tradeGiving: String { t("내가 줄 것", "You're giving", "渡すもの", "Tú das", "Tu donnes", "Você dá", "Du gibst") } + var tradeReceiving: String { t("내가 받을 것", "You're receiving", "受け取るもの", "Tú recibes", "Tu reçois", "Você recebe", "Du bekommst") } + var tradeAccept: String { t("승인", "Accept", "承認", "Aceptar", "Accepter", "Aceitar", "Annehmen") } + var tradeReject: String { t("거절", "Reject", "拒否", "Rechazar", "Refuser", "Recusar", "Ablehnen") } + var trade: String { t("교환", "Trade", "交換", "Intercambio", "Échange", "Troca", "Tausch") } + var tradeNickname: String { t("내 교환 닉네임", "Your trade nickname", "自分の交換ニックネーム", "Tu apodo de intercambio", "Ton pseudo d'échange", "Seu apelido de troca", "Dein Tausch-Spitzname") } + var tradeMyCode: String { t("내 교환 코드", "Your trade code", "自分の交換コード", "Tu código de intercambio", "Ton code d'échange", "Seu código de troca", "Dein Tauschcode") } + var tradeFindPeers: String { t("상대 찾기", "Find a partner", "相手を探す", "Buscar compañero", "Trouver un partenaire", "Encontrar parceiro", "Partner suchen") } + var tradeSearching: String { t("탐색 중…", "Searching…", "検索中…", "Buscando…", "Recherche…", "Procurando…", "Suche…") } + var tradeConnectedTo: String { t("연결된 상대", "Connected to", "接続中の相手", "Conectado con", "Connecté à", "Conectado a", "Verbunden mit") } + var tradeAutoDiscoveryFailed: String { t("자동으로 못 찾았어요 — 수동으로 연결하기", "Couldn't find them automatically — connect manually", "自動で見つかりませんでした — 手動で接続", "No se encontró automáticamente — conectar manualmente", "Introuvable automatiquement — connexion manuelle", "Não encontrado automaticamente — conectar manualmente", "Automatisch nicht gefunden — manuell verbinden") } + var tradeConnectFailed: String { t("그 상대에게 연결하지 못했어요 — 목록에서 다시 고르거나 수동으로 연결하세요.", "Couldn't connect to them — pick again from the list, or connect manually.", "その相手に接続できませんでした — 一覧から選び直すか、手動で接続してください。", "No se pudo conectar: elige de nuevo en la lista o conecta manualmente.", "Connexion impossible — choisis à nouveau dans la liste ou connecte-toi manuellement.", "Não foi possível conectar — escolha de novo na lista ou conecte manualmente.", "Verbindung fehlgeschlagen — wähle erneut aus der Liste oder verbinde manuell.") } + var tradeSwitchToManual: String { t("수동으로 연결하기", "Connect manually", "手動で接続", "Conectar manualmente", "Connexion manuelle", "Conectar manualmente", "Manuell verbinden") } + var tradeManualMyCode: String { t("내 연결 코드", "Your connection code", "自分の接続コード", "Tu código de conexión", "Ton code de connexion", "Seu código de conexão", "Dein Verbindungscode") } + var tradeManualCodePreparing: String { t("연결 코드 준비 중…", "Preparing your connection code…", "接続コードを準備中…", "Preparando tu código de conexión…", "Préparation de ton code de connexion…", "Preparando seu código de conexão…", "Verbindungscode wird vorbereitet…") } + var tradeManualCodeUnavailable: String { t("연결 코드를 만들지 못했어요 — Wi-Fi나 이더넷 연결을 확인하세요.", "Couldn't create a connection code — check your Wi-Fi or Ethernet connection.", "接続コードを作成できませんでした — Wi-Fiまたはイーサネットの接続を確認してください。", "No se pudo crear un código de conexión: revisa tu conexión Wi-Fi o Ethernet.", "Impossible de créer un code de connexion — vérifie ta connexion Wi-Fi ou Ethernet.", "Não foi possível criar um código de conexão — verifique sua conexão Wi-Fi ou Ethernet.", "Verbindungscode konnte nicht erstellt werden — prüfe deine WLAN- oder Ethernet-Verbindung.") } + var tradeManualEnterCode: String { t("상대 코드 입력", "Enter their code", "相手のコードを入力", "Introduce su código", "Entrer leur code", "Digite o código recebido", "Code eingeben") } + var tradeManualCodeInvalid: String { t("코드 형식이 올바르지 않아요 — 상대 화면의 연결 코드를 그대로 입력하세요.", "That code isn't in the right format — copy the connection code from their screen exactly.", "コードの形式が正しくありません — 相手の画面の接続コードをそのまま入力してください。", "Ese código no tiene el formato correcto: copia exactamente el código de conexión de su pantalla.", "Ce code n'a pas le bon format — recopie exactement le code de connexion affiché sur son écran.", "Esse código não está no formato certo — copie exatamente o código de conexão mostrado na outra tela.", "Dieser Code hat nicht das richtige Format — übernimm den Verbindungscode von ihrem Bildschirm genau.") } + var tradeManualConnect: String { t("연결", "Connect", "接続", "Conectar", "Connecter", "Conectar", "Verbinden") } + var tradeManualConnecting: String { t("연결 중… 상대가 교환 탭을 열어둔 상태인지 확인하세요.", "Connecting… make sure they have the Trade tab open.", "接続中… 相手が交換タブを開いているか確認してください。", "Conectando… asegúrate de que tenga abierta la pestaña Intercambio.", "Connexion… vérifie que l'onglet Échange est bien ouvert en face.", "Conectando… confirme que a aba Troca está aberta do outro lado.", "Verbinde… stelle sicher, dass der Tausch-Tab dort geöffnet ist.") } + var tradeSelectOffer: String { t("교환할 대상 선택", "Choose what to trade", "交換する対象を選択", "Elige qué intercambiar", "Choisis quoi échanger", "Escolha o que trocar", "Wähle, was du tauschst") } + var tradeWaitingForPeerOffer: String { t("상대의 제안을 기다리는 중…", "Waiting for their offer…", "相手の提案を待っています…", "Esperando su oferta…", "En attente de leur offre…", "Aguardando a oferta…", "Warte auf ihr Angebot…") } + var tradeWaitingForPeerAccept: String { t("상대의 승인을 기다리는 중…", "Waiting for them to accept…", "相手の承認を待っています…", "Esperando que acepte…", "En attente de son acceptation…", "Aguardando a aprovação…", "Warte auf ihre Zustimmung…") } + var tradeWaitingForPeerConfirm: String { t("교환을 적용했어요 — 상대 확인을 기다리는 중…", "Trade applied — waiting for their confirmation…", "交換を適用しました — 相手の確認を待っています…", "Intercambio aplicado: esperando su confirmación…", "Échange appliqué — en attente de sa confirmation…", "Troca aplicada — aguardando a confirmação…", "Tausch angewendet — warte auf ihre Bestätigung…") } + var tradeCompleted: String { t("교환 완료", "Trade complete", "交換完了", "Intercambio completo", "Échange terminé", "Troca concluída", "Tausch abgeschlossen") } + var tradeRejectedByPeer: String { t("상대가 교환을 거절했어요", "They declined the trade", "相手が交換を拒否しました", "Rechazó el intercambio", "L'échange a été refusé", "A troca foi recusada", "Der Tausch wurde abgelehnt") } + var tradeUncertain: String { t("연결이 끊겨 상대방 적용 여부를 확인할 수 없어요", "Connection dropped — can't confirm whether they applied it", "接続が切れ、相手の適用状況を確認できません", "Se perdió la conexión — no se puede confirmar si se aplicó", "Connexion perdue — impossible de confirmer l'application", "Conexão perdida — não é possível confirmar a aplicação", "Verbindung getrennt — Anwendung nicht bestätigbar") } + var tradeCommitFailed: String { t("백업을 만들지 못해 교환을 적용하지 않았어요 — 내 포켓몬은 그대로입니다.", "Couldn't write a backup, so the trade wasn't applied — your Pokémon are unchanged.", "バックアップを作成できなかったため、交換は適用されませんでした — あなたのポケモンはそのままです。", "No se pudo crear la copia de seguridad, así que el intercambio no se aplicó: tus Pokémon no han cambiado.", "La sauvegarde n'a pas pu être créée, l'échange n'a donc pas été appliqué — tes Pokémon sont inchangés.", "Não foi possível criar o backup, então a troca não foi aplicada — seus Pokémon continuam iguais.", "Es konnte keine Sicherung erstellt werden, daher wurde der Tausch nicht angewendet — deine Pokémon bleiben unverändert.") } + func tradeBackupHint(fileName: String) -> String { + t("백업 파일: \(fileName). 되돌리려면 이 파일을 열어 진행 상황을 확인한 뒤, 필요하면 세이브 불러오기로 복원하세요.", + "Backup file: \(fileName). To undo, open this file to check your progress, then restore it via Save Import if needed.", + "バックアップファイル: \(fileName)。元に戻すにはこのファイルを開いて確認し、必要ならセーブの読み込みで復元してください。", + "Archivo de respaldo: \(fileName). Para deshacer, ábrelo para revisar tu progreso y restáuralo con Importar guardado si es necesario.", + "Fichier de sauvegarde : \(fileName). Pour annuler, ouvre-le pour vérifier ta progression, puis restaure-le via Importer une sauvegarde si besoin.", + "Arquivo de backup: \(fileName). Para desfazer, abra-o para conferir seu progresso e restaure via Importar salvamento se necessário.", + "Sicherungsdatei: \(fileName). Zum Rückgängigmachen diese Datei öffnen, Fortschritt prüfen und bei Bedarf über Speicherstand importieren wiederherstellen.") + } + var tradeOpenBackupFolder: String { t("Finder에서 열기", "Open in Finder", "Finderで開く", "Abrir en Finder", "Ouvrir dans le Finder", "Abrir no Finder", "Im Finder öffnen") } + // MARK: 도감 요약 헤더 var dexTitle: String { t("도감", "Pokédex", "図鑑", "Pokédex", "Pokédex", "Pokédex", "Pokédex") } func dexTotal(_ n: Int) -> String { t("총 \(n)마리", "\(n) total", "全\(n)匹", "\(n) en total", "\(n) au total", "\(n) no total", "\(n) insgesamt") } diff --git a/Sources/PokeTokenBar/Core/SaveTransfer.swift b/Sources/PokeTokenBar/Core/SaveTransfer.swift index df0bf394..a5970f9f 100644 --- a/Sources/PokeTokenBar/Core/SaveTransfer.swift +++ b/Sources/PokeTokenBar/Core/SaveTransfer.swift @@ -87,6 +87,14 @@ enum SaveTransfer { /// 유지할 백업 개수 — 오래된 것부터 지운다. static let backupsToKeep = 5 + /// Backup filename written just before a trade commit — it uses a different prefix from the + /// save-import backup so the two features don't get confused when we tell the user why the + /// file is there. + static func tradeBackupFileName(date: Date) -> String { + "companion-state.pre-trade-\(secondStamp(date)).json" + } + static let tradeBackupFilePrefix = "companion-state.pre-trade-" + private static func stamp(_ date: Date, _ format: String) -> String { let f = DateFormatter() f.locale = Locale(identifier: "en_US_POSIX") diff --git a/Sources/PokeTokenBar/Core/Trade/ManualTradeTransport.swift b/Sources/PokeTokenBar/Core/Trade/ManualTradeTransport.swift new file mode 100644 index 00000000..38b7fd1e --- /dev/null +++ b/Sources/PokeTokenBar/Core/Trade/ManualTradeTransport.swift @@ -0,0 +1,260 @@ +import Foundation +import Network + +/// A one-shot gate that prevents a path where completion gets called twice (e.g. canceling the +/// listener right after a successful connection reports a failed state again). Split into its +/// own class because Swift 6 forbids concurrent access to a captured local var from multiple +/// callback closures — state lives in a reference type instead, protected by NSLock. +private final class OneShotGate: @unchecked Sendable { + private let lock = NSLock() + private var fired = false + + func fireOnce(_ body: () -> Void) { + lock.lock() + let alreadyFired = fired + fired = true + lock.unlock() + guard !alreadyFired else { return } + body() + } +} + +/// The fallback for when auto-discovery (MultipeerTradeTransport) can't find the peer — a person +/// manually exchanges an IP:port code and connects over TCP, without Bonjour discovery. This +/// class does not discover on its own (startDiscovery/stopDiscovery are intentional no-ops, +/// connect(to:) throws). +/// `listener`/`connection`/`frameBuffer` are accessed concurrently from the Network framework's +/// callback queue (writes) and the caller's thread (reads, send/disconnect), so they're +/// protected with NSLock — following the same lock convention as NetworkReachabilityMonitor: the +/// lock is held only for the short span of copying/swapping a value, and callbacks like +/// onConnected/onDisconnected/onMessageReceived are invoked after releasing it (NSLock is not +/// reentrant, so a callback re-entering this transport while holding the lock would deadlock). +final class ManualTradeTransport: NSObject, TradeTransport, @unchecked Sendable { + var onPeerFound: (@Sendable (TradePeer) -> Void)? + var onPeerLost: (@Sendable (String) -> Void)? + var onConnected: (@Sendable () -> Void)? + var onDisconnected: (@Sendable () -> Void)? + var onMessageReceived: (@Sendable (TradeMessage) -> Void)? + + private let lock = NSLock() + private var listener: NWListener? + private var connection: NWConnection? + private var frameBuffer = TCPFrameBuffer() + /// Whether onDisconnected has already been reported for the current connection — termination + /// can be detected via either stateUpdateHandler(.failed/.cancelled) or receiveLoop's EOF + /// check, so the callback must react to only whichever arrives first. + /// Reset to false each time wire(_:) sets up a new connection. + private var disconnectNotified = false + private let queue = DispatchQueue(label: "com.poketokenbar.trade-manual") + + /// Delivers my connection code ("ip:port") to show the peer, via completion. The port is a + /// system-assigned ephemeral port (NWEndpoint.Port.any) — a hardcoded port would fail its + /// second bind in test/QA scenarios that run two instances on the same Mac at once. + /// NWListener reports failures like a port conflict asynchronously via + /// stateUpdateHandler(.failed) rather than from the initializer, so completion must be a + /// callback rather than a synchronous return value. + /// completion is called exactly once whether the outcome is failure or success, but the + /// timing differs per path — if the local IPv4 can't be found or listener creation itself + /// fails, it's called synchronously (on the caller's thread) before this function returns; + /// every other success/failure is called asynchronously on the Network framework's callback + /// queue. The caller must handle both timings. + func startListening(completion: @escaping @Sendable (String?) -> Void) { + guard let address = Self.currentIPv4Address(), + let newListener = try? NWListener(using: .tcp, on: .any) else { + completion(nil) + return + } + + let gate = OneShotGate() + let callCompletionOnce: @Sendable (String?) -> Void = { result in + gate.fireOnce { completion(result) } + } + + newListener.newConnectionHandler = { [weak self] connection in + guard let self else { return } + self.lock.lock() + // Trading is 1:1, so only the first connection is accepted. Calling cancel while + // holding the lock is an intentional exception to this file's "callbacks outside the + // lock" discipline — NWListener.cancel() dispatches to the queue rather than + // re-entering the callback. + self.listener?.cancel() + self.listener = nil + self.lock.unlock() + self.wire(connection) + connection.start(queue: self.queue) + } + newListener.stateUpdateHandler = { [weak self] state in + switch state { + case .ready: + guard let boundPort = newListener.port else { + callCompletionOnce(nil) + return + } + callCompletionOnce("\(address):\(boundPort.rawValue)") + case .failed, .cancelled: + callCompletionOnce(nil) + self?.lock.lock() + self?.listener = nil + self?.lock.unlock() + default: + break + } + } + + lock.lock() + listener = newListener + lock.unlock() + newListener.start(queue: queue) + } + + /// Connects directly using the code the peer showed. + func connectManually(code: String) throws { + let parts = code.split(separator: ":") + guard parts.count == 2, let port = UInt16(parts[1]), let nwPort = NWEndpoint.Port(rawValue: port) + else { throw TradeTransportError.notConnected } + let connection = NWConnection(host: NWEndpoint.Host(String(parts[0])), port: nwPort, using: .tcp) + wire(connection) + connection.start(queue: queue) + } + + /// Cancels a leftover previous connection (preventing its old stateUpdateHandler from + /// continuing to fire) and sets up the new one. Needed for the path where connectManually is + /// called twice in a row (e.g. the user mistypes a code and retries). + private func wire(_ connection: NWConnection) { + lock.lock() + let previousConnection = self.connection + self.connection = connection + frameBuffer = TCPFrameBuffer() + disconnectNotified = false + lock.unlock() + previousConnection?.cancel() + + connection.stateUpdateHandler = { [weak self] state in + switch state { + case .ready: + self?.onConnected?() + self?.receiveLoop(on: connection) + case .failed, .cancelled: + self?.handleConnectionTerminated(connection) + default: + break + } + } + } + + /// Reports connection termination exactly once — stateUpdateHandler(.failed/.cancelled) and + /// receiveLoop's EOF check can both fire, so the signal that arrives later is ignored (an + /// R20/R21-class issue — prevents send from appearing to succeed on a dead connection). If + /// this connection has already been replaced by another (wire was called again), do nothing + /// — the new connection's state must not be overwritten by the old connection's termination. + private func handleConnectionTerminated(_ terminatedConnection: NWConnection) { + lock.lock() + guard connection === terminatedConnection else { + lock.unlock() + return + } + connection = nil + let alreadyNotified = disconnectNotified + disconnectNotified = true + lock.unlock() + guard !alreadyNotified else { return } + onDisconnected?() + } + + private func receiveLoop(on connection: NWConnection) { + connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { [weak self] data, _, isComplete, error in + guard let self else { return } + var decodedMessages: [TradeMessage] = [] + var frameTooLarge = false + if let data, !data.isEmpty { + self.lock.lock() + let frames: [Data] + do { + frames = try self.frameBuffer.append(data) + } catch { + frames = [] + frameTooLarge = true + } + self.lock.unlock() + for frame in frames { + if let message = try? JSONDecoder().decode(TradeMessage.self, from: frame) { + decodedMessages.append(message) + } else { + // Distinguishes, in the logs, a frame we couldn't decode due to a version + // mismatch from "the peer simply never made an offer." + AppLog.write("trade: undecodable manual frame (\(frame.count) bytes)") + } + } + } + for message in decodedMessages { + self.onMessageReceived?(message) + } + if frameTooLarge { + connection.cancel() // A manipulated length prefix — close the connection instead of stalling silently (R23). + self.handleConnectionTerminated(connection) + } else if isComplete || error != nil { + self.handleConnectionTerminated(connection) + } else { + self.receiveLoop(on: connection) + } + } + } + + // Auto-discovery is not supported (manual connection only). + func startDiscovery() {} + func stopDiscovery() {} + func connect(to peer: TradePeer) throws { throw TradeTransportError.notConnected } + + func send(_ message: TradeMessage) throws { + lock.lock() + let currentConnection = connection + lock.unlock() + guard let currentConnection else { throw TradeTransportError.sendFailed } + guard let data = try? JSONEncoder().encode(message) else { throw TradeTransportError.sendFailed } + currentConnection.send(content: TCPFrameBuffer.frame(data), completion: .contentProcessed { _ in }) + } + + func disconnect() { + lock.lock() + let currentConnection = connection + let currentListener = listener + connection = nil + listener = nil + frameBuffer = TCPFrameBuffer() // Don't let the next connection's framing get corrupted by leftover bytes from this one. + let alreadyNotified = disconnectNotified + disconnectNotified = true + lock.unlock() + currentConnection?.cancel() + currentListener?.cancel() + // Only report a disconnect if there was a connection — before connecting (when only the + // listener is being canceled), nothing was actually torn down. + // NWConnection.cancel()'s later stateUpdateHandler(.cancelled) callback finds connection + // already nil and is silently ignored in handleConnectionTerminated (avoids a duplicate + // notification). + if currentConnection != nil, !alreadyNotified { + onDisconnected?() + } + } + + /// en0/en1 IPv4 — only looks at the Mac's typical Wi-Fi/Ethernet interfaces (virtual/loopback + /// interfaces excluded). + static func currentIPv4Address() -> String? { + var address: String? + var ifaddrPointer: UnsafeMutablePointer? + guard getifaddrs(&ifaddrPointer) == 0, let firstAddr = ifaddrPointer else { return nil } + defer { freeifaddrs(ifaddrPointer) } + for pointer in sequence(first: firstAddr, next: { $0.pointee.ifa_next }) { + let interface = pointer.pointee + guard interface.ifa_addr.pointee.sa_family == UInt8(AF_INET) else { continue } + let name = String(cString: interface.ifa_name) + guard name == "en0" || name == "en1" else { continue } + var hostBuffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + getnameinfo(interface.ifa_addr, socklen_t(interface.ifa_addr.pointee.sa_len), + &hostBuffer, socklen_t(hostBuffer.count), nil, 0, NI_NUMERICHOST) + // getnameinfo NUL-terminates its output — stop there so trailing buffer padding doesn't end up in the string. + address = String(decoding: hostBuffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }, as: UTF8.self) + break + } + return address + } +} diff --git a/Sources/PokeTokenBar/Core/Trade/MultipeerTradeTransport.swift b/Sources/PokeTokenBar/Core/Trade/MultipeerTradeTransport.swift new file mode 100644 index 00000000..e23f2c14 --- /dev/null +++ b/Sources/PokeTokenBar/Core/Trade/MultipeerTradeTransport.swift @@ -0,0 +1,296 @@ +import Foundation +import MultipeerConnectivity + +/// The default transport — automatically finds and connects to a peer on the same Wi-Fi/LAN. +/// **Support is limited to Wi-Fi/LAN — Bluetooth is on hold (unsupported, unverified).** +/// MultipeerConnectivity lets the framework choose the transport medium and gives no public API +/// to turn that off, so a Bluetooth PAN can opportunistically get used. That path is not +/// verified, though, and its behavior isn't guaranteed — defects in a Bluetooth-only environment +/// are out of scope, and the UI copy doesn't promise Bluetooth either. +/// The MCSession delegate is invoked on the framework's own queue, so this class is not +/// @MainActor — the consumer (TradeSession) hops itself with Task { @MainActor in … } (same +/// convention as NetworkReachabilityMonitor). +/// `discoveredPeers`/`advertiser`/`browser` are accessed concurrently from the delegate queue +/// (writes) and the caller's thread (reads), so they're protected with NSLock (Ruling R19) — +/// following the same lock convention as NetworkReachabilityMonitor: the lock is held only for +/// the short span of copying/swapping a value, and callbacks like onPeerFound/onPeerLost are +/// invoked after releasing it. +final class MultipeerTradeTransport: NSObject, TradeTransport, @unchecked Sendable { + /// Bonjour service type — 1 to 15 chars, lowercase letters/digits/hyphens only (Apple spec). + static let serviceType = "ptb-trade" + /// The code key in the discoveryInfo dictionary — shared between startDiscovery and + /// makeTradePeer so the value can't drift between them. + static let discoveryInfoCodeKey = "code" + + var onPeerFound: (@Sendable (TradePeer) -> Void)? + var onPeerLost: (@Sendable (String) -> Void)? + var onConnected: (@Sendable () -> Void)? + var onDisconnected: (@Sendable () -> Void)? + var onMessageReceived: (@Sendable (TradeMessage) -> Void)? + + private let myPeerID: MCPeerID + private let code: String + /// When this was `lazy`, the first access raced between the caller's thread + /// (connect/send/disconnect) and the framework queue (advertiser delegate) — unlike this + /// class's other shared fields, it sat outside the lock. Creating it in init removes the + /// race entirely. + private let session: MCSession + private let lock = NSLock() + private var advertiser: MCNearbyServiceAdvertiser? + private var browser: MCNearbyServiceBrowser? + private var discoveredPeers: [String: MCPeerID] = [:] + /// The single peer we're currently trading with — MCSession supports multi-party + /// connections, so this layer enforces the 1:1 constraint. + private var partnerPeerID: MCPeerID? + /// The peer I just invited — used only to detect a crossed invitation. + private var invitedPeerID: MCPeerID? + /// Increments per invitation — lets the expiry cleanup timer only clear the invitation it + /// itself scheduled. + private var inviteGeneration = 0 + /// Shares the same timeout with `invitePeer(timeout:)` and expiry cleanup — if the two drift + /// apart, either `invitedPeerID` lingers after the invitation ended, or a still-live + /// invitation gets cleared prematurely. + private static let invitationTimeoutSeconds: TimeInterval = 15 + private let invitationQueue = DispatchQueue(label: "com.poketokenbar.trade-multipeer") + + /// The nickname is free text entered in Settings, so it can be empty or exceed 63 bytes — + /// MCPeerID(displayName:) traps on such values, so it must be clamped before construction + /// (Ruling R5). + init(nickname: String, code: String) { + let peerID = MCPeerID(displayName: Self.clampNickname(nickname, fallback: Host.current().localizedName ?? "PokeTokenBar")) + myPeerID = peerID + self.code = code + session = MCSession(peer: peerID, securityIdentity: nil, encryptionPreference: .required) + super.init() + session.delegate = self + } + + /// A total ordering that decides who accepts when invitations cross. The default nickname is + /// the computer name, so two devices can share the same name — the nickname alone gives no + /// ordering, so the per-device trade code is mixed in too. + static func tiebreakKey(displayName: String, code: String?) -> String { + "\(displayName)\u{0}\(code ?? "")" + } + + /// Whether to accept an invitation — split out as a pure decision so it can be verified + /// without framework callbacks. + /// If a partner is already set, only accept from that partner (letting a third party in + /// would make send() broadcast to two peers). + /// If the invitation comes back from a peer I already invited, it's a crossed invitation — + /// only the side with the larger key accepts, so the two connection attempts collapse to one. + /// Codes differ per device, so the keys can't actually tie, but if they did, `>=` falls back + /// to current behavior instead of a deadlock. + static func shouldAccept(invitationFrom peer: String, partner: String?, invited: String?, + myKey: String, theirKey: String) -> Bool { + if let partner { return partner == peer } + guard invited == peer else { return true } + return myKey >= theirKey + } + + /// Whether an expired invitation is safe to clear. If an invitation that got no response + /// lingers in `invitedPeerID`, tiebreak keeps rejecting that peer's later legitimate + /// invitations, and no signal reaches the peer at all (today there's no recovery besides + /// canceling and creating a fresh transport). + /// If a newer invitation has since gone out, the generations don't match, so leave it to that + /// invitation's own timer instead of touching it. + /// If a partner is already set, `invitedPeerID` was already cleared in `.connected`. + static func shouldClearExpiredInvitation(currentGeneration: Int, expiringGeneration: Int, + hasPartner: Bool) -> Bool { + currentGeneration == expiringGeneration && !hasPartner + } + + /// discoveryInfo parsing is split out as a pure function — makes it unit-testable without a + /// network stack. + static func makeTradePeer(displayName: String, discoveryInfo: [String: String]?) -> TradePeer { + TradePeer(id: displayName, nickname: displayName, code: discoveryInfo?[discoveryInfoCodeKey] ?? "????") + } + + /// MCPeerID(displayName:) requires a non-empty name that's 63 bytes or fewer in UTF-8, and + /// traps if that's violated. An empty value is replaced with the fallback, and a long value + /// is truncated by byte budget (Korean/Japanese use multiple bytes per character, so + /// truncating by character count could still exceed the budget). + static func clampNickname(_ nickname: String, fallback: String) -> String { + let trimmed = nickname.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return fallback } + guard trimmed.utf8.count > 63 else { return trimmed } + var truncated = trimmed + while truncated.utf8.count > 63 { + truncated.removeLast() + } + return truncated.isEmpty ? fallback : truncated + } + + func startDiscovery() { + let newAdvertiser = MCNearbyServiceAdvertiser(peer: myPeerID, discoveryInfo: [Self.discoveryInfoCodeKey: code], + serviceType: Self.serviceType) + newAdvertiser.delegate = self + + let newBrowser = MCNearbyServiceBrowser(peer: myPeerID, serviceType: Self.serviceType) + newBrowser.delegate = self + + lock.lock() + advertiser = newAdvertiser + browser = newBrowser + lock.unlock() + + newAdvertiser.startAdvertisingPeer() + newBrowser.startBrowsingForPeers() + } + + func stopDiscovery() { + lock.lock() + let currentAdvertiser = advertiser + let currentBrowser = browser + advertiser = nil + browser = nil + lock.unlock() + + currentAdvertiser?.stopAdvertisingPeer() + currentBrowser?.stopBrowsingForPeers() + } + + func connect(to peer: TradePeer) throws { + lock.lock() + let mcPeer = discoveredPeers[peer.id] + let currentBrowser = browser + let alreadyPaired = partnerPeerID != nil + // The condition for actually sending the invitation must match the condition for setting + // `invitedPeerID` — setting it on a path that throws would let an invitation that was + // never sent block tiebreak. + var pendingGeneration: Int? + if let mcPeer, currentBrowser != nil, !alreadyPaired { + invitedPeerID = mcPeer + inviteGeneration += 1 + pendingGeneration = inviteGeneration + } + lock.unlock() + + // There's no dedicated case for "the peer is no longer in discoveredPeers", so notConnected is reused. + guard let mcPeer, let currentBrowser, let pendingGeneration else { throw TradeTransportError.notConnected } + // An invitation can expire with no signal at all (the peer never responds), so clean it + // up ourselves on the same timeout. + invitationQueue.asyncAfter(deadline: .now() + Self.invitationTimeoutSeconds) { [weak self] in + self?.clearInvitationIfExpired(generation: pendingGeneration) + } + // Send my code along for crossed-invitation tiebreaking — the receiving side determines + // ordering from this value alone, without discoveryInfo. + currentBrowser.invitePeer(mcPeer, to: session, withContext: Data(code.utf8), + timeout: Self.invitationTimeoutSeconds) + } + + private func clearInvitationIfExpired(generation: Int) { + lock.lock() + let expired = Self.shouldClearExpiredInvitation(currentGeneration: inviteGeneration, + expiringGeneration: generation, + hasPartner: partnerPeerID != nil) + if expired { invitedPeerID = nil } + lock.unlock() + } + + func send(_ message: TradeMessage) throws { + lock.lock() + let partner = partnerPeerID + lock.unlock() + // Restrict the destination to the confirmed single partner rather than connectedPeers — + // even if a third party lingers in the session, my offer/accept doesn't also go to them. + guard let partner, session.connectedPeers.contains(partner) else { throw TradeTransportError.sendFailed } + guard let data = try? JSONEncoder().encode(message) else { throw TradeTransportError.sendFailed } + do { + try session.send(data, toPeers: [partner], with: .reliable) + } catch { + throw TradeTransportError.sendFailed + } + } + + func disconnect() { + lock.lock() + partnerPeerID = nil + invitedPeerID = nil + lock.unlock() + session.disconnect() + } +} + +extension MultipeerTradeTransport: MCSessionDelegate { + /// Ignore state changes for a peer that isn't the trade partner — a third party leaving must + /// not break an in-progress trade or reset the screen to the start. + func session(_ session: MCSession, peer peerID: MCPeerID, didChange state: MCSessionState) { + switch state { + case .connected: + lock.lock() + if partnerPeerID == nil { partnerPeerID = peerID } + if invitedPeerID == peerID { invitedPeerID = nil } + let isPartner = partnerPeerID == peerID + lock.unlock() + guard isPartner else { return } + onConnected?() + case .notConnected: + // The rejected side of a crossed invitation can drive .notConnected for a peerID that + // is **already connected** — only treat it as a real disconnect when the framework + // has actually dropped that peer (otherwise a live trade would get reset). + let stillConnected = session.connectedPeers.contains(peerID) + lock.lock() + let isPartner = partnerPeerID == peerID + if isPartner, !stillConnected { partnerPeerID = nil } + if invitedPeerID == peerID, !stillConnected { invitedPeerID = nil } + lock.unlock() + guard isPartner, !stillConnected else { return } + onDisconnected?() + case .connecting: break + @unknown default: break + } + } + + func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) { + lock.lock() + let isPartner = partnerPeerID == peerID + lock.unlock() + guard isPartner else { return } + guard let message = try? JSONDecoder().decode(TradeMessage.self, from: data) else { + // One line to distinguish, in the logs, a message we couldn't decode due to a + // version mismatch from "the peer simply never made an offer." + AppLog.write("trade: undecodable multipeer message (\(data.count) bytes) from \(peerID.displayName)") + return + } + onMessageReceived?(message) + } + + func session(_ session: MCSession, didReceive stream: InputStream, withName streamName: String, fromPeer peerID: MCPeerID) {} + func session(_ session: MCSession, didStartReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, with progress: Progress) {} + func session(_ session: MCSession, didFinishReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, at localURL: URL?, withError error: Error?) {} +} + +extension MultipeerTradeTransport: MCNearbyServiceBrowserDelegate { + func browser(_ browser: MCNearbyServiceBrowser, foundPeer peerID: MCPeerID, withDiscoveryInfo info: [String: String]?) { + lock.lock() + discoveredPeers[peerID.displayName] = peerID + lock.unlock() + onPeerFound?(Self.makeTradePeer(displayName: peerID.displayName, discoveryInfo: info)) + } + + func browser(_ browser: MCNearbyServiceBrowser, lostPeer peerID: MCPeerID) { + lock.lock() + discoveredPeers.removeValue(forKey: peerID.displayName) + lock.unlock() + onPeerLost?(peerID.displayName) + } +} + +extension MultipeerTradeTransport: MCNearbyServiceAdvertiserDelegate { + /// Only accept invitations within the bounds that keep the connection 1:1 (see + /// `shouldAccept`) — identity verification itself is left to the human, via the + /// nickname/code shown in the subsequent Hello message. + func advertiser(_ advertiser: MCNearbyServiceAdvertiser, didReceiveInvitationFromPeer peerID: MCPeerID, + withContext context: Data?, invitationHandler: @escaping (Bool, MCSession?) -> Void) { + lock.lock() + let partner = partnerPeerID?.displayName + let invited = invitedPeerID?.displayName + lock.unlock() + + let theirCode = context.flatMap { String(data: $0, encoding: .utf8) } + let accepted = Self.shouldAccept(invitationFrom: peerID.displayName, partner: partner, invited: invited, + myKey: Self.tiebreakKey(displayName: myPeerID.displayName, code: code), + theirKey: Self.tiebreakKey(displayName: peerID.displayName, code: theirCode)) + invitationHandler(accepted, accepted ? session : nil) + } +} diff --git a/Sources/PokeTokenBar/Core/Trade/TCPFrameBuffer.swift b/Sources/PokeTokenBar/Core/Trade/TCPFrameBuffer.swift new file mode 100644 index 00000000..c2ec3261 --- /dev/null +++ b/Sources/PokeTokenBar/Core/Trade/TCPFrameBuffer.swift @@ -0,0 +1,47 @@ +import Foundation + +/// TCP is a stream, so it has no message boundaries — frame with a 4-byte big-endian length +/// prefix. MultipeerConnectivity guarantees message-level delivery so it doesn't need this +/// framing — this is ManualTradeTransport(TCP)-only. +struct TCPFrameBuffer { + /// Upper bound on a frame's payload length — the peer is an arbitrary IP:port the user typed + /// in by hand, so it can't be trusted, and a manipulated length prefix (e.g. 0xFFFFFFFF) + /// would grow the buffer without bound if there were no cap. The real TradeMessage (JSON) + /// stays within a few hundred bytes, so this is set generously, the same way as + /// SaveTransfer.maxFileBytes. + static let maxFrameLength = 1 * 1024 * 1024 + + enum FrameError: Error, Equatable { + case frameTooLarge(length: Int, limit: Int) + } + + private var buffer = Data() + + static func frame(_ payload: Data) -> Data { + var length = UInt32(payload.count).bigEndian + var framed = Data(bytes: &length, count: 4) + framed.append(payload) + return framed + } + + /// Accumulates newly arrived bytes and returns every completed frame extracted so far + /// (a partial frame stays in the buffer). Throws if the length prefix exceeds the cap — the + /// caller must treat this as a connection close, not a silent stall. + mutating func append(_ data: Data) throws -> [Data] { + buffer.append(data) + var frames: [Data] = [] + while buffer.count >= 4 { + let lengthPrefix = buffer.prefix(4) + let length = Int(UInt32(bigEndian: lengthPrefix.withUnsafeBytes { $0.loadUnaligned(as: UInt32.self) })) + guard length <= Self.maxFrameLength else { + throw FrameError.frameTooLarge(length: length, limit: Self.maxFrameLength) + } + guard buffer.count >= 4 + length else { break } + let frameStart = buffer.startIndex + 4 + let frameEnd = frameStart + length + frames.append(buffer.subdata(in: frameStart.. String { + defaults.string(forKey: nicknameKey) ?? (Host.current().localizedName ?? ProcessInfo.processInfo.hostName) + } + + static func setNickname(_ nickname: String, defaults: UserDefaults = .standard) { + let trimmed = nickname.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + defaults.removeObject(forKey: nicknameKey) + } else { + defaults.set(trimmed, forKey: nicknameKey) + } + } + + /// 8 uppercase letters/digits — short enough for a person to copy by hand. Easily confused + /// characters (0/O, 1/I) are excluded from the alphabet. + static func code(defaults: UserDefaults = .standard) -> String { + if let existing = defaults.string(forKey: codeKey) { return existing } + let alphabet = Array("ABCDEFGHJKLMNPQRSTUVWXYZ23456789") + let generated = String((0..<8).map { _ in alphabet.randomElement()! }) + defaults.set(generated, forKey: codeKey) + return generated + } +} diff --git a/Sources/PokeTokenBar/Core/Trade/TradeItem.swift b/Sources/PokeTokenBar/Core/Trade/TradeItem.swift new file mode 100644 index 00000000..d4235fb0 --- /dev/null +++ b/Sources/PokeTokenBar/Core/Trade/TradeItem.swift @@ -0,0 +1,83 @@ +import Foundation + +/// What changes hands in a trade — either a graduated dex entry or the mon currently being raised. +enum TradeItem: Codable, Sendable { + case dexEntry(DexEntry) + case activeMon(MonState) + + /// Values coming from the peer are trust-boundary data. Following the same principle as + /// `SaveTransfer.sanitized`, normalize once right before commit — guarding every downstream + /// arithmetic site instead would recur each time a new site is added. + func sanitized() -> TradeItem { + switch self { + case .dexEntry(var entry): + entry.profile?.sanitize() + return .dexEntry(entry) + case .activeMon(var mon): + mon.usedAtStage = min(max(0, mon.usedAtStage), SaveTransfer.maxTokenValue) + mon.totalForms = min(max(1, mon.totalForms), 12) + mon.stageIndex = min(max(0, mon.stageIndex), max(0, mon.pathIDs.count - 1)) + mon.profile?.sanitize() + return .activeMon(mon) + } + } + + var rarity: Rarity { + switch self { + case .dexEntry(let entry): return entry.rarity + case .activeMon(let mon): return mon.rarity + } + } + + /// Display name for the approval screen to show "what" is being exchanged — rarity alone + /// doesn't tell you what you're giving up in a trade that can't be undone. `DexEntry.names` + /// travels with the payload, so the peer's item's species name resolves without a network + /// call too. An active mon doesn't carry a name map, so it falls back to `#id`. + func displayName(language: AppLanguage) -> String { + switch self { + case .dexEntry(let entry): + return entry.names?[entry.finalID].flatMap { language.resolveName($0) } ?? "#\(entry.finalID)" + case .activeMon(let mon): + return "#\(mon.currentID)" + } + } + + /// The pre-approval warning text is only needed when receiving an active (in-progress) mon. + var isActiveMon: Bool { + if case .activeMon = self { return true } + return false + } + + /// The species whose sprite the approval screen / offer picker should draw — for a dex entry + /// that's the `finalID` the dex itself shows, for an active mon it's the `currentID` at its + /// current stage. The screen passes this value straight to `SpriteView` alongside the name. + var displaySpeciesID: Int { + switch self { + case .dexEntry(let entry): return entry.finalID + case .activeMon(let mon): return mon.currentID + } + } + + var displayIsShiny: Bool { + switch self { + case .dexEntry(let entry): return entry.isShiny + case .activeMon(let mon): return mon.isShiny + } + } + + var displayUnownForm: UnownForm? { + switch self { + case .dexEntry(let entry): return entry.unownForm + case .activeMon(let mon): return mon.unownForm + } + } + + /// The baseline line for name lookup — `PokeProviding.line(baseSpeciesID:)` returns the whole + /// evolution chain's names keyed off the pre-evolution stage. + var displayBaseID: Int { + switch self { + case .dexEntry(let entry): return entry.baseID + case .activeMon(let mon): return mon.baseID + } + } +} diff --git a/Sources/PokeTokenBar/Core/Trade/TradeMessage.swift b/Sources/PokeTokenBar/Core/Trade/TradeMessage.swift new file mode 100644 index 00000000..5c6ffff4 --- /dev/null +++ b/Sources/PokeTokenBar/Core/Trade/TradeMessage.swift @@ -0,0 +1,17 @@ +import Foundation + +/// Messages exchanged during a trade session. Sent as JSON regardless of transport +/// (Multipeer/manual TCP). All associated values are Codable, so the compiler synthesizes +/// per-case encoding (SE-0295). +enum TradeMessage: Codable, Sendable { + case hello(nickname: String, code: String) + case offer(TradeItem) + case offerWithdrawn + case accept + case reject(reason: String) + /// Informational only — by the time Accepts are exchanged, both sides have already + /// independently started committing, so receiving this doesn't trigger any action + /// (see TradeSession.handle). The nonce is for log correlation. + case commit(nonce: String) + case commitAck(nonce: String) +} diff --git a/Sources/PokeTokenBar/Core/Trade/TradeSession.swift b/Sources/PokeTokenBar/Core/Trade/TradeSession.swift new file mode 100644 index 00000000..3dd2211c --- /dev/null +++ b/Sources/PokeTokenBar/Core/Trade/TradeSession.swift @@ -0,0 +1,159 @@ +import Foundation + +/// The trade protocol state machine — manages the Offer→Accept→Commit sequence independent of +/// the transport layer. This class does not perform the actual state application (backup+apply) +/// itself — whoever receives the `onReadyToCommit` callback (the CompanionStore consumer) must +/// call `confirmLocalCommit()` after finishing the apply for the session to complete. +@MainActor +final class TradeSession { + private let transport: any TradeTransport + /// The store that reads trade identity (nickname/code) — same injection convention as + /// UsageStore/CompanionStore. If a test doesn't pass an isolated suite, simply sending hello + /// will generate a tradeCode in the real user domain. + private let defaults: UserDefaults + private let sessionID = UUID().uuidString + + private(set) var peerIdentity: (nickname: String, code: String)? + private(set) var myOffer: TradeItem? + private(set) var theirOffer: TradeItem? + private var myAcceptSent = false + /// Observable session state at the same level as `theirOffer` — "has the peer's accept + /// arrived" is a condition that gates whether we can commit, so it must be readable from + /// outside. + private(set) var theirAcceptReceived = false + private var committed = false + private var localCommitAckSent = false + private var remoteCommitAckReceived = false + private var completed = false + + var onPeerIdentified: (((nickname: String, code: String)) -> Void)? + var onOffersReady: ((_ mine: TradeItem, _ theirs: TradeItem) -> Void)? + /// The peer withdrew their offer — notify so the review screen stops showing an item that's + /// no longer there. + var onOfferWithdrawn: (() -> Void)? + var onReadyToCommit: ((_ received: TradeItem) -> Void)? + var onRejected: ((String) -> Void)? + var onDisconnected: (() -> Void)? + var onCompleted: (() -> Void)? + + init(transport: any TradeTransport, defaults: UserDefaults = .standard) { + self.transport = transport + self.defaults = defaults + transport.onConnected = { [weak self] in + Task { @MainActor [weak self] in self?.sendHello() } + } + transport.onMessageReceived = { [weak self] message in + Task { @MainActor [weak self] in self?.handle(message) } + } + transport.onDisconnected = { [weak self] in + Task { @MainActor [weak self] in self?.onDisconnected?() } + } + } + + private func sendHello() { + try? transport.send(.hello(nickname: TradeIdentity.nickname(defaults: defaults), + code: TradeIdentity.code(defaults: defaults))) + } + + func proposeOffer(_ item: TradeItem) { + myOffer = item + // If the offer changes, any Accept for the previous review pair is void on both sides — + // neither the Accept I sent nor the one the peer sent was consent for this new pair, so + // clear both. + invalidateAcceptsBeforeCommit() + try? transport.send(.offer(item)) + notifyIfBothOffersReady() + } + + func withdrawOffer() { + myOffer = nil + invalidateAcceptsBeforeCommit() + try? transport.send(.offerWithdrawn) + } + + func accept() { + guard !myAcceptSent else { return } + do { + try transport.send(.accept) + myAcceptSent = true + commitIfBothAccepted() + } catch { + // Don't set the flag if the send fails — leaves accept() retryable. + } + } + + func reject(reason: String) { + try? transport.send(.reject(reason: reason)) + transport.disconnect() + } + + /// Call this after finishing the actual state application (CompanionStore.applyTradeCommit) + /// triggered by `onReadyToCommit`. + func confirmLocalCommit() { + do { + try transport.send(.commitAck(nonce: sessionID)) + localCommitAckSent = true + checkCompleted() + } catch { + // Don't advance to completed if the send fails — the UI stays in the "committing" + // state, leaving room for reconnect/retry. + } + } + + func disconnect() { + transport.disconnect() + } + + private func handle(_ message: TradeMessage) { + switch message { + case .hello(let nickname, let code): + peerIdentity = (nickname, code) + onPeerIdentified?((nickname, code)) + case .offer(let item): + theirOffer = item.sanitized() + invalidateAcceptsBeforeCommit() + notifyIfBothOffersReady() + case .offerWithdrawn: + theirOffer = nil + invalidateAcceptsBeforeCommit() + onOfferWithdrawn?() + case .accept: + theirAcceptReceived = true + commitIfBothAccepted() + case .reject(let reason): + onRejected?(reason) + case .commit: + break // Informational only — both sides already reach commitIfBothAccepted independently once Accepts are exchanged. + case .commitAck: + remoteCommitAckReceived = true + checkCompleted() + } + } + + /// An Accept is consent for "this specific offer pair, right now" — before commit, if either + /// side's offer changes, both Accept flags (the one I sent, the one I received from the + /// peer) lose their basis for consent, so clear them together. + private func invalidateAcceptsBeforeCommit() { + guard !committed else { return } + myAcceptSent = false + theirAcceptReceived = false + } + + private func notifyIfBothOffersReady() { + guard let myOffer, let theirOffer else { return } + onOffersReady?(myOffer, theirOffer) + } + + private func commitIfBothAccepted() { + guard myOffer != nil, myAcceptSent, theirAcceptReceived, !committed, let theirOffer else { return } + committed = true + try? transport.send(.commit(nonce: sessionID)) + onReadyToCommit?(theirOffer) + } + + private func checkCompleted() { + guard localCommitAckSent, remoteCommitAckReceived, !completed else { return } + completed = true + onCompleted?() + } +} diff --git a/Sources/PokeTokenBar/Core/Trade/TradeTransport.swift b/Sources/PokeTokenBar/Core/Trade/TradeTransport.swift new file mode 100644 index 00000000..81c916ad --- /dev/null +++ b/Sources/PokeTokenBar/Core/Trade/TradeTransport.swift @@ -0,0 +1,32 @@ +import Foundation + +/// A discovered candidate peer — the minimal info needed to show it in the discovery list. +struct TradePeer: Identifiable, Equatable, Sendable { + let id: String + let nickname: String + let code: String +} + +enum TradeTransportError: Error, Equatable { + case notConnected + case sendFailed + case discoveryTimedOut +} + +/// The transport layer for a trade session — `TradeSession` doesn't need to know which mechanism +/// (Multipeer auto-discovery / manual TCP) made the connection. Callbacks may be invoked on any +/// thread — the MainActor consumer (TradeSession) hops itself +/// (same convention as NetworkReachabilityMonitor.onReconnected). +protocol TradeTransport: AnyObject { + var onPeerFound: (@Sendable (TradePeer) -> Void)? { get set } + var onPeerLost: (@Sendable (String) -> Void)? { get set } + var onConnected: (@Sendable () -> Void)? { get set } + var onDisconnected: (@Sendable () -> Void)? { get set } + var onMessageReceived: (@Sendable (TradeMessage) -> Void)? { get set } + + func startDiscovery() + func stopDiscovery() + func connect(to peer: TradePeer) throws + func send(_ message: TradeMessage) throws + func disconnect() +} diff --git a/Sources/PokeTokenBar/PokeTokenBarApp.swift b/Sources/PokeTokenBar/PokeTokenBarApp.swift index 1bff0bbe..108d8093 100644 --- a/Sources/PokeTokenBar/PokeTokenBarApp.swift +++ b/Sources/PokeTokenBar/PokeTokenBarApp.swift @@ -105,6 +105,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { popover.behavior = .transient popover.delegate = self // didShow: outside-click monitor; didClose: 호스팅 해제 + 모니터 제거 + // Keep the popover from closing on an outside click, but only while a trade holds a + // connection — closing tears the session down and loses the trade in progress + // (see the PopoverNavigation.tradeSessionActive comment). + navigation.onTradeSessionActiveChanged = { [weak self] active in + self?.popover.behavior = active ? .applicationDefined : .transient + } observeStore() observeCompanionSprite() @@ -572,6 +578,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] _ in Task { @MainActor in guard let self, self.popover.isShown else { return } + // This monitor closes the popover on outside clicks that .transient misses, + // which would defeat the .applicationDefined behavior holding it open + // during a trade. + guard !self.navigation.tradeSessionActive else { return } self.popover.performClose(nil) } } diff --git a/Sources/PokeTokenBar/UI/PopoverView.swift b/Sources/PokeTokenBar/UI/PopoverView.swift index f1d43ce5..152a90ed 100644 --- a/Sources/PokeTokenBar/UI/PopoverView.swift +++ b/Sources/PokeTokenBar/UI/PopoverView.swift @@ -1,7 +1,7 @@ import AppKit import SwiftUI -enum PopoverTab { case home, shop, bag, collection } +enum PopoverTab { case home, shop, bag, collection, trade } /// 팝오버 치수의 단일 소스. 자식이 쓸 수 있는 폭을 알아야 할 때 이 값을 쓴다 — 넘치는 자식이 /// 부모 폭을 부풀리므로 GeometryReader 로 재면 순환한다. @@ -27,6 +27,22 @@ final class PopoverNavigation { /// 설정을 열 때 고급 섹션을 펼친 채로 시작할지. 세션 키 행이 접힌 disclosure 안에 살아서, /// 그냥 설정만 열면 "만료됐다"를 보고 들어온 사용자가 고칠 입력란을 못 찾는다. var expandAdvancedOnOpen = false + /// True while a trade holds a connection. The popover is `.transient`, so a single click + /// outside closes it, and closing runs `TradeView.onDisappear`, which tears the session down + /// and loses the trade in progress (observed: click another window while browsing for a + /// partner, and reopening lands on Home in the initial state). That teardown is what keeps a + /// commit from happening with no screen attached, so it can't be removed — instead + /// `AppDelegate` reads this value and keeps the popover open to outside clicks **only while + /// trading**. + var tradeSessionActive = false { + didSet { + guard oldValue != tradeSessionActive else { return } + onTradeSessionActiveChanged?(tradeSessionActive) + } + } + /// Hook for `AppDelegate` to change the popover's behavior — the seam that keeps the view + /// from knowing about AppKit directly. + var onTradeSessionActiveChanged: ((Bool) -> Void)? func reset() { showSettings = false @@ -119,6 +135,7 @@ struct PopoverView: View { Text(l.shop).tag(PopoverTab.shop) Text(l.bag).tag(PopoverTab.bag) Text(l.collection).tag(PopoverTab.collection) + Text(l.trade).tag(PopoverTab.trade) } .pickerStyle(.segmented) .labelsHidden() @@ -129,6 +146,8 @@ struct PopoverView: View { BagView(store: companion, nav: nav) } else if nav.tab == .shop { ShopView(store: companion, nav: nav) + } else if nav.tab == .trade { + TradeView(store: companion) } else { CompanionHeader(store: companion) Divider() diff --git a/Sources/PokeTokenBar/UI/SettingsView.swift b/Sources/PokeTokenBar/UI/SettingsView.swift index ddd0a6d3..f1ee287c 100644 --- a/Sources/PokeTokenBar/UI/SettingsView.swift +++ b/Sources/PokeTokenBar/UI/SettingsView.swift @@ -23,6 +23,7 @@ struct SettingsView: View { @State private var sessionKeyInput = "" @State private var isCheckingUpdate = false @State private var didCheckUpdate = false + @State private var tradeNicknameDraft = TradeIdentity.nickname() @State private var selectedScanProviderID = "claude_code" /// Provider the draft currently describes. Picker change updates `selectedScanProviderID` /// before the TextField blurs; committing against the selection would write Claude paths @@ -67,6 +68,7 @@ struct SettingsView: View { VStack(alignment: .leading, spacing: 18) { generalGroup(store) difficultyGroup + tradeIdentityGroup menuBarGroup(store) floatingPetGroup(store) notificationsGroup(store) @@ -85,8 +87,15 @@ struct SettingsView: View { if startExpanded { advancedExpanded = true Task { @MainActor in - try? await Task.sleep(nanoseconds: 80_000_000) - withAnimation(.easeInOut(duration: 0.25)) { + // Repeats instead of a single timed guess: advancedExpanded's newly revealed rows + // need a real layout pass before the anchor's true position is known, and how long + // that pass takes scales with how much the Settings body has to lay out overall — + // a single settings section added anywhere in this screen can push it past a fixed + // delay. Re-issuing scrollTo across several run-loop turns is self-correcting: an + // early call that lands on stale geometry is simply overwritten by a later one that + // sees the settled layout, at no cost when the first call was already right. + for _ in 0..<6 { + try? await Task.sleep(nanoseconds: 50_000_000) proxy.scrollTo("advancedSettingsSection", anchor: .top) } sessionKeyFocused = true @@ -245,6 +254,33 @@ struct SettingsView: View { DifficultySettingsSection(companion: companion) } + /// Trade identity — a separate concept from the save-transfer device label (`deviceName`), + /// so it gets its own section. The code is generated once per device and never changes, + /// so it is display-only. + private var tradeIdentityGroup: some View { + settingsSection(l.trade) { + groupRow { + Text(l.tradeNickname).font(.callout) + Spacer() + TextField(l.tradeNickname, text: $tradeNicknameDraft) + .textFieldStyle(.roundedBorder) + .labelsHidden() + .frame(width: 160) + // Apply on every keystroke so a user who closes Settings without pressing Enter + // doesn't lose the change. + .onChange(of: tradeNicknameDraft) { _, draft in TradeIdentity.setNickname(draft) } + } + Divider() + groupRow { + Text(l.tradeMyCode).font(.callout) + Spacer() + Text(TradeIdentity.code()) + .font(.system(.callout, design: .monospaced)) + .textSelection(.enabled) + } + } + } + @ViewBuilder private func menuBarGroup(_ store: UsageStore) -> some View { @Bindable var store = store diff --git a/Sources/PokeTokenBar/UI/TradeItemRow.swift b/Sources/PokeTokenBar/UI/TradeItemRow.swift new file mode 100644 index 00000000..ca9346b5 --- /dev/null +++ b/Sources/PokeTokenBar/UI/TradeItemRow.swift @@ -0,0 +1,35 @@ +import SwiftUI + +/// One trade item row — sprite + name. Shared by the offer picker and the accept screen (what +/// I'm giving / what I'm receiving). +/// Shows both the name and the artwork to make clear "what" is being traded in an irreversible +/// trade (`#37` alone doesn't tell you). +/// The name shows the best available value immediately via `store.cachedTradeItemName`, then gets +/// filled in with the exact value via `resolveTradeItemName` — following the same +/// cache-first-then-async-correct approach as `CompanionView`. +@MainActor +struct TradeItemRow: View { + let item: TradeItem + let store: CompanionStore + var spriteSize: CGFloat = 28 + + @State private var name: String + + init(item: TradeItem, store: CompanionStore, spriteSize: CGFloat = 28) { + self.item = item + self.store = store + self.spriteSize = spriteSize + _name = State(initialValue: store.cachedTradeItemName(for: item)) + } + + var body: some View { + HStack(spacing: 6) { + SpriteView(speciesID: item.displaySpeciesID, size: spriteSize, + shiny: item.displayIsShiny, unownForm: item.displayUnownForm) + Text(name) + } + .task { + name = await store.resolveTradeItemName(for: item) + } + } +} diff --git a/Sources/PokeTokenBar/UI/TradeProposalPanel.swift b/Sources/PokeTokenBar/UI/TradeProposalPanel.swift new file mode 100644 index 00000000..83e9a8ff --- /dev/null +++ b/Sources/PokeTokenBar/UI/TradeProposalPanel.swift @@ -0,0 +1,49 @@ +import SwiftUI + +/// Trade summary + accept/reject. Receives, as is, the snapshot `TradeView` builds inside the +/// `TradeSession.onOffersReady` callback — even if the session state changes afterward (e.g. a +/// withdraw), this screen keeps showing the fixed "offer as seen at that moment." +/// Inside the popover this is shown inline as the trade tab's body instead of via `.sheet` — that +/// avoids an existing defect where an orphaned sheet left behind when a transient popover closes +/// swallows every subsequent click (see the NOTE at the top of `PopoverView`, and `BagView`'s +/// identical workaround). +@MainActor +struct TradeProposalPanel: View { + let myOffer: TradeItem + let theirOffer: TradeItem + let overwriteWarning: String? + let store: CompanionStore + let l: L + let onAccept: () -> Void + let onReject: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(l.tradeProposalTitle).font(.headline) + tradeRow(label: l.tradeGiving, item: myOffer) + tradeRow(label: l.tradeReceiving, item: theirOffer) + if let overwriteWarning { + Text(overwriteWarning) + .font(.caption) + .foregroundStyle(.orange) + .fixedSize(horizontal: false, vertical: true) + } + HStack { + Button(l.tradeReject, role: .cancel, action: onReject) + Spacer() + Button(l.tradeAccept, action: onAccept) + .buttonStyle(.borderedProminent) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func tradeRow(label: String, item: TradeItem) -> some View { + HStack { + Text(label).font(.caption).foregroundStyle(.secondary) + Spacer() + TradeItemRow(item: item, store: store) + Text(l.rarityLabel(item.rarity)).font(.caption).foregroundStyle(.secondary) + } + } +} diff --git a/Sources/PokeTokenBar/UI/TradeView.swift b/Sources/PokeTokenBar/UI/TradeView.swift new file mode 100644 index 00000000..cbec619e --- /dev/null +++ b/Sources/PokeTokenBar/UI/TradeView.swift @@ -0,0 +1,519 @@ +import AppKit +import SwiftUI + +/// The popover's "Trade" tab — identity display, peer discovery (automatic with manual fallback), +/// offer selection, acceptance, and commit outcome, all handled in a single screen. +@MainActor +struct TradeView: View { + let store: CompanionStore + /// Signals that the popover shouldn't close on outside clicks while a trade is in progress — + /// if it closes, `onDisappear` tears down the session and the trade is lost (see the comment + /// on `PopoverNavigation.tradeSessionActive`). + @Environment(PopoverNavigation.self) private var navigation + + /// The manual fallback listener's state. Collapsing "still preparing" and "failed to create" + /// into a single `String?` would show a failure message during the few milliseconds it's + /// still preparing. + private enum ManualListener { + case preparing + case ready(code: String) + case unavailable + } + + /// `TradeItem` isn't Equatable, so this enum can't be Equatable either — check the phase only + /// with `if case`/`switch` pattern matching, never `==`. + private enum Phase { + case idle + case searching + case manualFallback(listener: ManualListener, connecting: Bool) + case pickingOffer + case waitingForPeerOffer + case reviewingProposal(mine: TradeItem, theirs: TradeItem) + case waitingForPeerAccept + /// The local commit (backup + state update) is done and we're waiting for the peer's + /// commitAck — if the connection drops here, the irreversible change has already + /// happened, so we go to `.uncertain`. + case committing + case completed + case rejected + /// The apply itself was aborted because the backup failed — nothing changed, so this is + /// distinct from `.uncertain`, whose purpose is showing backup guidance. + case commitFailed + case uncertain + } + + /// How long to wait before giving up on automatic discovery and switching to manual code exchange. + private static let discoveryTimeoutNanoseconds: UInt64 = 10_000_000_000 + + @State private var phase: Phase = .idle + @State private var multipeerTransport: MultipeerTradeTransport? + @State private var manualTransport: ManualTradeTransport? + @State private var session: TradeSession? + @State private var discoveredPeers: [TradePeer] = [] + @State private var connectedPeer: TradePeer? + @State private var manualCodeInput = "" + @State private var manualCodeInvalid = false + @State private var discoveryTimeout: Task? + @State private var lastBackupURL: URL? + @State private var connectFailed = false + + private var l: L { store.l } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + identityHeader + Divider() + phaseContent + } + .frame(maxWidth: .infinity, alignment: .leading) + // Switching tabs or closing the popover destroys this view, but the closures startSession + // planted hold the @State boxes through a copy of the view struct, so the session and + // transport survive — the peer's accept can be received with no screen present, running + // all the way through applyTradeCommit (silently changing the save), with the backup + // location recorded in the now-discarded box. + .onDisappear { teardownConnection() } + } + + // MARK: Identity header + + private var identityHeader: some View { + VStack(alignment: .leading, spacing: 4) { + Text(TradeIdentity.nickname()).font(.headline) + Text("\(l.tradeMyCode): \(TradeIdentity.code())") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + if let connectedPeer { + Text("\(l.tradeConnectedTo): \(connectedPeer.nickname) (\(connectedPeer.code))") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + // MARK: Per-phase body + + @ViewBuilder + private var phaseContent: some View { + switch phase { + case .idle: + Button(l.tradeFindPeers) { startAutomaticDiscovery() } + case .searching: + searchingContent + case .manualFallback(let listener, let connecting): + manualFallbackContent(listener: listener, connecting: connecting) + case .pickingOffer: + offerPicker + case .waitingForPeerOffer: + waitingWithRepick(l.tradeWaitingForPeerOffer) + case .reviewingProposal(let mine, let theirs): + TradeProposalPanel( + myOffer: mine, + theirOffer: theirs, + overwriteWarning: store.tradeOverwriteWarning(forReceiving: theirs), + store: store, + l: l, + onAccept: { acceptProposal() }, + onReject: { rejectProposal() }) + case .waitingForPeerAccept: + waitingWithRepick(l.tradeWaitingForPeerAccept) + case .committing: + VStack(alignment: .leading, spacing: 8) { + waitingRow(l.tradeWaitingForPeerConfirm) + backupHint + Button(l.close) { closeFromCommitting() } + } + case .completed: + outcomeContent(title: l.tradeCompleted, showsBackupHint: true) + case .uncertain: + outcomeContent(title: l.tradeUncertain, showsBackupHint: true) + case .commitFailed: + outcomeContent(title: l.tradeCommitFailed, showsBackupHint: false) + case .rejected: + outcomeContent(title: l.tradeRejectedByPeer, showsBackupHint: false) + } + } + + private var searchingContent: some View { + VStack(alignment: .leading, spacing: 8) { + waitingRow(l.tradeSearching) + ForEach(discoveredPeers) { peer in + Button("\(peer.nickname) (\(peer.code))") { connect(to: peer) } + } + if connectFailed { + Text(l.tradeConnectFailed) + .font(.caption).foregroundStyle(.orange) + .fixedSize(horizontal: false, vertical: true) + } + // If an invite expires without a response, the transport gives no signal at all, and + // the automatic fallback timer backs off once it has found even one peer — so there + // must be a manual way for the person to switch to a manual connection. + Button(l.tradeSwitchToManual) { fallBackToManual() } + Button(l.cancel) { resetToIdle() } + } + } + + private func manualFallbackContent(listener: ManualListener, connecting: Bool) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(l.tradeAutoDiscoveryFailed) + .font(.caption).foregroundStyle(.orange) + .fixedSize(horizontal: false, vertical: true) + switch listener { + case .preparing: + Text(l.tradeManualCodePreparing).font(.caption).foregroundStyle(.secondary) + case .ready(let code): + Text("\(l.tradeManualMyCode): \(code)") + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + case .unavailable: + Text(l.tradeManualCodeUnavailable) + .font(.caption).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + if connecting { + // If the peer's address simply doesn't respond, the transport gives no signal at + // all — don't declare failure, keep showing progress, but always leave a way back + // to re-enter the code. + waitingRow(l.tradeManualConnecting) + Button(l.cancel) { cancelManualConnecting() } + } else { + TextField(l.tradeManualEnterCode, text: $manualCodeInput) + .textFieldStyle(.roundedBorder) + .onSubmit { connectManually() } + if manualCodeInvalid { + Text(l.tradeManualCodeInvalid) + .font(.caption).foregroundStyle(.orange) + .fixedSize(horizontal: false, vertical: true) + } + HStack(spacing: 8) { + Button(l.tradeManualConnect) { connectManually() } + Button(l.cancel) { resetToIdle() } + } + } + } + } + + private var offerPicker: some View { + VStack(alignment: .leading, spacing: 8) { + Text(l.tradeSelectOffer).font(.caption).foregroundStyle(.secondary) + ScrollView { + VStack(alignment: .leading, spacing: 2) { + if let active = store.state.active { + offerRow(item: .activeMon(active), badge: l.dexRaising) + } + ForEach(store.tradeOfferableDexEntries) { entry in + offerRow(item: .dexEntry(entry), badge: nil) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(height: 300) + Button(l.cancel) { resetToIdle() } + } + } + + private func offerRow(item: TradeItem, badge: String?) -> some View { + Button { + propose(item) + } label: { + HStack(spacing: 6) { + TradeItemRow(item: item, store: store, spriteSize: 24) + if let badge { + Text(badge).font(.caption2).foregroundStyle(.secondary) + } + Spacer(minLength: 4) + Text(l.rarityLabel(item.rarity)).font(.caption).foregroundStyle(.secondary) + } + .padding(.vertical, 3) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + private func waitingWithRepick(_ message: String) -> some View { + VStack(alignment: .leading, spacing: 8) { + waitingRow(message) + Button(l.tradeSelectOffer) { returnToOfferPicker() } + Button(l.cancel) { resetToIdle() } + } + } + + private func outcomeContent(title: String, showsBackupHint: Bool) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.callout.weight(.semibold)) + .fixedSize(horizontal: false, vertical: true) + if showsBackupHint { backupHint } + Button(l.close) { resetToIdle() } + } + } + + private func waitingRow(_ message: String) -> some View { + HStack(spacing: 6) { + ProgressView().controlSize(.small) + Text(message).font(.caption).fixedSize(horizontal: false, vertical: true) + } + } + + @ViewBuilder + private var backupHint: some View { + if let lastBackupURL { + VStack(alignment: .leading, spacing: 6) { + Text(l.tradeBackupHint(fileName: lastBackupURL.lastPathComponent)) + .font(.caption).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + // Open with this backup file selected, not just the folder — so it's unambiguous + // which of several backups is the current one. + Button(l.tradeOpenBackupFolder) { + NSWorkspace.shared.activateFileViewerSelecting([lastBackupURL]) + } + } + } + } + + // MARK: Connection + + private func startAutomaticDiscovery() { + navigation.tradeSessionActive = true + let transport = MultipeerTradeTransport(nickname: TradeIdentity.nickname(), code: TradeIdentity.code()) + transport.onPeerFound = { peer in Task { @MainActor in addDiscoveredPeer(peer) } } + transport.onPeerLost = { peerID in + Task { @MainActor in discoveredPeers.removeAll { $0.id == peerID } } + } + multipeerTransport = transport + // Create the session **before** connecting — even the side receiving the invite needs the + // session already holding the transport's onConnected to exchange Hello messages. If it's + // created after connecting, that side never identifies its peer. + startSession(with: transport) + transport.startDiscovery() + phase = .searching + discoveryTimeout = Task { @MainActor in + try? await Task.sleep(nanoseconds: Self.discoveryTimeoutNanoseconds) + guard !Task.isCancelled, case .searching = phase, discoveredPeers.isEmpty else { return } + transport.stopDiscovery() + fallBackToManual() + } + } + + private func addDiscoveredPeer(_ peer: TradePeer) { + guard !discoveredPeers.contains(where: { $0.id == peer.id }) else { return } + discoveredPeers.append(peer) + } + + /// Enters manual fallback. `startListening`'s completion fires synchronously when there's no + /// local IPv4 address or listener creation fails, and otherwise fires exactly once, + /// asynchronously, on the Network framework's callback queue — so the phase is set to + /// `.preparing` first, before listening starts, so the result can update the phase whenever + /// it actually arrives. + private func fallBackToManual() { + discoveryTimeout?.cancel() + discoveryTimeout = nil + releaseMultipeerTransport() + discoveredPeers = [] + connectFailed = false + let transport = ManualTradeTransport() + manualTransport = transport + startSession(with: transport) + phase = .manualFallback(listener: .preparing, connecting: false) + transport.startListening { code in + Task { @MainActor in applyManualListening(code: code) } + } + } + + private func applyManualListening(code: String?) { + guard case .manualFallback(_, let connecting) = phase else { return } + phase = .manualFallback(listener: code.map { .ready(code: $0) } ?? .unavailable, connecting: connecting) + } + + /// The discovery timeout isn't cancelled here — only in `onPeerIdentified`, once the + /// connection is confirmed. Clearing the fallback path just from sending an invite would trap + /// us in `.searching` if the invite never actually succeeds. + private func connect(to peer: TradePeer) { + guard let multipeerTransport else { return } + do { + try multipeerTransport.connect(to: peer) + connectFailed = false + } catch { + // The peer is stale (already gone) or already trading with someone else — remove the + // row and let the person know. + discoveredPeers.removeAll { $0.id == peer.id } + connectFailed = true + } + } + + private func connectManually() { + guard let manualTransport, case .manualFallback(let listener, false) = phase else { return } + let code = manualCodeInput.trimmingCharacters(in: .whitespacesAndNewlines) + guard !code.isEmpty else { return } + do { + try manualTransport.connectManually(code: code) + manualCodeInvalid = false + phase = .manualFallback(listener: listener, connecting: true) + } catch { + manualCodeInvalid = true + } + } + + /// Cancelling doesn't disconnect the transport — the listener is shared, so disconnecting + /// would also kill our own connection code. Any pending connection gets replaced by the next + /// `connectManually` inside `wire`. + private func cancelManualConnecting() { + guard case .manualFallback(let listener, true) = phase else { return } + phase = .manualFallback(listener: listener, connecting: false) + } + + // MARK: Session + + private func startSession(with transport: any TradeTransport) { + // The fallback path swaps sessions — the old one must not keep firing callbacks here. + releaseSession() + let newSession = TradeSession(transport: transport) + newSession.onPeerIdentified = { identity in + connectedPeer = TradePeer(id: identity.code, nickname: identity.nickname, code: identity.code) + switch phase { + case .searching, .manualFallback: + discoveryTimeout?.cancel() + multipeerTransport?.stopDiscovery() + phase = .pickingOffer + default: + break + } + } + newSession.onOffersReady = { mine, theirs in + // Whichever side's offer changes, the session invalidates both Accepts, so we go + // back to the review phase. Offers arriving after commit are ignored — a trade + // that's already been applied can't be accepted again. + switch phase { + case .committing, .completed, .uncertain, .commitFailed, .rejected: + break + default: + phase = .reviewingProposal(mine: mine, theirs: theirs) + } + } + newSession.onOfferWithdrawn = { + // The review screen is a snapshot from the moment the offer arrived, so it keeps + // promising an item the peer has withdrawn — that can't sit next to an irreversible + // accept button, so we fall back to the waiting phase. Our own offer is left as is. + if case .reviewingProposal = phase { phase = .waitingForPeerOffer } + } + newSession.onReadyToCommit = { [weak newSession] received in + // `sending:` must always be our own local offer — passing in a value echoed back by + // the peer would let it delete an arbitrary Pokédex entry without normalization + // (per the `CompanionStore.applyTradeCommit` contract). + // If the session is already gone, don't even **start** applying — stopping with + // nothing changed is safer than applying the trade and then failing to send the ack + // (the same direction as the backup-failure path below). + guard let newSession, let myOffer = newSession.myOffer else { return } + do { + lastBackupURL = try store.applyTradeCommit(sending: myOffer, receiving: received) + } catch { + // The apply was aborted because we couldn't write the backup — we don't send + // commitAck, so the peer also stays unconfirmed (a failure that leaves both + // sides' local state unchanged, which is the safe direction). + phase = .commitFailed + newSession.disconnect() + return + } + phase = .committing + newSession.confirmLocalCommit() + } + newSession.onCompleted = { phase = .completed } + newSession.onRejected = { _ in + teardownConnection() + phase = .rejected + } + newSession.onDisconnected = { + switch phase { + case .committing: + phase = .uncertain // The irreversible local change has already finished + case .manualFallback(let listener, true): + phase = .manualFallback(listener: listener, connecting: false) + case .idle, .searching, .manualFallback, .completed, .uncertain, .commitFailed, .rejected: + break + default: + resetToIdle() + } + } + session = newSession + } + + private func propose(_ item: TradeItem) { + session?.proposeOffer(item) + // If the peer's offer has already arrived, proposeOffer moves us to the review phase itself — don't overwrite that. + if case .pickingOffer = phase { phase = .waitingForPeerOffer } + } + + private func returnToOfferPicker() { + session?.withdrawOffer() + phase = .pickingOffer + } + + private func acceptProposal() { + session?.accept() + // If the peer already accepted first, accept() itself carries the trade through to commit — don't overwrite that. + if case .reviewingProposal = phase { phase = .waitingForPeerAccept } + } + + private func rejectProposal() { + session?.reject(reason: "user_declined") + resetToIdle() + } + + /// Releases the session — clears the callbacks before dropping the reference. If the old + /// session overwrites this screen's phase while it's still alive during the fallback swap + /// (while the transport layer is still flushing its last callback), the user ends up dragged + /// into the wrong phase carrying the peer name of a connection already discarded. + private func releaseSession() { + guard let session else { return } + session.onPeerIdentified = nil + session.onOffersReady = nil + session.onOfferWithdrawn = nil + session.onReadyToCommit = nil + session.onCompleted = nil + session.onRejected = nil + session.onDisconnected = nil + session.disconnect() + self.session = nil + } + + /// `disconnect()` only tears down the MCSession — the advertiser/browser is only shut down by + /// `stopDiscovery()`. Missing either one leaves the app still advertising the trade service, + /// and an auto-accepted invite can wake a session that's already been discarded. + private func releaseMultipeerTransport() { + multipeerTransport?.stopDiscovery() + multipeerTransport?.disconnect() + multipeerTransport = nil + } + + private func teardownConnection() { + navigation.tradeSessionActive = false + discoveryTimeout?.cancel() + discoveryTimeout = nil + releaseSession() + releaseMultipeerTransport() + manualTransport?.disconnect() + manualTransport = nil + } + + /// `.committing` means the local commit has already finished, so sending it through + /// `resetToIdle` would clear, with a single click, the backup location and recovery guidance + /// and the "peer's apply is uncertain" indicator that the spec requires — the file survives + /// but loses its name. Instead, just disconnect and go to `.uncertain`, the same phase as the + /// disconnect path (which is semantically accurate — this really is an uncertain state). + private func closeFromCommitting() { + teardownConnection() + phase = .uncertain + } + + private func resetToIdle() { + teardownConnection() + discoveredPeers = [] + connectedPeer = nil + manualCodeInput = "" + manualCodeInvalid = false + lastBackupURL = nil + connectFailed = false + phase = .idle + } +} diff --git a/Tests/PokeTokenBarTests/IdenticalTradeTests.swift b/Tests/PokeTokenBarTests/IdenticalTradeTests.swift new file mode 100644 index 00000000..dd2aef2f --- /dev/null +++ b/Tests/PokeTokenBarTests/IdenticalTradeTests.swift @@ -0,0 +1,163 @@ +import XCTest +@testable import PokeTokenBar + +private enum IdenticalTradeStubError: Error { case unavailable } + +private struct IdenticalTradeOfflineProvider: PokeProviding { + func line(baseSpeciesID: Int) async throws -> EvoLine { throw IdenticalTradeStubError.unavailable } + func baseSpeciesIndex() async throws -> [BaseSpecies] { throw IdenticalTradeStubError.unavailable } + func baseSpecies(id: Int) async throws -> BaseSpecies? { throw IdenticalTradeStubError.unavailable } +} + +/// Trading for a mon identical to one already owned — same species, nature, moves and stats. +@MainActor +final class IdenticalTradeTests: XCTestCase { + private func fixture(state: CompanionState = CompanionState()) throws -> CompanionStore { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent("identical-trade-\(UUID())") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let url = dir.appendingPathComponent("companion-state.json") + try JSONEncoder().encode(state).write(to: url) + let defaults = try XCTUnwrap(UserDefaults(suiteName: "identical-trade-\(UUID())")) + return CompanionStore(provider: IdenticalTradeOfflineProvider(), fileURL: url, + dittoDisguiseRollingEnabled: false, defaults: defaults) + } + + /// One shared profile so every entry below has identical moves, IVs and stats. A graduated entry + /// takes its id from `profile.instanceID`, so entries seeded here carry that pairing too. + private func sharedProfile(instanceID: String) -> PokemonProfile { + PokemonProfile.generate(seed: 42, instanceID: instanceID) + } + + private func entry(id: String, instanceID: String? = nil) -> DexEntry { + DexEntry(id: id, baseID: 1, finalID: 3, chainOrder: [1, 2, 3], rarity: .rare, + caughtAt: Date(timeIntervalSince1970: 0), nature: .adamant, + profile: sharedProfile(instanceID: instanceID ?? id)) + } + + // MARK: Individuals stay distinct + + /// Receiving a mon identical in every visible respect, but a distinct individual. + func testReceivingIdenticalTwinKeepsBothIndividuals() throws { + var seed = CompanionState() + seed.dex = [entry(id: "mine"), entry(id: "payment")] + let store = try fixture(state: seed) + + _ = try store.applyTradeCommit(sending: .dexEntry(entry(id: "payment")), + receiving: .dexEntry(entry(id: "twin"))) + + XCTAssertEqual(Set(store.state.dex.map(\.id)), ["mine", "twin"]) + XCTAssertEqual(store.pokemonIndividuals(speciesID: 3).count, 2, + "both identical individuals must stay visible on the species page") + } + + /// The dex collapses by species, so an identical twin must not inflate the collected count. + func testIdenticalTwinDoesNotInflateSpeciesCount() throws { + var seed = CompanionState() + seed.dex = [entry(id: "mine"), entry(id: "payment")] + let store = try fixture(state: seed) + let speciesBefore = store.dexSpecies.count + + _ = try store.applyTradeCommit(sending: .dexEntry(entry(id: "payment")), + receiving: .dexEntry(entry(id: "twin"))) + + XCTAssertEqual(store.dexSpecies.count, speciesBefore) + } + + // MARK: Identity reissue + + /// The partner returns an entry we still hold — a one-sided commit left the same id on both + /// devices. The incoming id must be reissued or a later trade deletes two mons at once. + func testReceivingAnEntryWithAnIdWeStillHoldReissuesTheId() throws { + var seed = CompanionState() + seed.dex = [entry(id: "shared"), entry(id: "payment")] + let store = try fixture(state: seed) + + _ = try store.applyTradeCommit(sending: .dexEntry(entry(id: "payment")), + receiving: .dexEntry(entry(id: "shared"))) + + XCTAssertEqual(store.state.dex.count, 2) + XCTAssertEqual(Set(store.state.dex.map(\.id)).count, 2, "the incoming id must be reissued") + } + + /// A graduated entry's id comes from `profile.instanceID`, so reissuing one without the other + /// leaves the traded copy pointing at another individual's instance identity. + func testReissuedEntryKeepsIdAndInstanceIDInSync() throws { + var seed = CompanionState() + seed.dex = [entry(id: "shared"), entry(id: "payment")] + let store = try fixture(state: seed) + + _ = try store.applyTradeCommit(sending: .dexEntry(entry(id: "payment")), + receiving: .dexEntry(entry(id: "shared"))) + + let instanceIDs = store.state.dex.compactMap { $0.profile?.instanceID } + XCTAssertEqual(instanceIDs.count, 2) + XCTAssertEqual(Set(instanceIDs).count, 2, "two individuals must not share an instanceID") + for entry in store.state.dex { + XCTAssertEqual(entry.id, entry.profile?.instanceID, + "a dex entry's id and its profile instanceID must stay equal") + } + } + + /// The ids differ but the instance identity still collides — a twin cloned from our own entry + /// on the partner's device. Checking only the id would let this one through. + func testInstanceIDCollisionIsReissuedEvenWhenTheIdDiffers() throws { + var seed = CompanionState() + seed.dex = [entry(id: "mine", instanceID: "cloned"), entry(id: "payment")] + let store = try fixture(state: seed) + + _ = try store.applyTradeCommit(sending: .dexEntry(entry(id: "payment")), + receiving: .dexEntry(entry(id: "twin", instanceID: "cloned"))) + + let instanceIDs = store.state.dex.compactMap { $0.profile?.instanceID } + XCTAssertEqual(Set(instanceIDs).count, 2, "the colliding instanceID must be reissued") + XCTAssertEqual(store.state.dex.count, 2, "reissuing identity must not drop the entry") + } + + // MARK: Offer eligibility + + /// A released entry is the record of a mon we let go, so it must not appear among the things + /// we can offer — while still staying in the dex so its species page survives. + func testReleasedEntriesAreNotOfferable() throws { + var released = entry(id: "released") + released.releasedAt = Date(timeIntervalSince1970: 0) + var seed = CompanionState() + seed.dex = [entry(id: "graduated"), released] + let store = try fixture(state: seed) + + XCTAssertEqual(store.tradeOfferableDexEntries.map(\.id), ["graduated"]) + XCTAssertEqual(store.state.dex.count, 2, "the released record itself must stay in the dex") + } + + // MARK: Commit ordering and the active mon + + /// Giving an entry away and receiving the identical id back in the same trade: remove runs + /// first, so the entry ends up held exactly once rather than duplicated or lost. + func testTradingAnEntryForItsOwnIdLeavesExactlyOneCopy() throws { + var seed = CompanionState() + seed.dex = [entry(id: "same")] + let store = try fixture(state: seed) + + _ = try store.applyTradeCommit(sending: .dexEntry(entry(id: "same")), + receiving: .dexEntry(entry(id: "same"))) + + XCTAssertEqual(store.state.dex.count, 1) + XCTAssertEqual(store.state.dex[0].id, "same") + } + + /// Receiving an in-progress mon identical to the one being raised still warns before it + /// overwrites — an identical twin destroys the local mon just the same. + func testReceivingIdenticalActiveMonStillWarnsAndOverwrites() throws { + var seed = CompanionState() + seed.active = MonState(baseID: 1, pathIDs: [1, 2, 3], stageIndex: 1, usedAtStage: 500, + rarity: .rare, totalForms: 3) + let store = try fixture(state: seed) + let twin = MonState(baseID: 1, pathIDs: [1, 2, 3], stageIndex: 1, usedAtStage: 500, + rarity: .rare, totalForms: 3) + + XCTAssertNotNil(store.tradeOverwriteWarning(forReceiving: .activeMon(twin))) + _ = try store.applyTradeCommit(sending: .activeMon(try XCTUnwrap(seed.active)), + receiving: .activeMon(twin)) + XCTAssertEqual(store.state.active?.usedAtStage, 500) + XCTAssertNil(store.state.eggTier) + } +} diff --git a/Tests/PokeTokenBarTests/ManualTradeTransportTests.swift b/Tests/PokeTokenBarTests/ManualTradeTransportTests.swift new file mode 100644 index 00000000..5de9eae9 --- /dev/null +++ b/Tests/PokeTokenBarTests/ManualTradeTransportTests.swift @@ -0,0 +1,151 @@ +import XCTest +@testable import PokeTokenBar + +final class ManualTradeTransportTests: XCTestCase { + func testConnectManuallyRejectsMalformedCode() { + let transport = ManualTradeTransport() + XCTAssertThrowsError(try transport.connectManually(code: "not-a-valid-code")) + } + + func testSendBeforeConnectionThrowsSendFailed() { + let transport = ManualTradeTransport() + XCTAssertThrowsError(try transport.send(.accept)) { error in + XCTAssertEqual(error as? TradeTransportError, .sendFailed) + } + } + + func testLoopbackConnectionExchangesOneMessage() throws { + let listenerSide = ManualTradeTransport() + addTeardownBlock { listenerSide.disconnect() } + let listeningStarted = expectation(description: "listener started") + nonisolated(unsafe) var listenerCode: String? + listenerSide.startListening { code in + listenerCode = code + listeningStarted.fulfill() + } + wait(for: [listeningStarted], timeout: 5) + guard let code = listenerCode else { + throw XCTSkip("No local IPv4 address in this environment — run on a local dev Mac") + } + let connectorSide = ManualTradeTransport() + addTeardownBlock { connectorSide.disconnect() } + + let listenerConnected = expectation(description: "listener connected") + let connectorConnected = expectation(description: "connector connected") + let messageReceived = expectation(description: "message received") + listenerSide.onConnected = { listenerConnected.fulfill() } + connectorSide.onConnected = { connectorConnected.fulfill() } + listenerSide.onMessageReceived = { message in + guard case .accept = message else { return } + messageReceived.fulfill() + } + + try connectorSide.connectManually(code: code) + wait(for: [listenerConnected, connectorConnected], timeout: 5) + try connectorSide.send(.accept) + wait(for: [messageReceived], timeout: 5) + } + + /// R20 regression — when the peer disconnects, this side's `connection` must be cleared. + /// Otherwise a send on the dead NWConnection "succeeds" without an error, which leads + /// TradeSession.confirmLocalCommit() into a desync where it treats a message that was never + /// actually delivered as if it had been. + func testSendAfterPeerDisconnectionThrowsSendFailed() throws { + let listenerSide = ManualTradeTransport() + addTeardownBlock { listenerSide.disconnect() } + let listeningStarted = expectation(description: "listener started") + nonisolated(unsafe) var listenerCode: String? + listenerSide.startListening { code in + listenerCode = code + listeningStarted.fulfill() + } + wait(for: [listeningStarted], timeout: 5) + guard let code = listenerCode else { + throw XCTSkip("No local IPv4 address in this environment — run on a local dev Mac") + } + let connectorSide = ManualTradeTransport() + addTeardownBlock { connectorSide.disconnect() } + + let listenerConnected = expectation(description: "listener connected") + let connectorConnected = expectation(description: "connector connected") + listenerSide.onConnected = { listenerConnected.fulfill() } + connectorSide.onConnected = { connectorConnected.fulfill() } + try connectorSide.connectManually(code: code) + wait(for: [listenerConnected, connectorConnected], timeout: 5) + + let connectorNoticedDisconnect = expectation(description: "connector noticed peer disconnect") + connectorNoticedDisconnect.assertForOverFulfill = true + connectorSide.onDisconnected = { connectorNoticedDisconnect.fulfill() } + listenerSide.disconnect() + wait(for: [connectorNoticedDisconnect], timeout: 5) + + XCTAssertThrowsError(try connectorSide.send(.accept)) { error in + XCTAssertEqual(error as? TradeTransportError, .sendFailed) + } + } + + /// R21 regression — the previous version only checked "completion was called once," which really + /// verified "at least once" (it never triggered a second transition, so assertForOverFulfill had + /// nothing to catch). Here, after success, disconnect() cancels the listener so + /// stateUpdateHandler(.cancelled) calls callCompletionOnce again, and we actually verify that + /// OneShotGate suppresses that second call. + func testStartListeningCompletionFiresExactlyOnce() throws { + let transport = ManualTradeTransport() + addTeardownBlock { transport.disconnect() } + let completionCalled = expectation(description: "completion called once") + completionCalled.assertForOverFulfill = true + transport.startListening { _ in + completionCalled.fulfill() + } + wait(for: [completionCalled], timeout: 5) + + transport.disconnect() // cancels the listener — re-entering .cancelled triggers a second completion call. + let settled = expectation(description: "allow time for a suppressed second call to surface") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { settled.fulfill() } + wait(for: [settled], timeout: 1) + } + + /// R22 regression — when connectManually is called again to switch to a different peer, failing + /// to cancel the previous connection leaves the old peer believing the connection is still alive + /// (a socket leak plus the old stateUpdateHandler staying alive). Whether the previous connection + /// was actually cancelled is observed through whether the peer (firstListener) detects the + /// disconnect — if only the self.connection pointer were swapped, firstListener would never know. + func testReconnectingCancelsPreviousConnection() throws { + let firstListener = ManualTradeTransport() + addTeardownBlock { firstListener.disconnect() } + let secondListener = ManualTradeTransport() + addTeardownBlock { secondListener.disconnect() } + let connectorSide = ManualTradeTransport() + addTeardownBlock { connectorSide.disconnect() } + + let firstListenerCodeReady = expectation(description: "first listener started") + nonisolated(unsafe) var firstCode: String? + firstListener.startListening { code in + firstCode = code + firstListenerCodeReady.fulfill() + } + let secondListenerCodeReady = expectation(description: "second listener started") + nonisolated(unsafe) var secondCode: String? + secondListener.startListening { code in + secondCode = code + secondListenerCodeReady.fulfill() + } + wait(for: [firstListenerCodeReady, secondListenerCodeReady], timeout: 5) + guard let codeA = firstCode, let codeB = secondCode else { + throw XCTSkip("No local IPv4 address in this environment — run on a local dev Mac") + } + + let firstConnected = expectation(description: "connected to first listener") + firstListener.onConnected = { firstConnected.fulfill() } + try connectorSide.connectManually(code: codeA) + wait(for: [firstConnected], timeout: 5) + + let firstListenerNoticedDisconnect = expectation(description: "first listener noticed disconnect") + firstListener.onDisconnected = { firstListenerNoticedDisconnect.fulfill() } + let secondConnected = expectation(description: "connected to second listener") + secondListener.onConnected = { secondConnected.fulfill() } + + try connectorSide.connectManually(code: codeB) // reconnects to a different peer — the previous connection must be cancelled. + wait(for: [firstListenerNoticedDisconnect, secondConnected], timeout: 5) + } +} diff --git a/Tests/PokeTokenBarTests/MultipeerLiveDiscoveryTests.swift b/Tests/PokeTokenBarTests/MultipeerLiveDiscoveryTests.swift new file mode 100644 index 00000000..b4069337 --- /dev/null +++ b/Tests/PokeTokenBarTests/MultipeerLiveDiscoveryTests.swift @@ -0,0 +1,78 @@ +import XCTest +@testable import PokeTokenBar + +/// Drives two real `MultipeerTradeTransport` instances against the actual MultipeerConnectivity +/// stack — discovery, invitation, connection and one message over the wire. Every other trade +/// test substitutes an in-memory transport, so this is the only place the Multipeer path is +/// exercised rather than reasoned about. +/// +/// Skips rather than fails when the environment has no usable local network (sandboxed CI, no +/// permission grant): a skip says "unverified here", a failure would say "broken", and only the +/// first is true. +final class MultipeerLiveDiscoveryTests: XCTestCase { + private func makePair() -> (MultipeerTradeTransport, MultipeerTradeTransport) { + // Distinct nicknames keep the tiebreak deterministic and the discovery list unambiguous. + (MultipeerTradeTransport(nickname: "PTBTestAlice-\(UUID().uuidString.prefix(4))", code: "AAAA1111"), + MultipeerTradeTransport(nickname: "PTBTestBob-\(UUID().uuidString.prefix(4))", code: "BBBB2222")) + } + + func testTwoTransportsDiscoverConnectAndExchangeAMessage() throws { + let (alice, bob) = makePair() + defer { alice.disconnect(); bob.disconnect(); alice.stopDiscovery(); bob.stopDiscovery() } + + let aliceFoundBob = expectation(description: "alice discovers bob") + aliceFoundBob.assertForOverFulfill = false + let bobConnected = expectation(description: "bob sees the session connect") + bobConnected.assertForOverFulfill = false + let aliceConnected = expectation(description: "alice sees the session connect") + aliceConnected.assertForOverFulfill = false + let bobReceivedHello = expectation(description: "bob receives alice's hello") + bobReceivedHello.assertForOverFulfill = false + + let discovered = OSAllocatedUnfairBoxCompat(nil) + alice.onPeerFound = { peer in + guard peer.code == "BBBB2222" else { return } + discovered.set(peer) + aliceFoundBob.fulfill() + } + alice.onConnected = { aliceConnected.fulfill() } + bob.onConnected = { bobConnected.fulfill() } + bob.onMessageReceived = { message in + guard case .hello(_, let code) = message, code == "AAAA1111" else { return } + bobReceivedHello.fulfill() + } + + alice.startDiscovery() + bob.startDiscovery() + + let discoveryResult = XCTWaiter().wait(for: [aliceFoundBob], timeout: 25) + guard discoveryResult == .completed, let peer = discovered.value else { + throw XCTSkip("No local-network discovery in this environment — Multipeer path unverified here.") + } + + try alice.connect(to: peer) + wait(for: [aliceConnected, bobConnected], timeout: 25) + + try alice.send(.hello(nickname: "alice", code: "AAAA1111")) + wait(for: [bobReceivedHello], timeout: 15) + } +} + +/// Minimal thread-safe box — the transport's callbacks arrive on framework queues, so the value +/// the test reads back afterwards has to cross threads. +private final class OSAllocatedUnfairBoxCompat: @unchecked Sendable { + private let lock = NSLock() + private var storage: Value + + init(_ value: Value) { storage = value } + + var value: Value { + lock.lock(); defer { lock.unlock() } + return storage + } + + func set(_ newValue: Value) { + lock.lock(); defer { lock.unlock() } + storage = newValue + } +} diff --git a/Tests/PokeTokenBarTests/MultipeerTradeTransportTests.swift b/Tests/PokeTokenBarTests/MultipeerTradeTransportTests.swift new file mode 100644 index 00000000..93d6968d --- /dev/null +++ b/Tests/PokeTokenBarTests/MultipeerTradeTransportTests.swift @@ -0,0 +1,155 @@ +import MultipeerConnectivity +import XCTest +@testable import PokeTokenBar + +final class MultipeerTradeTransportTests: XCTestCase { + /// Bonjour service type rules: 1-15 characters, alphanumerics and hyphens only, must not + /// start or end with a hyphen. + func testServiceTypeIsValidBonjourServiceType() { + let serviceType = MultipeerTradeTransport.serviceType + XCTAssertTrue((1...15).contains(serviceType.count)) + XCTAssertTrue(serviceType.allSatisfy { $0.isLowercase || $0.isNumber || $0 == "-" }) + XCTAssertFalse(serviceType.hasPrefix("-")) + XCTAssertFalse(serviceType.hasSuffix("-")) + } + + func testMakeTradePeerUsesDiscoveryInfoCode() { + let peer = MultipeerTradeTransport.makeTradePeer(displayName: "민수의 Mac", discoveryInfo: ["code": "ABCD1234"]) + XCTAssertEqual(peer.id, "민수의 Mac") + XCTAssertEqual(peer.nickname, "민수의 Mac") + XCTAssertEqual(peer.code, "ABCD1234") + } + + func testMakeTradePeerFallsBackWhenDiscoveryInfoMissing() { + let peer = MultipeerTradeTransport.makeTradePeer(displayName: "민수의 Mac", discoveryInfo: nil) + XCTAssertEqual(peer.code, "????") + } + + // MARK: R5 — clamp the nickname to MCPeerID's constraints (1-63 UTF-8 bytes, not empty). + + func testClampNicknameFallsBackToHostNameWhenEmpty() { + let clamped = MultipeerTradeTransport.clampNickname("", fallback: "MacBook-Pro") + XCTAssertEqual(clamped, "MacBook-Pro") + } + + func testClampNicknameFallsBackWhenOnlyWhitespace() { + let clamped = MultipeerTradeTransport.clampNickname(" ", fallback: "MacBook-Pro") + XCTAssertEqual(clamped, "MacBook-Pro") + } + + func testClampNicknameTruncatesToUTF8ByteBudgetNotCharacterCount() { + // A single Korean character is 3 bytes in UTF-8 — with a 63-byte budget it can't exceed 21 characters. + let longKoreanName = String(repeating: "민", count: 30) + let clamped = MultipeerTradeTransport.clampNickname(longKoreanName, fallback: "fallback") + XCTAssertLessThanOrEqual(clamped.utf8.count, 63) + XCTAssertFalse(clamped.isEmpty) + } + + func testClampNicknameLeavesShortNameUnchanged() { + let clamped = MultipeerTradeTransport.clampNickname("민수의 Mac", fallback: "fallback") + XCTAssertEqual(clamped, "민수의 Mac") + } + + func testClampNicknameProducesValidMCPeerID() { + // Verifies that constructing a real MCPeerID from the clamped result succeeds without + // trapping — the whole reason R5 exists. + let longKoreanName = String(repeating: "가", count: 100) + let clamped = MultipeerTradeTransport.clampNickname(longKoreanName, fallback: "fallback") + let peerID = MCPeerID(displayName: clamped) + XCTAssertFalse(peerID.displayName.isEmpty) + } + + // MARK: C2 — guarantee 1:1. MCSession supports multi-party connections, so unconditionally + // accepting invitations would turn send into a broadcast. + + func testInvitationFromAThirdPartyIsRefusedWhileTradingWithSomeone() { + let accepted = MultipeerTradeTransport.shouldAccept( + invitationFrom: "Carol", partner: "Bob", invited: nil, + myKey: "Alice\u{0}AAAA", theirKey: "Carol\u{0}CCCC") + XCTAssertFalse(accepted, "a third party must not join an established 1:1 trade") + } + + func testInvitationFromTheCurrentPartnerIsAcceptedAgain() { + // The case where the current partner tries to reconnect — this must not be blocked by + // the partner-lock. + let accepted = MultipeerTradeTransport.shouldAccept( + invitationFrom: "Bob", partner: "Bob", invited: nil, + myKey: "Alice\u{0}AAAA", theirKey: "Bob\u{0}BBBB") + XCTAssertTrue(accepted) + } + + func testInvitationFromAnUninvitedPeerIsAcceptedWhenFree() { + let accepted = MultipeerTradeTransport.shouldAccept( + invitationFrom: "Bob", partner: nil, invited: nil, + myKey: "Alice\u{0}AAAA", theirKey: "Bob\u{0}BBBB") + XCTAssertTrue(accepted, "the side that did not click must still be reachable") + } + + func testCrossedInvitationsAreResolvedSoExactlyOneSideAccepts() { + // A situation where both sides tapped each other's row, crossing the invitations — if both + // accept, the same pair ends up with two connections, one of which soon drops, silently + // resetting the exchange back to the start. + let aliceKey = "Alice\u{0}AAAA" + let bobKey = "Bob\u{0}BBBB" + let aliceAccepts = MultipeerTradeTransport.shouldAccept( + invitationFrom: "Bob", partner: nil, invited: "Bob", myKey: aliceKey, theirKey: bobKey) + let bobAccepts = MultipeerTradeTransport.shouldAccept( + invitationFrom: "Alice", partner: nil, invited: "Alice", myKey: bobKey, theirKey: aliceKey) + XCTAssertNotEqual(aliceAccepts, bobAccepts, "exactly one side of a crossed invitation may accept") + } + + func testCrossedInvitationTieBreakUsesTheCodeWhenNicknamesMatch() { + // The default nickname is the computer name, so two devices can end up with the same + // name — the nickname alone gives no ordering. + let firstKey = MultipeerTradeTransport.tiebreakKey(displayName: "MacBook Pro", code: "AAAA1111") + let secondKey = MultipeerTradeTransport.tiebreakKey(displayName: "MacBook Pro", code: "BBBB2222") + XCTAssertNotEqual(firstKey, secondKey) + let firstAccepts = MultipeerTradeTransport.shouldAccept( + invitationFrom: "MacBook Pro", partner: nil, invited: "MacBook Pro", + myKey: firstKey, theirKey: secondKey) + let secondAccepts = MultipeerTradeTransport.shouldAccept( + invitationFrom: "MacBook Pro", partner: nil, invited: "MacBook Pro", + myKey: secondKey, theirKey: firstKey) + XCTAssertNotEqual(firstAccepts, secondAccepts) + } + + func testTiebreakKeySeparatesNameFromCode() { + // Concatenating without a separator would make ("ab","c") and ("a","bc") produce the same key. + XCTAssertNotEqual(MultipeerTradeTransport.tiebreakKey(displayName: "ab", code: "c"), + MultipeerTradeTransport.tiebreakKey(displayName: "a", code: "bc")) + } + + // MARK: NB2 — an invitation that expired without a response must not linger and keep + // refusing that peer's later invitations. + + func testExpiredInvitationIsClearedWhenNoNewerInvitationWentOut() { + XCTAssertTrue(MultipeerTradeTransport.shouldClearExpiredInvitation( + currentGeneration: 1, expiringGeneration: 1, hasPartner: false)) + } + + func testExpiryOfAnOlderInvitationLeavesTheNewerOneAlone() { + // If an earlier timer wakes up late and clears an invitation that just went out, then when + // that invitation crosses with the peer's, both sides accept and two connections are + // created — exactly the state C2 was meant to prevent. + XCTAssertFalse(MultipeerTradeTransport.shouldClearExpiredInvitation( + currentGeneration: 2, expiringGeneration: 1, hasPartner: false)) + } + + func testExpiryDoesNothingOnceAPartnerIsFixed() { + XCTAssertFalse(MultipeerTradeTransport.shouldClearExpiredInvitation( + currentGeneration: 1, expiringGeneration: 1, hasPartner: true)) + } + + func testClearedInvitationLetsThatPeerBeAcceptedAgain() { + // The whole point of clearing an expired invitation — after clearing it, that peer's + // invitation must pass through without hitting the tiebreak. + let myKey = MultipeerTradeTransport.tiebreakKey(displayName: "Alice", code: "AAAA") + let theirKey = MultipeerTradeTransport.tiebreakKey(displayName: "Bob", code: "BBBB") + XCTAssertFalse(MultipeerTradeTransport.shouldAccept( + invitationFrom: "Bob", partner: nil, invited: "Bob", myKey: myKey, theirKey: theirKey), + "while our invitation is live the lower-keyed side must refuse") + XCTAssertTrue(MultipeerTradeTransport.shouldAccept( + invitationFrom: "Bob", partner: nil, invited: nil, myKey: myKey, theirKey: theirKey), + "once the expired invitation is cleared their invitation must be accepted") + } +} diff --git a/Tests/PokeTokenBarTests/TCPFrameBufferTests.swift b/Tests/PokeTokenBarTests/TCPFrameBufferTests.swift new file mode 100644 index 00000000..d1514b6f --- /dev/null +++ b/Tests/PokeTokenBarTests/TCPFrameBufferTests.swift @@ -0,0 +1,84 @@ +import XCTest +@testable import PokeTokenBar + +final class TCPFrameBufferTests: XCTestCase { + func testSingleFrameArrivingWhole() throws { + var buffer = TCPFrameBuffer() + let payload = Data("hello".utf8) + XCTAssertEqual(try buffer.append(TCPFrameBuffer.frame(payload)), [payload]) + } + + func testFrameSplitAcrossTwoAppends() throws { + var buffer = TCPFrameBuffer() + let framed = TCPFrameBuffer.frame(Data("hello".utf8)) + let firstHalf = Data(framed.prefix(3)) + let secondHalf = Data(framed.suffix(from: 3)) + XCTAssertTrue(try buffer.append(firstHalf).isEmpty) + XCTAssertEqual(try buffer.append(secondHalf), [Data("hello".utf8)]) + } + + func testTwoFramesArrivingTogether() throws { + var buffer = TCPFrameBuffer() + var combined = TCPFrameBuffer.frame(Data("a".utf8)) + combined.append(TCPFrameBuffer.frame(Data("bb".utf8))) + XCTAssertEqual(try buffer.append(combined), [Data("a".utf8), Data("bb".utf8)]) + } + + func testEmptyPayloadRoundTrips() throws { + var buffer = TCPFrameBuffer() + XCTAssertEqual(try buffer.append(TCPFrameBuffer.frame(Data())), [Data()]) + } + + /// The length prefix (4 bytes) has fully arrived but the payload has only partially arrived — + /// knowing the length must not trigger an immediate cut; it must wait until the whole payload arrives. + func testPayloadArrivesSplitAfterCompleteLengthPrefix() throws { + var buffer = TCPFrameBuffer() + let framed = TCPFrameBuffer.frame(Data("hello".utf8)) + let prefixPlusPartialPayload = Data(framed.prefix(6)) + let remainingPayload = Data(framed.suffix(from: 6)) + XCTAssertTrue(try buffer.append(prefixPlusPartialPayload).isEmpty) + XCTAssertEqual(try buffer.append(remainingPayload), [Data("hello".utf8)]) + } + + /// The length prefix (4 bytes) itself arrives split into two chunks — the first chunk alone + /// isn't enough to know the frame length. + func testLengthPrefixItselfArrivesSplit() throws { + var buffer = TCPFrameBuffer() + let framed = TCPFrameBuffer.frame(Data("hello".utf8)) + let firstTwoBytes = Data(framed.prefix(2)) + let remainder = Data(framed.suffix(from: 2)) + XCTAssertTrue(try buffer.append(firstTwoBytes).isEmpty) + XCTAssertEqual(try buffer.append(remainder), [Data("hello".utf8)]) + } + + /// Verifies the next frame is cut correctly even when a frame was already consumed before this + /// subdata(in:)/removeSubrange(_:), so the buffer's internal index doesn't start at 0 — a + /// missing index offset would lead to a crash or corrupted data. + func testThirdFrameAfterTwoPriorFramesConsumed() throws { + var buffer = TCPFrameBuffer() + var combined = TCPFrameBuffer.frame(Data("a".utf8)) + combined.append(TCPFrameBuffer.frame(Data("bb".utf8))) + combined.append(TCPFrameBuffer.frame(Data("ccc".utf8))) + XCTAssertEqual(try buffer.append(combined), [Data("a".utf8), Data("bb".utf8), Data("ccc".utf8)]) + } + + /// R23 — a manipulated length prefix (a value exceeding the cap) must throw instead of causing + /// unbounded buffer growth. The peer is an arbitrary IP:port the user typed in themselves, so + /// this length value cannot be trusted. + func testLengthExceedingCapThrows() { + var buffer = TCPFrameBuffer() + var oversizedLength = UInt32(TCPFrameBuffer.maxFrameLength + 1).bigEndian + let malformedPrefix = Data(bytes: &oversizedLength, count: 4) + XCTAssertThrowsError(try buffer.append(malformedPrefix)) { error in + XCTAssertEqual(error as? TCPFrameBuffer.FrameError, + .frameTooLarge(length: TCPFrameBuffer.maxFrameLength + 1, limit: TCPFrameBuffer.maxFrameLength)) + } + } + + /// A length within the cap must pass normally — verifies R23's guard isn't overly strict. + func testLengthAtCapIsAccepted() throws { + var buffer = TCPFrameBuffer() + let payload = Data(repeating: 0, count: TCPFrameBuffer.maxFrameLength) + XCTAssertEqual(try buffer.append(TCPFrameBuffer.frame(payload)), [payload]) + } +} diff --git a/Tests/PokeTokenBarTests/TradeCommitTests.swift b/Tests/PokeTokenBarTests/TradeCommitTests.swift new file mode 100644 index 00000000..c8e90689 --- /dev/null +++ b/Tests/PokeTokenBarTests/TradeCommitTests.swift @@ -0,0 +1,225 @@ +import XCTest +@testable import PokeTokenBar + +private enum TradeCommitStubError: Error { case unavailable } + +private struct TradeCommitOfflineProvider: PokeProviding { + func line(baseSpeciesID: Int) async throws -> EvoLine { throw TradeCommitStubError.unavailable } + func baseSpeciesIndex() async throws -> [BaseSpecies] { throw TradeCommitStubError.unavailable } + func baseSpecies(id: Int) async throws -> BaseSpecies? { throw TradeCommitStubError.unavailable } +} + +@MainActor +final class TradeCommitTests: XCTestCase { + /// Seeds the store's persisted state the same way `DifficultySaveTests` does — encode a + /// `CompanionState` to the fixture file before construction — instead of a production-visible + /// setter that would let any app-target code bypass the backup guarantee this task establishes. + private func fixture(state: CompanionState = CompanionState()) throws -> (CompanionStore, URL) { + // Own subdirectory per test so a test that removes the state directory + // (to simulate a backup write failure) never touches the shared temp root. + let dir = FileManager.default.temporaryDirectory.appendingPathComponent("trade-commit-\(UUID())") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let url = dir.appendingPathComponent("companion-state.json") + try JSONEncoder().encode(state).write(to: url) + let defaults = try XCTUnwrap(UserDefaults(suiteName: "trade-commit-\(UUID())")) + let store = CompanionStore(provider: TradeCommitOfflineProvider(), fileURL: url, + dittoDisguiseRollingEnabled: false, defaults: defaults) + return (store, url) + } + + func testOverwriteWarningIsNilWhenReceivingDexEntry() throws { + let (store, _) = try fixture() + let entry = DexEntry(baseID: 1, finalID: 1, chainOrder: [1], rarity: .common, caughtAt: Date()) + XCTAssertNil(store.tradeOverwriteWarning(forReceiving: .dexEntry(entry))) + } + + func testOverwriteWarningIsNilWhenIHaveNoActiveMonAndNoEggProgress() throws { + let (store, _) = try fixture() + let mon = MonState(baseID: 4, pathIDs: [4], stageIndex: 0, usedAtStage: 0, rarity: .common, totalForms: 1) + XCTAssertNil(store.tradeOverwriteWarning(forReceiving: .activeMon(mon))) + } + + func testOverwriteWarningPresentWhenReceivingActiveMonWhileGrowingOne() throws { + var seed = CompanionState() + seed.active = MonState(baseID: 1, pathIDs: [1, 2], stageIndex: 0, usedAtStage: 100, + rarity: .common, totalForms: 2) + let (store, _) = try fixture(state: seed) + let mon = MonState(baseID: 4, pathIDs: [4], stageIndex: 0, usedAtStage: 0, rarity: .common, totalForms: 1) + XCTAssertNotNil(store.tradeOverwriteWarning(forReceiving: .activeMon(mon))) + } + + /// R17 — no active mon growing, but a paid egg guarantee (or hatch progress) would be lost. + func testOverwriteWarningPresentWhenReceivingActiveMonWouldLoseEggGuarantee() throws { + var seed = CompanionState() + seed.eggTier = .rare + let (store, _) = try fixture(state: seed) + let mon = MonState(baseID: 4, pathIDs: [4], stageIndex: 0, usedAtStage: 0, rarity: .common, totalForms: 1) + XCTAssertNotNil(store.tradeOverwriteWarning(forReceiving: .activeMon(mon))) + } + + /// R17 — same, but via egg-hatching progress rather than a purchased guarantee. + func testOverwriteWarningPresentWhenReceivingActiveMonWouldLoseEggProgress() throws { + var seed = CompanionState() + seed.eggUsage = 500 + let (store, _) = try fixture(state: seed) + let mon = MonState(baseID: 4, pathIDs: [4], stageIndex: 0, usedAtStage: 0, rarity: .common, totalForms: 1) + XCTAssertNotNil(store.tradeOverwriteWarning(forReceiving: .activeMon(mon))) + } + + func testApplyTradeCommitCreatesBackupBeforeMutatingState() throws { + let sent = DexEntry(baseID: 1, finalID: 1, chainOrder: [1], rarity: .common, caughtAt: Date()) + var seed = CompanionState() + seed.dex = [sent] + let (store, url) = try fixture(state: seed) + let received = DexEntry(baseID: 4, finalID: 4, chainOrder: [4], rarity: .common, caughtAt: Date()) + + let backupURL = try store.applyTradeCommit(sending: .dexEntry(sent), receiving: .dexEntry(received)) + + let dir = url.deletingLastPathComponent() + let backups = try FileManager.default.contentsOfDirectory(atPath: dir.path) + .filter { $0.hasPrefix(SaveTransfer.tradeBackupFilePrefix) } + XCTAssertEqual(backups.count, 1) + XCTAssertEqual(backupURL.deletingLastPathComponent(), dir) + + // R15 — the backup must be the PRE-trade snapshot, not merely "a backup exists". Decoding it + // and checking for the sent/received items proves ordering; a post-trade backup would pass + // the file-exists checks above just as well but recover nothing. + let backedUpState = try JSONDecoder().decode(CompanionState.self, from: Data(contentsOf: backupURL)) + XCTAssertTrue(backedUpState.dex.contains { $0.id == sent.id }) + XCTAssertFalse(backedUpState.dex.contains { $0.id == received.id }) + + XCTAssertFalse(store.state.dex.contains { $0.id == sent.id }) + XCTAssertTrue(store.state.dex.contains { $0.id == received.id }) + } + + func testApplyTradeCommitReplacingActiveMonClearsEggGuarantee() throws { + var seed = CompanionState() + seed.active = MonState(baseID: 1, pathIDs: [1], stageIndex: 0, usedAtStage: 0, + rarity: .common, totalForms: 1) + seed.eggTier = .rare + let (store, _) = try fixture(state: seed) + let received = MonState(baseID: 4, pathIDs: [4], stageIndex: 0, usedAtStage: 0, + rarity: .common, totalForms: 1) + let sent = DexEntry(baseID: 1, finalID: 1, chainOrder: [1], rarity: .common, caughtAt: Date()) + + try store.applyTradeCommit(sending: .dexEntry(sent), receiving: .activeMon(received)) + + XCTAssertEqual(store.state.active?.baseID, 4) + XCTAssertNil(store.state.eggTier) + } + + /// The give-away direction had no coverage at all — every other test sends a dex entry. + /// Giving away the active mon must clear it and leave the egg-guarantee fields consistent + /// (no guarantee/progress left dangling for the next free egg to inherit). + func testApplyTradeCommitGivingAwayActiveMonClearsActiveAndEggGuarantee() throws { + var seed = CompanionState() + seed.active = MonState(baseID: 1, pathIDs: [1], stageIndex: 0, usedAtStage: 0, + rarity: .common, totalForms: 1) + seed.eggTier = .rare + seed.pendingHatchID = 7 + seed.eggUsage = 250 + let (store, _) = try fixture(state: seed) + let sent = MonState(baseID: 1, pathIDs: [1], stageIndex: 0, usedAtStage: 0, + rarity: .common, totalForms: 1) + let received = DexEntry(baseID: 4, finalID: 4, chainOrder: [4], rarity: .common, caughtAt: Date()) + + try store.applyTradeCommit(sending: .activeMon(sent), receiving: .dexEntry(received)) + + XCTAssertNil(store.state.active) + XCTAssertNil(store.state.eggTier) + XCTAssertNil(store.state.pendingHatchID) + XCTAssertNil(store.state.pendingUnownForm) + XCTAssertEqual(store.state.eggUsage, 0) + } + + /// R14 — receiving an active mon while none is currently growing must flip the presentation + /// state to `.idle`; leaving it at `.egg` (its pre-trade value) would show an empty egg slot + /// even though `state.active` now holds a Pokémon. + func testApplyTradeCommitReceivingActiveMonUpdatesDisplayStateToIdle() throws { + let (store, _) = try fixture() + XCTAssertEqual(store.displayState, .egg) + let received = MonState(baseID: 4, pathIDs: [4], stageIndex: 0, usedAtStage: 0, + rarity: .common, totalForms: 1) + let sent = DexEntry(baseID: 1, finalID: 1, chainOrder: [1], rarity: .common, caughtAt: Date()) + + try store.applyTradeCommit(sending: .dexEntry(sent), receiving: .activeMon(received)) + + XCTAssertEqual(store.displayState, .idle) + } + + /// R14 — the mirror direction: giving away the active mon must flip presentation back to + /// `.egg`; leaving it at `.idle` would keep showing a Pokémon that no longer exists. + func testApplyTradeCommitGivingAwayActiveMonUpdatesDisplayStateToEgg() throws { + var seed = CompanionState() + seed.active = MonState(baseID: 1, pathIDs: [1], stageIndex: 0, usedAtStage: 0, + rarity: .common, totalForms: 1) + let (store, _) = try fixture(state: seed) + XCTAssertEqual(store.displayState, .idle) + let sent = MonState(baseID: 1, pathIDs: [1], stageIndex: 0, usedAtStage: 0, + rarity: .common, totalForms: 1) + let received = DexEntry(baseID: 4, finalID: 4, chainOrder: [4], rarity: .common, caughtAt: Date()) + + try store.applyTradeCommit(sending: .activeMon(sent), receiving: .dexEntry(received)) + + XCTAssertEqual(store.displayState, .egg) + } + + func testApplyTradeCommitAbortsWhenBackupCannotBeWritten() throws { + let sent = DexEntry(baseID: 1, finalID: 1, chainOrder: [1], rarity: .common, caughtAt: Date()) + var seed = CompanionState() + seed.dex = [sent] + let (store, url) = try fixture(state: seed) + let dir = url.deletingLastPathComponent() + let received = DexEntry(baseID: 4, finalID: 4, chainOrder: [4], rarity: .common, caughtAt: Date()) + + // Replace the state directory with a file so any backup write into it fails. + try FileManager.default.removeItem(at: dir) + try Data().write(to: dir) + defer { try? FileManager.default.removeItem(at: dir) } + + XCTAssertThrowsError(try store.applyTradeCommit(sending: .dexEntry(sent), receiving: .dexEntry(received))) { error in + XCTAssertEqual(error as? SaveTransferError, .backupFailed) + } + XCTAssertTrue(store.state.dex.contains { $0.id == sent.id }) + XCTAssertFalse(store.state.dex.contains { $0.id == received.id }) + } + + // MARK: I5 — if the same DexEntry.id ends up twice in the dex, a single exchange erases both. + + /// Because the spec allows a one-sided commit (covered by the backup), the same id can + /// legitimately exist on two devices, and receiving it back creates a duplicate id in my dex — + /// afterward removeAll { $0.id == } erases both. + func testReceivingAnEntryWhoseIdIsAlreadyInTheDexReissuesTheId() throws { + var seed = CompanionState() + let mine = DexEntry(baseID: 1, finalID: 1, chainOrder: [1], rarity: .common, caughtAt: Date()) + let sent = DexEntry(baseID: 7, finalID: 7, chainOrder: [7], rarity: .common, caughtAt: Date()) + seed.dex = [mine, sent] + let (store, _) = try fixture(state: seed) + + var returning = DexEntry(baseID: 4, finalID: 4, chainOrder: [4], rarity: .common, caughtAt: Date()) + returning.id = mine.id // simulates the same item, still held on the peer's device, coming back + try store.applyTradeCommit(sending: .dexEntry(sent), receiving: .dexEntry(returning)) + + XCTAssertEqual(store.state.dex.count, 2) + XCTAssertEqual(Set(store.state.dex.map(\.id)).count, 2, "a received entry must not share an id with an owned one") + } + + func testALaterTradeRemovesOnlyTheEntryItSent() throws { + var seed = CompanionState() + let mine = DexEntry(baseID: 1, finalID: 1, chainOrder: [1], rarity: .common, caughtAt: Date()) + let sent = DexEntry(baseID: 7, finalID: 7, chainOrder: [7], rarity: .common, caughtAt: Date()) + seed.dex = [mine, sent] + let (store, _) = try fixture(state: seed) + + var returning = DexEntry(baseID: 4, finalID: 4, chainOrder: [4], rarity: .common, caughtAt: Date()) + returning.id = mine.id + try store.applyTradeCommit(sending: .dexEntry(sent), receiving: .dexEntry(returning)) + + let laterOffer = try XCTUnwrap(store.state.dex.first { $0.id == mine.id }) + let other = DexEntry(baseID: 10, finalID: 10, chainOrder: [10], rarity: .common, caughtAt: Date()) + try store.applyTradeCommit(sending: .dexEntry(laterOffer), receiving: .dexEntry(other)) + + XCTAssertEqual(store.state.dex.count, 2, "one trade must not erase two owned Pokémon") + XCTAssertEqual(store.state.dex.filter { $0.baseID == 4 }.count, 1, "the earlier received entry must survive") + } +} diff --git a/Tests/PokeTokenBarTests/TradeIdentityTests.swift b/Tests/PokeTokenBarTests/TradeIdentityTests.swift new file mode 100644 index 00000000..3f82c18c --- /dev/null +++ b/Tests/PokeTokenBarTests/TradeIdentityTests.swift @@ -0,0 +1,40 @@ +import XCTest +@testable import PokeTokenBar + +final class TradeIdentityTests: XCTestCase { + private func freshDefaults() -> UserDefaults { + UserDefaults(suiteName: "TradeIdentityTests-\(UUID().uuidString)")! + } + + func testNicknameDefaultsToComputerNameWhenUnset() { + let defaults = freshDefaults() + XCTAssertFalse(TradeIdentity.nickname(defaults: defaults).isEmpty) + } + + func testSetNicknameOverridesDefault() { + let defaults = freshDefaults() + TradeIdentity.setNickname("테스트기기", defaults: defaults) + XCTAssertEqual(TradeIdentity.nickname(defaults: defaults), "테스트기기") + } + + func testSetNicknameToBlankClearsOverride() { + let defaults = freshDefaults() + TradeIdentity.setNickname("테스트기기", defaults: defaults) + TradeIdentity.setNickname(" ", defaults: defaults) + XCTAssertNotEqual(TradeIdentity.nickname(defaults: defaults), "테스트기기") + } + + func testCodeIsGeneratedOnceAndPersists() { + let defaults = freshDefaults() + let first = TradeIdentity.code(defaults: defaults) + let second = TradeIdentity.code(defaults: defaults) + XCTAssertEqual(first, second) + XCTAssertEqual(first.count, 8) + } + + func testCodeDiffersAcrossDefaultsInstances() { + let a = TradeIdentity.code(defaults: freshDefaults()) + let b = TradeIdentity.code(defaults: freshDefaults()) + XCTAssertNotEqual(a, b) + } +} diff --git a/Tests/PokeTokenBarTests/TradeItemTests.swift b/Tests/PokeTokenBarTests/TradeItemTests.swift new file mode 100644 index 00000000..823cb47a --- /dev/null +++ b/Tests/PokeTokenBarTests/TradeItemTests.swift @@ -0,0 +1,143 @@ +import XCTest +@testable import PokeTokenBar + +final class TradeItemTests: XCTestCase { + private func sampleMon(usedAtStage: Int = 0, totalForms: Int = 2) -> MonState { + MonState(baseID: 1, pathIDs: [1, 2], stageIndex: 0, usedAtStage: usedAtStage, + rarity: .common, totalForms: totalForms) + } + + private func sampleEntry() -> DexEntry { + DexEntry(baseID: 1, finalID: 1, chainOrder: [1], rarity: .common, caughtAt: Date()) + } + + /// A profile with an out-of-range IV — `sanitize()` clamps `ivs.hp` to 0...31, so 999 is an + /// observable witness that `sanitize()` actually ran (as opposed to merely being callable). + private func profileWithOutOfRangeIV() -> PokemonProfile { + var profile = PokemonProfile.generate(seed: 1) + profile.ivs.hp = 999 + return profile + } + + func testSanitizedClampsActiveMonUsedAtStageToZeroMinimum() { + var mon = sampleMon() + mon.usedAtStage = -5 + guard case .activeMon(let result) = TradeItem.activeMon(mon).sanitized() else { + return XCTFail("expected activeMon") + } + XCTAssertEqual(result.usedAtStage, 0) + } + + func testSanitizedClampsActiveMonUsedAtStageToUpperBound() { + var mon = sampleMon() + mon.usedAtStage = SaveTransfer.maxTokenValue + 1 + guard case .activeMon(let result) = TradeItem.activeMon(mon).sanitized() else { + return XCTFail("expected activeMon") + } + XCTAssertEqual(result.usedAtStage, SaveTransfer.maxTokenValue) + } + + func testSanitizedClampsTotalFormsUpperBound() { + let mon = sampleMon(totalForms: 999) + guard case .activeMon(let result) = TradeItem.activeMon(mon).sanitized() else { + return XCTFail("expected activeMon") + } + XCTAssertEqual(result.totalForms, 12) + } + + func testSanitizedClampsTotalFormsLowerBound() { + let mon = sampleMon(totalForms: 0) + guard case .activeMon(let result) = TradeItem.activeMon(mon).sanitized() else { + return XCTFail("expected activeMon") + } + XCTAssertEqual(result.totalForms, 1) + } + + func testSanitizedClampsStageIndexToZeroMinimum() { + var mon = sampleMon() + mon.stageIndex = -5 + guard case .activeMon(let result) = TradeItem.activeMon(mon).sanitized() else { + return XCTFail("expected activeMon") + } + XCTAssertEqual(result.stageIndex, 0) + } + + func testSanitizedClampsStageIndexToPathIDsUpperBound() { + var mon = sampleMon() + mon.stageIndex = 999 + guard case .activeMon(let result) = TradeItem.activeMon(mon).sanitized() else { + return XCTFail("expected activeMon") + } + XCTAssertEqual(result.stageIndex, mon.pathIDs.count - 1) + } + + func testSanitizedSanitizesActiveMonProfile() { + var mon = sampleMon() + mon.profile = profileWithOutOfRangeIV() + guard case .activeMon(let result) = TradeItem.activeMon(mon).sanitized() else { + return XCTFail("expected activeMon") + } + XCTAssertEqual(result.profile?.ivs.hp, 31) + } + + func testSanitizedPreservesDexEntryIdentity() { + let entry = sampleEntry() + guard case .dexEntry(let result) = TradeItem.dexEntry(entry).sanitized() else { + return XCTFail("expected dexEntry") + } + XCTAssertEqual(result.id, entry.id) + } + + func testSanitizedSanitizesDexEntryProfile() { + var entry = sampleEntry() + entry.profile = profileWithOutOfRangeIV() + guard case .dexEntry(let result) = TradeItem.dexEntry(entry).sanitized() else { + return XCTFail("expected dexEntry") + } + XCTAssertEqual(result.profile?.ivs.hp, 31) + } + + func testIsActiveMonDistinguishesCases() { + XCTAssertFalse(TradeItem.dexEntry(sampleEntry()).isActiveMon) + XCTAssertTrue(TradeItem.activeMon(sampleMon()).isActiveMon) + } + + func testRarityReadsThroughBothCases() { + XCTAssertEqual(TradeItem.dexEntry(sampleEntry()).rarity, .common) + XCTAssertEqual(TradeItem.activeMon(sampleMon()).rarity, .common) + } + + /// The proposal sheet identifies *which* Pokémon is leaving, and the peer's payload is all it + /// has — `DexEntry.names` travels with the entry, so the species name resolves without network. + func testDisplayNameResolvesDexEntryNameInSelectedLanguage() { + let entry = DexEntry(baseID: 1, finalID: 3, chainOrder: [1, 3], rarity: .common, + caughtAt: Date(), names: [3: ["ko": "이상해꽃", "en": "Venusaur"]]) + XCTAssertEqual(TradeItem.dexEntry(entry).displayName(language: .ko), "이상해꽃") + XCTAssertEqual(TradeItem.dexEntry(entry).displayName(language: .en), "Venusaur") + } + + /// Older saves (and any entry whose names never loaded) must still name something the user can + /// look up, rather than rendering an empty row next to an irreversible Accept button. + func testDisplayNameFallsBackToSpeciesIDWhenNamesAreMissing() { + var entry = sampleEntry() + entry.finalID = 25 + entry.names = nil + XCTAssertEqual(TradeItem.dexEntry(entry).displayName(language: .ko), "#25") + XCTAssertEqual(TradeItem.dexEntry(entry).displayName(language: .ja), "#25") + } + + /// An entry that carries names for other chain members but not the final species must not fall + /// through to a neighbour's name — the row would then claim the wrong Pokémon. + func testDisplayNameFallsBackWhenFinalSpeciesHasNoNames() { + let entry = DexEntry(baseID: 1, finalID: 3, chainOrder: [1, 3], rarity: .common, + caughtAt: Date(), names: [1: ["ko": "이상해씨", "en": "Bulbasaur"]]) + XCTAssertEqual(TradeItem.dexEntry(entry).displayName(language: .ko), "#3") + } + + /// `MonState` carries no name map, so the current species id is the honest answer. + func testDisplayNameUsesCurrentSpeciesIDForActiveMon() { + var mon = sampleMon() + mon.stageIndex = 1 + XCTAssertEqual(TradeItem.activeMon(mon).displayName(language: .en), "#2") + } +} diff --git a/Tests/PokeTokenBarTests/TradeMessageTests.swift b/Tests/PokeTokenBarTests/TradeMessageTests.swift new file mode 100644 index 00000000..397f6543 --- /dev/null +++ b/Tests/PokeTokenBarTests/TradeMessageTests.swift @@ -0,0 +1,48 @@ +import XCTest +@testable import PokeTokenBar + +final class TradeMessageTests: XCTestCase { + private func roundTrip(_ message: TradeMessage) throws -> TradeMessage { + let data = try JSONEncoder().encode(message) + return try JSONDecoder().decode(TradeMessage.self, from: data) + } + + func testHelloRoundTrips() throws { + guard case .hello(let nickname, let code) = try roundTrip(.hello(nickname: "테스트", code: "ABCD1234")) else { + return XCTFail("expected hello") + } + XCTAssertEqual(nickname, "테스트") + XCTAssertEqual(code, "ABCD1234") + } + + func testOfferRoundTripsDexEntry() throws { + let entry = DexEntry(baseID: 1, finalID: 1, chainOrder: [1], rarity: .common, caughtAt: Date()) + guard case .offer(.dexEntry(let result)) = try roundTrip(.offer(.dexEntry(entry))) else { + return XCTFail("expected offer(.dexEntry)") + } + XCTAssertEqual(result.id, entry.id) + } + + func testRejectRoundTripsReason() throws { + guard case .reject(let reason) = try roundTrip(.reject(reason: "마음이 바뀜")) else { + return XCTFail("expected reject") + } + XCTAssertEqual(reason, "마음이 바뀜") + } + + func testCommitAndCommitAckRoundTripNonce() throws { + guard case .commit(let nonce) = try roundTrip(.commit(nonce: "abc-123")) else { + return XCTFail("expected commit") + } + XCTAssertEqual(nonce, "abc-123") + guard case .commitAck(let ackNonce) = try roundTrip(.commitAck(nonce: "abc-123")) else { + return XCTFail("expected commitAck") + } + XCTAssertEqual(ackNonce, "abc-123") + } + + func testOfferWithdrawnAndAcceptRoundTripWithNoPayload() throws { + guard case .offerWithdrawn = try roundTrip(.offerWithdrawn) else { return XCTFail("expected offerWithdrawn") } + guard case .accept = try roundTrip(.accept) else { return XCTFail("expected accept") } + } +} diff --git a/Tests/PokeTokenBarTests/TradeSessionTests.swift b/Tests/PokeTokenBarTests/TradeSessionTests.swift new file mode 100644 index 00000000..3dd63b31 --- /dev/null +++ b/Tests/PokeTokenBarTests/TradeSessionTests.swift @@ -0,0 +1,380 @@ +import XCTest +@testable import PokeTokenBar + +// MARK: Stubs (this file only) + +/// A fake transport that connects two TradeSessions directly, without real networking. +private final class InMemoryTradeTransport: TradeTransport, @unchecked Sendable { + var onPeerFound: (@Sendable (TradePeer) -> Void)? + var onPeerLost: (@Sendable (String) -> Void)? + var onConnected: (@Sendable () -> Void)? + var onDisconnected: (@Sendable () -> Void)? + var onMessageReceived: (@Sendable (TradeMessage) -> Void)? + weak var peer: InMemoryTradeTransport? + + func startDiscovery() {} + func stopDiscovery() {} + func connect(to peer: TradePeer) throws {} + + func send(_ message: TradeMessage) throws { + guard let peer else { throw TradeTransportError.notConnected } + peer.onMessageReceived?(message) + } + + /// Both real transport implementations fire onDisconnected on the disconnecting side too + /// (MCSession's .notConnected, ManualTradeTransport.disconnect) — this stub, which used to + /// notify only the peer, was unrealistic to that extent. + func disconnect() { + guard let disconnectedPeer = peer else { return } + peer = nil + disconnectedPeer.peer = nil + disconnectedPeer.onDisconnected?() + onDisconnected?() + } +} + +/// Waits until a polling condition becomes true — for verifying callbacks delivered +/// asynchronously via Task { @MainActor in ... }. +@MainActor +private func waitUntil(timeout: TimeInterval = 1, _ condition: @escaping () -> Bool) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if condition() { return true } + try? await Task.sleep(nanoseconds: 1_000_000) + } + return condition() +} + +/// A throwaway defaults suite per session. Connecting sends a hello, and building that hello +/// generates and stores a trade code — on `.standard` that write would land in the real user +/// domain and survive the test run. +private func isolatedDefaults() -> UserDefaults { + UserDefaults(suiteName: "TradeSessionTests-\(UUID().uuidString)")! +} + +@MainActor +private func makeConnectedSessions() -> (TradeSession, TradeSession) { + let transportA = InMemoryTradeTransport() + let transportB = InMemoryTradeTransport() + transportA.peer = transportB + transportB.peer = transportA + // Separate suites: two devices never share an identity, so their codes must differ. + let sessionA = TradeSession(transport: transportA, defaults: isolatedDefaults()) + let sessionB = TradeSession(transport: transportB, defaults: isolatedDefaults()) + transportA.onConnected?() + transportB.onConnected?() + return (sessionA, sessionB) +} + +private func sampleEntry(baseID: Int) -> DexEntry { + DexEntry(baseID: baseID, finalID: baseID, chainOrder: [baseID], rarity: .common, caughtAt: Date()) +} + +@MainActor +final class TradeSessionTests: XCTestCase { + func testMutualOfferAndAcceptLeadsToCompletion() async { + let (sessionA, sessionB) = makeConnectedSessions() + var receivedByA: TradeItem? + var receivedByB: TradeItem? + var completedA = false + var completedB = false + sessionA.onReadyToCommit = { receivedByA = $0 } + sessionB.onReadyToCommit = { receivedByB = $0 } + sessionA.onCompleted = { completedA = true } + sessionB.onCompleted = { completedB = true } + + let entryA = sampleEntry(baseID: 1) + let entryB = sampleEntry(baseID: 4) + sessionA.proposeOffer(.dexEntry(entryA)) + sessionB.proposeOffer(.dexEntry(entryB)) + // The real accept button is only pressed after the offer actually arrives (async delivery), + // so wait until both offers have arrived — otherwise a late-arriving .offer would invalidate + // an accept that was already sent. + let bothOffersArrived = await waitUntil { sessionA.theirOffer != nil && sessionB.theirOffer != nil } + XCTAssertTrue(bothOffersArrived) + sessionA.accept() + sessionB.accept() + + let readyToCommit = await waitUntil { receivedByA != nil && receivedByB != nil } + XCTAssertTrue(readyToCommit, "both sides should reach onReadyToCommit") + + guard case .dexEntry(let gotByA) = receivedByA else { return XCTFail("A should receive B's offer") } + guard case .dexEntry(let gotByB) = receivedByB else { return XCTFail("B should receive A's offer") } + XCTAssertEqual(gotByA.id, entryB.id) + XCTAssertEqual(gotByB.id, entryA.id) + + sessionA.confirmLocalCommit() + try? await Task.sleep(nanoseconds: 20_000_000) + XCTAssertFalse(completedA) // the peer's commitAck hasn't arrived yet + sessionB.confirmLocalCommit() + let bothCompleted = await waitUntil { completedA && completedB } + XCTAssertTrue(bothCompleted) + } + + func testRejectStopsSessionBeforeCommit() async { + // Both sides made an offer and A even pressed Accept, but if B sends Reject instead of + // Accept, neither side must reach commit even though commit would otherwise have been possible. + let (sessionA, sessionB) = makeConnectedSessions() + var rejectedReason: String? + var commitFiredA = false + var commitFiredB = false + sessionA.onRejected = { rejectedReason = $0 } + sessionA.onReadyToCommit = { _ in commitFiredA = true } + sessionB.onReadyToCommit = { _ in commitFiredB = true } + + sessionA.proposeOffer(.dexEntry(sampleEntry(baseID: 1))) + sessionB.proposeOffer(.dexEntry(sampleEntry(baseID: 2))) + let bothOffered = await waitUntil { sessionA.theirOffer != nil && sessionB.theirOffer != nil } + XCTAssertTrue(bothOffered, "both offers should have been exchanged before reject") + + sessionA.accept() + sessionB.reject(reason: "마음이 바뀜") + + let rejected = await waitUntil { rejectedReason != nil } + XCTAssertTrue(rejected) + XCTAssertEqual(rejectedReason, "마음이 바뀜") + XCTAssertFalse(commitFiredA) + XCTAssertFalse(commitFiredB) + } + + func testAcceptWithoutBothOffersDoesNotCommit() async { + // onReadyToCommit must be attached on both sides — a defect where B fires commit (without + // its own offer) after sending only Accept without an offer would be hidden if we only + // watched A's side. + let (sessionA, sessionB) = makeConnectedSessions() + var commitFiredA = false + var commitFiredB = false + sessionA.onReadyToCommit = { _ in commitFiredA = true } + sessionB.onReadyToCommit = { _ in commitFiredB = true } + + sessionA.proposeOffer(.dexEntry(sampleEntry(baseID: 1))) + sessionA.accept() + sessionB.accept() // B hasn't sent its own offer yet + + try? await Task.sleep(nanoseconds: 50_000_000) + XCTAssertFalse(commitFiredA) + XCTAssertFalse(commitFiredB) + } + + func testOfferChangeAfterAcceptInvalidatesStaleAccept() async { + // A offers X, B accepts X, then A withdraws X and offers Y. B's earlier Accept was + // consent to X, not Y — it must not silently carry over and let B commit into + // receiving something it never agreed to. + let (sessionA, sessionB) = makeConnectedSessions() + var commitFiredB = false + sessionB.onReadyToCommit = { _ in commitFiredB = true } + + sessionB.proposeOffer(.dexEntry(sampleEntry(baseID: 9))) + sessionA.proposeOffer(.dexEntry(sampleEntry(baseID: 1))) // X + let firstOfferSeen = await waitUntil { + if case .dexEntry(let entry) = sessionB.theirOffer { return entry.baseID == 1 } + return false + } + XCTAssertTrue(firstOfferSeen) + + sessionB.accept() + + sessionA.withdrawOffer() + sessionA.proposeOffer(.dexEntry(sampleEntry(baseID: 2))) // Y + let secondOfferSeen = await waitUntil { + if case .dexEntry(let entry) = sessionB.theirOffer { return entry.baseID == 2 } + return false + } + XCTAssertTrue(secondOfferSeen) + + sessionA.accept() + try? await Task.sleep(nanoseconds: 50_000_000) + XCTAssertFalse(commitFiredB, "B's stale accept of X must not carry over to Y") + } + + func testDuplicateCommitAckDoesNotRefireCompletion() async { + let (sessionA, sessionB) = makeConnectedSessions() + var completedCountA = 0 + var commitFiredA = false + var commitFiredB = false + sessionA.onCompleted = { completedCountA += 1 } + sessionA.onReadyToCommit = { _ in commitFiredA = true } + sessionB.onReadyToCommit = { _ in commitFiredB = true } + + sessionA.proposeOffer(.dexEntry(sampleEntry(baseID: 1))) + sessionB.proposeOffer(.dexEntry(sampleEntry(baseID: 2))) + // If accept() is called before the offer arrives, the late-arriving .offer invalidates that + // accept and commit is never reached — then, despite its name, this test would never exercise + // the "duplicate ack" path. + let bothReady = await waitUntil { sessionA.theirOffer != nil && sessionB.theirOffer != nil } + XCTAssertTrue(bothReady) + sessionA.accept() + sessionB.accept() + let bothCommitted = await waitUntil { commitFiredA && commitFiredB } + XCTAssertTrue(bothCommitted, "the duplicate-ack path is only reachable after a real commit") + + sessionA.confirmLocalCommit() + sessionB.confirmLocalCommit() + let completedOnce = await waitUntil { completedCountA == 1 } + XCTAssertTrue(completedOnce) + + // Simulate a duplicate commitAck — even if B resends its ack, A's completion must not refire. + sessionB.confirmLocalCommit() + try? await Task.sleep(nanoseconds: 50_000_000) + XCTAssertEqual(completedCountA, 1, "duplicate commitAck must not refire onCompleted") + } + + /// Regression guard: the hello a session sends must come from the defaults it was handed. + /// Before injection, `sendHello` read `TradeIdentity` with its `.standard` default, so merely + /// connecting two stub transports in a test generated and persisted a `tradeCode` in the real + /// user domain. Asserting the peer's code equals the injected suite's code is what fails there — + /// `.standard` would hand over a different (or pre-existing) code. + func testHelloUsesTheInjectedDefaultsAndLeavesStandardUntouched() async { + let standardCodeBefore = UserDefaults.standard.string(forKey: "tradeCode") + let defaultsB = isolatedDefaults() + let transportA = InMemoryTradeTransport() + let transportB = InMemoryTradeTransport() + transportA.peer = transportB + transportB.peer = transportA + let sessionA = TradeSession(transport: transportA, defaults: isolatedDefaults()) + let sessionB = TradeSession(transport: transportB, defaults: defaultsB) + transportB.onConnected?() + + let identified = await waitUntil { sessionA.peerIdentity != nil } + XCTAssertTrue(identified) + XCTAssertEqual(sessionA.peerIdentity?.code, TradeIdentity.code(defaults: defaultsB)) + XCTAssertEqual(UserDefaults.standard.string(forKey: "tradeCode"), standardCodeBefore, + "a trade session must not write an identity into the real user domain") + _ = sessionB + } + + func testPeerIdentifiedFiresFromHelloExchangedOnConnect() async { + // sessionB must stay alive until its deferred onConnected Task runs and sends + // its hello — discarding it into `_` would let ARC free it (and its transport) + // before that Task fires, so A would never receive B's hello. + let (sessionA, sessionB) = makeConnectedSessions() + let identified = await waitUntil { sessionA.peerIdentity != nil } + XCTAssertTrue(identified) + _ = sessionB + } + + /// The shape `TradeView.onReadyToCommit` originally had: a callback the session stores, capturing + /// the session strongly. That is a self-retain cycle, so dropping every outside reference leaks + /// the session *and* the transport it owns — and the leaked callbacks keep firing into the view + /// that let it go. Documented here because the leak is invisible at the call site. + func testStronglySelfCapturingCallbackLeaksTheSession() { + weak var leaked: TradeSession? + do { + let session = TradeSession(transport: InMemoryTradeTransport(), defaults: isolatedDefaults()) + leaked = session + session.onCompleted = { _ = session.myOffer } + } + XCTAssertNotNil(leaked, "a strongly self-capturing callback keeps the session alive forever") + + leaked?.onCompleted = nil + XCTAssertNil(leaked, "clearing the callback breaks the cycle") + } + + /// Regression guard for the fix: capturing the session weakly lets it deallocate as soon as its + /// owner drops it, which also releases the transport underneath. + func testWeaklySelfCapturingCallbackDoesNotRetainTheSession() { + weak var observed: TradeSession? + do { + let session = TradeSession(transport: InMemoryTradeTransport(), defaults: isolatedDefaults()) + observed = session + session.onCompleted = { [weak session] in _ = session?.myOffer } + } + XCTAssertNil(observed, "weak capture must let the session deallocate once its owner drops it") + } + + func testWithdrawnOfferNotifiesTheReviewingSide() async { + // If the peer withdraws its offer, the snapshot left on the review screen promises an item + // that no longer exists — that screen's approve button is blocked by commitIfBothAccepted's + // theirOffer guard and does nothing. + let (sessionA, sessionB) = makeConnectedSessions() + var withdrawnSeenByB = false + var commitFiredB = false + sessionB.onOfferWithdrawn = { withdrawnSeenByB = true } + sessionB.onReadyToCommit = { _ in commitFiredB = true } + + sessionB.proposeOffer(.dexEntry(sampleEntry(baseID: 9))) + sessionA.proposeOffer(.dexEntry(sampleEntry(baseID: 1))) + let offerSeen = await waitUntil { sessionB.theirOffer != nil } + XCTAssertTrue(offerSeen) + + sessionA.withdrawOffer() + let withdrawn = await waitUntil { withdrawnSeenByB } + XCTAssertTrue(withdrawn, "the reviewing side must be told the offer is gone") + XCTAssertNil(sessionB.theirOffer) + + sessionB.accept() + try? await Task.sleep(nanoseconds: 50_000_000) + XCTAssertFalse(commitFiredB, "accepting a withdrawn offer must not commit") + } + + func testWithdrawnOfferKeepsTheLocalOffer() async { + // My own offer stays put even when the peer withdraws theirs — forcing me to pick again + // would push the screen back right before approval. + let (sessionA, sessionB) = makeConnectedSessions() + sessionB.proposeOffer(.dexEntry(sampleEntry(baseID: 9))) + sessionA.proposeOffer(.dexEntry(sampleEntry(baseID: 1))) + let offerSeen = await waitUntil { sessionB.theirOffer != nil } + XCTAssertTrue(offerSeen) + + sessionA.withdrawOffer() + let withdrawn = await waitUntil { sessionB.theirOffer == nil } + XCTAssertTrue(withdrawn) + XCTAssertNotNil(sessionB.myOffer) + } + + func testDisconnectBeforeCommitNotifiesBothSidesAndCommitsNothing() async { + // The "mid-session disconnect" case required by the spec's test strategy — if the connection + // drops after offers have been exchanged, neither side must reach commit, and both sides must + // be notified of the disconnect so the screen doesn't hang. + let (sessionA, sessionB) = makeConnectedSessions() + var disconnectedA = false + var disconnectedB = false + var commitFiredA = false + var commitFiredB = false + sessionA.onDisconnected = { disconnectedA = true } + sessionB.onDisconnected = { disconnectedB = true } + sessionA.onReadyToCommit = { _ in commitFiredA = true } + sessionB.onReadyToCommit = { _ in commitFiredB = true } + + sessionA.proposeOffer(.dexEntry(sampleEntry(baseID: 1))) + sessionB.proposeOffer(.dexEntry(sampleEntry(baseID: 2))) + let bothOffered = await waitUntil { sessionA.theirOffer != nil && sessionB.theirOffer != nil } + XCTAssertTrue(bothOffered) + + sessionA.accept() // A even accepted, but the connection drops before B's accept + sessionB.disconnect() + + let bothNotified = await waitUntil { disconnectedA && disconnectedB } + XCTAssertTrue(bothNotified, "both sides must learn the connection is gone") + XCTAssertFalse(commitFiredA) + XCTAssertFalse(commitFiredB) + } + + func testAcceptAfterDisconnectDoesNotCommit() async { + // The path where the accept button is still on screen after disconnect and gets pressed — + // since send fails, myAcceptSent never gets set, so commit must not be reached (otherwise + // only my save would change while the peer received nothing). + let (sessionA, sessionB) = makeConnectedSessions() + var commitFiredA = false + sessionA.onReadyToCommit = { _ in commitFiredA = true } + + sessionA.proposeOffer(.dexEntry(sampleEntry(baseID: 1))) + sessionB.proposeOffer(.dexEntry(sampleEntry(baseID: 2))) + let bothOffered = await waitUntil { sessionA.theirOffer != nil && sessionB.theirOffer != nil } + XCTAssertTrue(bothOffered) + // The disconnect must happen **after** the peer's accept has actually arrived for this test + // to exercise the path its name describes — waiting on a condition that's already true + // (theirOffer != nil) would pass even if the accept never arrived, making it impossible to + // tell "passed because commit was never possible" apart from "blocked by the disconnect." + sessionB.accept() + let theirAcceptArrived = await waitUntil { sessionA.theirAcceptReceived } + XCTAssertTrue(theirAcceptArrived, "B's accept must reach A before the disconnect") + + // At this point A has myOffer, theirOffer, and theirAcceptReceived all set, and hasn't + // committed yet — the only thing blocking commit is the failed accept send. + sessionA.disconnect() + sessionA.accept() + try? await Task.sleep(nanoseconds: 50_000_000) + XCTAssertFalse(commitFiredA, "a failed accept send must not reach commit") + } +} diff --git a/docs/reference/defect-log.md b/docs/reference/defect-log.md index 06c91bb6..7b9ea725 100644 --- a/docs/reference/defect-log.md +++ b/docs/reference/defect-log.md @@ -7,6 +7,7 @@ read_when: - 메뉴바·플로팅 펫 등 상시 표시 애니메이션의 성능을 손볼 때 - 스프라이트·이미지를 고정 크기 프레임에 그릴 때(비율 왜곡 부류) - 세이브 이전/병합·외부 파일 입력 경로를 만들 때 + - 뷰가 네트워크 연결·타이머·세션 같은 외부 자원을 @State 로 쥘 때 --- # 결함 대응 축적 규칙 @@ -669,6 +670,17 @@ read_when: 슬립/런치 직후 refresh 완료 전 몇 초간 메뉴바가 회색이 돼 '고장/비활성'으로 오인된다(사용자 반복 지적, `&& lastUpdated != nil` 로 런치만 막는 건 슬립-후 stale 을 못 막음). '오래됨' 신호는 팝오버에서만. - **UI 변경 → 스크린샷 stale** 은 `release.sh` 가 자동 경고(`CLAUDE.md` §릴리스) — 통과의례화 방지. +- **`ScrollViewReader.scrollTo` 뒤에 거는 포커스는 한 번의 고정 지연에 걸어선 안 된다.** 세션 키 만료 + 안내로 들어온 Settings 는 `advancedExpanded = true` 로 숨은 행을 펼친 뒤 `proxy.scrollTo(anchor: .top)` + 로 그 행을 뷰포트 위로 올리고 포커스를 준다 — 펼침이 실제로 레이아웃을 치기 전에 스크롤을 계산하면 + 옛(접힌) 기하로 앵커를 잡아 목표 필드가 뷰포트 밖에 남는다. 이전 코드는 이 순서를 "펼침 → 80ms 대기 + → 애니메이션 스크롤" 한 번의 도박으로 처리했는데, 이 80ms는 같은 프레임에서 SwiftUI 가 처리해야 할 + 뷰 트리 크기에 반비례하는 여유일 뿐 보장이 아니다 — 무역 신원 섹션(`tradeIdentityGroup`) 하나가 Settings + 본문에 추가되자 이 여유가 특정 환경(호스팅 CI)에서 소진돼 `SessionKeySettingsRenderingTests`가 깨졌다. + Settings 에 행 하나가 늘 때마다 이 지연이 다시 부족해질 수 있으므로, 고정 지연 한 번이 아니라 + **여러 런루프 턴에 걸쳐 스크롤을 반복**한다 — 이른 시도가 옛 기하를 잡아도 늦은 시도가 정착된 기하로 + 덮어써 자기 교정된다. 새 Settings 섹션을 앵커보다 앞에 추가할 때 이 반복 스크롤을 다시 한 번짜리로 + 되돌리지 않는다. - **번역 가드는 "이미 표에 있는 문구"만 본다 — 표에 *못 들어간* 문구는 구조상 안 보인다.** `LocalizationInterpolationTests` 는 `L(lang)` 을 거친 문자열의 `\(...)` 보간이 언어마다 살아남는지 검사한다. 그래서 `Text("Antigravity 세션 갱신 필요")` 처럼 애초에 `L` 을 안 거친 리터럴은 통과한다 — @@ -823,3 +835,69 @@ read_when: 자동 업데이트 시 앱 종료를 기다릴 때 `pgrep -x PokeTokenBar`를 쓰면, 중복 인스턴스가 살아있는 동안 루프를 결코 빠져나오지 못하고 20초 타임아웃을 온전히 소모한다(#175). `ProcessInfo.processInfo.processIdentifier`로 종료 대상 프로세스 PID를 전달하고 `kill -0 "$3"`로 특정 프로세스의 종료를 대기한다. + +## 뷰 수명과 외부 자원 + +- **탭 전환은 뷰를 파괴하지만, 뷰가 심은 클로저는 그 뷰의 `@State` 상자를 계속 붙잡는다.** SwiftUI + 뷰는 값 타입이라 "뷰가 사라지면 그 안의 것도 사라진다" 는 직관이 통하지 않는다 — 콜백이 뷰 구조체 + 복사본을 캡처하면 그 복사본이 `@State` 상자를 붙잡고, 상자가 세션과 트랜스포트를 붙잡아 화면 없이 + 살아남는다. `PopoverView` 의 `else if nav.tab == .trade { TradeView(store:) }` 는 탭을 옮기는 순간 + `TradeView` 를 트리에서 걷어내는데, `TradeView` 에 `.onDisappear` 가 없어 고아가 된 `TradeSession` 이 + 상대의 `.accept` 를 계속 받아 `onReadyToCommit` → `CompanionStore.applyTradeCommit` 까지 실행했다. + **누수가 아니라 세이브 변경이 문제다** — 사용자가 다른 탭을 보는 사이 되돌릴 수 없는 변경이 일어나고, + 그 백업 경로는 버려진 상자에 적혀 영영 화면에 안 뜬다. 게다가 탭으로 돌아오면 새 상자가 `.idle` 로 + 시작해, 아직 연결·광고 중인 고아 위에 두 번째 세션이 겹친다. + 규율: **뷰가 네트워크 연결·타이머·`Task`·세션을 `@State` 로 쥐면 `.onDisappear` 에서 반드시 놓는다.** + 해제는 참조를 버리기 전에 **콜백부터 nil 로** 끊어야 한다(`TradeView.releaseSession`) — 끊는 순서가 + 반대면 해제 도중 도착한 마지막 메시지가 여전히 커밋 경로를 탄다. + 부류 스윕(2026-09-21): `Sources/PokeTokenBar/UI` 전수 — 외부 자원을 `@State` 로 쥔 뷰는 + `SettingsView`(`customScanMatchTask`, `.onDisappear` 로 cancel 함)와 `TradeView` 둘뿐이었고, + `UI/` 밖에는 SwiftUI 뷰가 없다. `TradeView` 만 결함이었다. + **테스트가 못 걸렀던 이유**: 이 저장소는 SwiftUI 뷰를 단위 테스트하지 않는다(명세의 테스트 전략도 + "UI 는 수동 확인" 으로 둔다). 그래서 `TradeSession` 커버리지가 아무리 높아도 **누가 세션을 놓는가** + 라는 소유권 질문은 어느 테스트도 묻지 않는다. Core 계층에 남길 수 있는 가드는 + `TradeSessionTests.testWeaklySelfCapturingCallbackDoesNotRetainTheSession` 처럼 "콜백이 세션을 붙잡지 + 않는가" 까지이고, "뷰가 사라질 때 놓는가" 는 수동 QA 항목으로만 남는다 — 그러니 이 부류는 테스트가 + 아니라 **코드 리뷰 체크 항목**으로 막는다: 새 뷰가 위 네 부류 중 하나를 `@State` 로 선언하면 + `.onDisappear` 를 같은 diff 에서 확인한다. + +## 리뷰·프로세스 + +- **"기존에 알던 flaky"는 주장이 아니라 2분짜리 검증이다.** `SessionKeySettingsRenderingTests` 가 + p2p-trade 브랜치에서 깨지자 "브랜치 전체의 기존 flaky 테스트"로 핸드오프됐고, 그 판단만으로 다음 + 구현자들에게 그대로 넘어가 방치됐다. 실제로는 브랜치 시작 커밋(`a910767`)에서 3/3 통과, 브랜치 + HEAD 에서 3/3 실패, `tradeIdentityGroup` 한 줄을 빼면 다시 3/3 통과 — 브랜치가 만든 회귀였다. + **단, 이 3/3 실패는 머신이 바쁠 때(백그라운드 작업 다수)만 재현된다** — 유휴 상태에서는 같은 커밋이 + 통과한다. 한가할 때 한 번 돌려 보고 "재현 안 되니 역시 flaky"로 결론내면 같은 함정에 두 번 빠진다. + 고정 지연에 건 타이밍 결함은 여유가 줄었을 뿐 사라진 게 아니고, 부하가 남은 여유를 마저 먹을 뿐이다. + **왜 못 걸렀나:** "pre-existing" 판단에 필요한 검증 — 같은 테스트를 브랜치 시작점에 임시 워크트리로 + 체크아웃해 돌려 보는 것 — 이 실제로 실행되지 않았다. 실행에 2분이 안 걸리는 이 확인을 생략하고 이전 + 실패 이력이나 "SwiftUI 레이아웃 테스트는 원래 불안정하다"는 일반화에 근거해 결론을 내렸다. **영구 + 캡처:** 어떤 실패 테스트를 "이 브랜치와 무관한 pre-existing/flaky"로 분류하려면, 그 분류를 다음 사람에게 + 넘기기 전에 반드시 (1) 브랜치가 갈라진 지점에서 같은 테스트를 3회 이상 돌려 통과를 확인하고 (2) 그 + 결과(커밋 SHA·통과 횟수)를 핸드오프 메모에 남긴다. "이전에도 실패했다"는 기억이나 인상은 검증이 + 아니다 — 검증 없이 내려진 flaky 판정은 그 뒤로 이어지는 모든 세션에서 실제 회귀를 숨긴다. + +## 테스트 격리 (영구 저장소) + +- **주입 파라미터를 만들어 두는 것만으로는 격리되지 않는다 — 프로덕션 호출부가 기본 인자를 쓰면 + 테스트가 실제 사용자 도메인에 쓴다.** `TradeIdentity` 는 처음부터 `defaults: UserDefaults = .standard` + 를 받았고 `TradeIdentityTests` 도 `UserDefaults(suiteName:)` 로 격리했다. 그런데 `TradeSession.sendHello()` + 가 인자 없이 `TradeIdentity.code()` 를 불렀고, `TradeIdentity.code()` 는 **읽기가 아니라 쓰기다** + (없으면 8자리 코드를 생성해 저장한다). `TradeSessionTests` 는 `transportA.onConnected?()` 를 직접 + 호출해 hello 교환을 검증하므로, 테스트를 한 번 돌릴 때마다 실제 `.standard` 도메인에 `tradeCode` 가 + 생겨 디스크에 영구히 남았다. 규율: **기본 인자로 `.standard` 를 두는 API 라도, 테스트가 도달하는 + 프로덕션 경로에서는 저장소를 명시적으로 넘긴다.** `TradeSession.init(transport:defaults:)` 로 세션이 + 신원 저장소를 들고 있게 하고, 뷰(`TradeView`/`SettingsView`)는 앱 런타임 전용이라 `.standard` 기본값을 + 그대로 둔다 — 이 저장소는 SwiftUI 뷰를 단위 테스트하지 않으므로 뷰 호출부는 오염원이 아니다. + **테스트가 못 걸렀던 이유**: 격리 테스트가 `TradeIdentity` 자신에게만 있었다. 격리는 API 의 성질이 + 아니라 *호출부*의 성질인데, 단위 테스트는 자기가 넘긴 suite 만 보므로 "다른 테스트가 기본 인자로 + 부르고 있는가" 를 절대 묻지 못한다 — 통과가 곧 false confidence 였다. + 부류 스윕(2026-09-21): `Sources` 전수에서 `UserDefaults.standard` 를 직접 쓰는 곳은 + `KeychainAccess`(`disableKeychainAccess` 읽기), `BinaryLocator`(`Path` 읽기), + `CompanionStore.swift:1114`(`companionNotifications` 읽기) 뿐이고 **모두 읽기**라 오염이 없다. + 주입 기본 인자를 생략해 부르는 프로덕션 호출부 중 *쓰기* 에 닿는 것은 `TradeSession` 하나였다. + 회귀 가드: `TradeSessionTests.testHelloUsesTheInjectedDefaultsAndLeavesStandardUntouched` — + 상대가 받은 코드가 **주입한 suite 의 코드와 같은지**를 본다(`.standard` 로 되돌리면 다른 코드가 와서 + 실패한다. 결함 주입으로 확인함). `.standard` 의 `tradeCode` 가 전후로 안 변하는지도 함께 본다 — + 다만 이미 값이 있으면 이 단정만으로는 못 잡으므로, 실패를 책임지는 쪽은 앞의 등가 단정이다. diff --git a/scripts/build-app.sh b/scripts/build-app.sh index 1f9db28e..a7675862 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -34,6 +34,13 @@ cat > "$APP/Contents/Info.plist" <CFBundleIconFileAppIcon LSUIElement NSHighResolutionCapable + NSLocalNetworkUsageDescription + Local network access is required to trade Pokémon with nearby devices. + NSBonjourServices + + _ptb-trade._tcp + _ptb-trade._udp + PLIST