import { DEFAULT_GLOBAL_SETTINGS } from '../constants/global-settings.js'; import { matchIntroductionDuration, matchIntroductionMessage } from '../src/shared/match-introduction.js'; import assert from 'node:assert/strict'; import test from 'node:test'; import type { GameApi } from '../src/client/api.js'; import { createLobbyFeatures } from '../src/client/lobby-features.js'; import { normalizeAppearance, type FighterAppearance } from '../src/shared/cosmetics.js'; import type { PlayerProfile } from '../src/shared/ranked-types.js'; import { createMatchEngine } from '../src/shared/match-engine.js'; import { RANKED_POLL_MS } from '../constants/ranked.js'; // A selector/event surface, not an HTML parser or rendering implementation. // The test invokes the production selection/equip handlers and observes the // appearance callback used by main.ts to display the equipped character. function documentSurface() { const ids = new Map(); class Element { nodes = new Map(); listeners = new Map void>(); classList = { remove() {} }; disabled = false; textContent = ''; innerHTML = ''; value = ''; onclick?: () => void | Promise; set id(id: string) { ids.set(id, this); } querySelector(selector: string): Element { if (!this.nodes.has(selector)) this.nodes.set(selector, new Element()); return this.nodes.get(selector)!; } addEventListener(name: string, listener: (event: any) => void) { this.listeners.set(name, listener); } prepend() {} after() {} append() {} showModal() {} close() {} } const document = { body: new Element(), createElement: () => new Element(), querySelector: () => new Element(), getElementById(id: string) { if (!ids.has(id)) ids.set(id, new Element()); return ids.get(id)!; }, }; return document; } for (const initiallyQueued of [false, true]) test(`ranked introduction follows server countdown (queued: ${initiallyQueued})`, async t => { const document = documentSurface(), original = Object.getOwnPropertyDescriptor(globalThis, 'document'); Object.defineProperty(globalThis, 'document', { configurable: true, value: document }); t.after(() => { if (original) Object.defineProperty(globalThis, 'document', original); else Reflect.deleteProperty(globalThis, 'document'); }); t.mock.timers.enable({ apis: ['setTimeout'] }); let now = 0; const engine = createMatchEngine({ matchId: 'ranked-intro', mode: 'friend', selfIndex: 0, clock: { now: () => now } }); engine.start(matchIntroductionDuration(DEFAULT_GLOBAL_SETTINGS, true)); const profile: PlayerProfile = { playerId: 'player-a', displayName: 'Alpha', appearance: normalizeAppearance(undefined), stats: { wins: 0, losses: 0, draws: 0, current: 0, best: 0 }, unlockedColors: ['white'], unlockedEquipment: [], }; const screens: string[] = []; const api = { async request(path: string) { if (path === '/api/profile') return profile; if (path === '/api/ranked/cancel' || path === '/api/ranked/leave') return { status: 'idle', profile }; if (initiallyQueued && path === '/api/ranked/queue') return { status: 'queued', profile }; if (now >= engine.view().startsAt) engine.beginTurn(); return { status: 'matched', profile, match: { ...engine.view(), ranked: true } }; } } as unknown as GameApi; const lobby = createLobbyFeatures({ api, onQueue: message => screens.push(message), onMatch: state => screens.push(matchIntroductionMessage(state, state.clockNow) ?? state.phase), onHome: () => screens.push('home'), onAppearance() {}, onAvailabilityChange() {}, onError(error) { throw error; }, }); const flush = async () => { for (let i = 0; i < 5; i++) await Promise.resolve(); }; const advance = async (ms: number) => { for (let elapsed = 0; elapsed < ms; elapsed += RANKED_POLL_MS) { now += RANKED_POLL_MS; t.mock.timers.tick(RANKED_POLL_MS); await flush(); } }; await lobby.initialize(false); document.getElementById('ranked-start').onclick!(); assert.equal(screens.at(-1), '랭킹대전 매칭 중…', 'show search before the queue response'); await flush(); assert.equal(screens.includes('countdown'), false); await advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs); assert.equal(screens.at(-1), '랭킹대전 매칭 성공!'); await advance(DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs); assert.equal(screens.at(-1), '랭킹대전 준비 중…'); await advance(3000); assert.equal(screens.at(-1), 'selecting'); assert.equal(engine.view().deadline - now, engine.view().settings!.turnDurationMs, 'retain the full first turn'); await lobby.leave(); const count = screens.length; await advance(1000); assert.equal(screens.length, count, 'leaving stops presentation updates'); }); test('a profile read started before equip cannot undo the saved appearance', async t => { const document = documentSurface(), original = Object.getOwnPropertyDescriptor(globalThis, 'document'); Object.defineProperty(globalThis, 'document', { configurable: true, value: document }); t.after(() => { if (original) Object.defineProperty(globalThis, 'document', original); else Reflect.deleteProperty(globalThis, 'document'); }); const profile = (skinTone: 'light' | 'tan'): PlayerProfile => ({ playerId: 'player-a', displayName: 'Alpha', appearance: normalizeAppearance({ characterId: 0, skinTone, colorId: 'white' }), stats: { wins: 0, losses: 0, draws: 0, current: 0, best: 0 }, unlockedColors: ['white'], unlockedEquipment: [], }); let finishOldRead!: (value: PlayerProfile) => void; const oldRead = new Promise(resolve => { finishOldRead = resolve; }); let reads = 0, savedAppearance: FighterAppearance | undefined; const appearances: FighterAppearance[] = []; let savedDisplayed!: () => void; const displayed = new Promise(resolve => { savedDisplayed = resolve; }); const api = { async request(path: string, method?: string, body?: { appearance: FighterAppearance }) { assert.equal(path, '/api/profile'); if (method === 'POST') { savedAppearance = body!.appearance; return { ...profile('light'), appearance: normalizeAppearance(savedAppearance) }; } return ++reads === 1 ? profile('light') : oldRead; } } as unknown as GameApi; const lobby = createLobbyFeatures({ api, onQueue() {}, onMatch() {}, onHome() {}, onAvailabilityChange() {}, onError(error) { throw error; }, onAppearance(appearance) { appearances.push(appearance); if (appearance.skinTone === 'tan') savedDisplayed(); }, }); await lobby.initialize(false); const opening = document.getElementById('appearance-open').onclick!(); const dialog = document.getElementById('appearance-dialog'); dialog.querySelector('.skin-options').listeners.get('click')!({ target: { closest: () => ({ dataset: { skin: 'tan' } }) }, }); const equip = dialog.querySelector('.equip'); assert.equal(equip.disabled, false); equip.onclick!(); await displayed; assert.deepEqual(savedAppearance, normalizeAppearance({ characterId: 0, skinTone: 'tan', colorId: 'white' })); finishOldRead(profile('light')); await opening; assert.deepEqual(appearances.at(-1), savedAppearance, 'the late GET must not revert the displayed equipment'); assert.equal(equip.disabled, true, 'the saved preview must still be equipped'); }); for (const editedDuringLoad of [false, true]) { test(editedDuringLoad ? 'refresh preserves hair edits made while the profile is loading' : 'refresh replaces an untouched preview with equipment saved in another tab', async t => { const document = documentSurface(), original = Object.getOwnPropertyDescriptor(globalThis, 'document'); Object.defineProperty(globalThis, 'document', { configurable: true, value: document }); t.after(() => { if (original) Object.defineProperty(globalThis, 'document', original); else Reflect.deleteProperty(globalThis, 'document'); }); const cached = normalizeAppearance({ characterId: 0, colorId: 'white' }); const remote = normalizeAppearance({ characterId: 1, colorId: 'white', equipment: ['shoes'] }); const profile = (appearance: FighterAppearance): PlayerProfile => ({ playerId: 'player-a', displayName: 'Alpha', appearance, stats: { wins: 5, losses: 0, draws: 0, current: 5, best: 5 }, unlockedColors: ['white'], unlockedEquipment: ['shoes', 'wristbands'], }); let finishRead!: (profile: PlayerProfile) => void; const pendingRead = new Promise(resolve => { finishRead = resolve; }); let reads = 0, savedAppearance: FighterAppearance | undefined; let savedDisplayed!: () => void; const displayed = new Promise(resolve => { savedDisplayed = resolve; }); const api = { async request(path: string, method?: string, body?: { appearance: FighterAppearance }) { assert.equal(path, '/api/profile'); if (method === 'POST') { savedAppearance = body!.appearance; return profile(savedAppearance); } return ++reads === 1 ? profile(cached) : pendingRead; } } as unknown as GameApi; const lobby = createLobbyFeatures({ api, onQueue() {}, onMatch() {}, onHome() {}, onAvailabilityChange() {}, onError(error) { throw error; }, onAppearance() { if (savedAppearance) savedDisplayed(); }, }); await lobby.initialize(false); const opening = document.getElementById('appearance-open').onclick!(); const dialog = document.getElementById('appearance-dialog'); const chooseBald = () => dialog.querySelector('.hair-options').listeners.get('click')!({ target: { closest: () => ({ dataset: { hair: 'bald' } }) }, }); if (editedDuringLoad) chooseBald(); finishRead(profile(remote)); await opening; const equip = dialog.querySelector('.equip'); assert.equal(equip.disabled, !editedDuringLoad, 'an untouched refreshed preview is already equipped'); if (!editedDuringLoad) chooseBald(); assert.equal(equip.disabled, false); equip.onclick!(); await displayed; assert.deepEqual(savedAppearance, editedDuringLoad ? normalizeAppearance({ characterId: 0, colorId: 'white', hairStyle: 'bald', equipment: [] }) : normalizeAppearance({ characterId: 1, colorId: 'white', hairStyle: 'bald', equipment: ['shoes'] })); }); }