312 lines
18 KiB
TypeScript
312 lines
18 KiB
TypeScript
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<string, Element>();
|
|
class Element {
|
|
nodes = new Map<string, Element>();
|
|
listeners = new Map<string, (event: any) => void>();
|
|
classList = { remove() {} };
|
|
disabled = false; textContent = ''; innerHTML = ''; value = '';
|
|
onclick?: () => void | Promise<void>;
|
|
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); }
|
|
open = false;
|
|
prepend() {} after() {} append() {} setAttribute() {} focus() {}
|
|
showModal() { this.open = true; } close() { this.open = false; }
|
|
}
|
|
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;
|
|
}
|
|
|
|
test('first visit waits for a saved nickname and keeps the form open on rejection', 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: PlayerProfile = { playerId: 'a', displayName: '', nameTag: 1, appearance: normalizeAppearance(undefined),
|
|
stats: { wins: 0, losses: 0, draws: 0, current: 0, best: 0 }, unlockedColors: ['white'], unlockedEquipment: [] };
|
|
let writes = 0, initialized = false, rejectSave = true;
|
|
const api = { async request(_path: string, method?: string, body?: { displayName: string }) {
|
|
if (method === 'POST') {
|
|
writes++;
|
|
if (rejectSave) throw new Error('저장 실패');
|
|
return { ...profile, displayName: body!.displayName };
|
|
}
|
|
return profile;
|
|
} } as unknown as GameApi;
|
|
const lobby = createLobbyFeatures({ api, onQueue() {}, onMatch() {}, onHome() {}, onAppearance() {}, onAvailabilityChange() {}, onError(error) { throw error; } });
|
|
const loading = lobby.initialize(false).then(() => { initialized = true; });
|
|
await Promise.resolve();
|
|
const dialog = document.getElementById('nickname-dialog');
|
|
assert.equal(dialog.open, true); assert.equal(initialized, false);
|
|
let prevented = false;
|
|
dialog.listeners.get('cancel')!({ preventDefault() { prevented = true; } });
|
|
assert.equal(prevented, true);
|
|
const submit = () => dialog.querySelector('form').listeners.get('submit')!({ preventDefault() {} });
|
|
dialog.querySelector('input').value = '씨 발'; await submit();
|
|
assert.equal(writes, 0); assert.equal(dialog.open, true);
|
|
dialog.querySelector('input').value = '손오공'; await submit();
|
|
assert.equal(initialized, false); assert.equal(dialog.querySelector('p').textContent, '저장 실패');
|
|
rejectSave = false; await submit(); await loading;
|
|
assert.equal(initialized, true); assert.equal(dialog.open, false);
|
|
assert.equal(document.getElementById('profile-name').textContent, '손오공#0001');
|
|
await document.getElementById('nickname-open').onclick!();
|
|
assert.equal(dialog.querySelector('input').value, '손오공', 'the tag is not part of the editable nickname');
|
|
dialog.querySelector('input').value = '베지터'; await submit();
|
|
assert.equal(document.getElementById('profile-name').textContent, '베지터#0001');
|
|
});
|
|
|
|
test('ranked confirmation shows accept, waits for opponent and cancels by offer id', 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'] });
|
|
const profile: PlayerProfile = { playerId: 'a', displayName: 'A', nameTag: 1, appearance: normalizeAppearance(undefined),
|
|
stats: { wins: 0, losses: 0, draws: 0, current: 0, best: 0 }, unlockedColors: ['white'], unlockedEquipment: [] };
|
|
let accepted = false, screen = '', cancelBody: unknown;
|
|
const api = { async request(path: string, method?: string, body?: unknown) {
|
|
if (path === '/api/ranked/accept') { assert.deepEqual(body, { matchId: 'offer-a' }); accepted = true; }
|
|
if (path === '/api/ranked/cancel') { cancelBody = body; return { status: 'idle', profile }; }
|
|
return { status: 'confirming', profile, offer: { matchId: 'offer-a', expiresAt: 10000, serverNow: 0, accepted } };
|
|
} } as unknown as GameApi;
|
|
const lobby = createLobbyFeatures({ api, onQueue(message) { screen = message; }, onMatch() { assert.fail('must wait for both accepts'); },
|
|
onHome() { screen = 'home'; }, onAppearance() {}, onAvailabilityChange() {}, onError(error) { throw error; } });
|
|
await lobby.initialize(true);
|
|
assert.equal(screen, '매칭 성공!');
|
|
const accept = document.getElementById('ranked-accept');
|
|
assert.equal(accept.disabled, false);
|
|
await accept.onclick!();
|
|
assert.equal(accept.disabled, true);
|
|
assert.equal(document.getElementById('ranked-queue').querySelector('.accept-status').textContent, '상대 수락 대기');
|
|
await lobby.leave();
|
|
assert.deepEqual(cancelBody, { matchId: 'offer-a' });
|
|
assert.equal(screen, 'home');
|
|
});
|
|
|
|
test('a delayed accept response cannot overwrite a restored newer offer', 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'] });
|
|
const profile: PlayerProfile = { playerId: 'a', displayName: 'A', nameTag: 1, appearance: normalizeAppearance(undefined),
|
|
stats: { wins: 0, losses: 0, draws: 0, current: 0, best: 0 }, unlockedColors: ['white'], unlockedEquipment: [] };
|
|
let currentId = 'old', finishAccept!: (value: unknown) => void;
|
|
const pendingAccept = new Promise(resolve => { finishAccept = resolve; });
|
|
const status = (matchId: string, accepted = false) => ({ status: 'confirming', profile, offer: { matchId, accepted, expiresAt: 10000, serverNow: 0 } });
|
|
const bodies: unknown[] = [];
|
|
const api = { async request(path: string, method?: string, body?: unknown) {
|
|
if (path === '/api/ranked/accept') return pendingAccept;
|
|
if (path === '/api/ranked/cancel') { bodies.push(body); return { status: 'idle', profile }; }
|
|
return status(currentId);
|
|
} } as unknown as GameApi;
|
|
const lobby = createLobbyFeatures({ api, onQueue() {}, onMatch() {}, onHome() {}, onAppearance() {}, onAvailabilityChange() {}, onError(error) { throw error; } });
|
|
await lobby.initialize(true);
|
|
const accepting = document.getElementById('ranked-accept').onclick!();
|
|
currentId = 'new'; await lobby.initialize(true);
|
|
finishAccept(status('old', true)); await accepting;
|
|
assert.equal(document.getElementById('ranked-accept').disabled, false);
|
|
await lobby.leave();
|
|
assert.deepEqual(bodies, [{ matchId: 'new' }]);
|
|
});
|
|
|
|
test('an old confirmation poll cannot restore the prompt after accepting starts preparation', 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'] });
|
|
const profile: PlayerProfile = { playerId: 'a', displayName: 'A', nameTag: 1, appearance: normalizeAppearance(undefined),
|
|
stats: { wins: 0, losses: 0, draws: 0, current: 0, best: 0 }, unlockedColors: ['white'], unlockedEquipment: [] };
|
|
const confirming = { status: 'confirming', profile, offer: { matchId: 'offer-a', expiresAt: 10000, serverNow: 0, accepted: false } };
|
|
const engine = createMatchEngine({ matchId: 'offer-a', mode: 'friend', selfIndex: 0, clock: { now: () => 0 } });
|
|
engine.start();
|
|
let reads = 0, screen = '', finishPoll!: (value: unknown) => void;
|
|
const oldPoll = new Promise(resolve => { finishPoll = resolve; });
|
|
const api = { async request(path: string) {
|
|
if (path === '/api/ranked/accept') return { status: 'matched', profile, match: { ...engine.view(), ranked: true } };
|
|
if (path === '/api/ranked/leave') return { status: 'idle', profile };
|
|
return ++reads === 1 ? confirming : oldPoll;
|
|
} } as unknown as GameApi;
|
|
const lobby = createLobbyFeatures({ api, onQueue(message) { screen = message; }, onMatch() { screen = 'preparing'; },
|
|
onHome() { screen = 'home'; }, onAppearance() {}, onAvailabilityChange() {}, onError(error) { throw error; } });
|
|
await lobby.initialize(true);
|
|
t.mock.timers.tick(RANKED_POLL_MS);
|
|
await document.getElementById('ranked-accept').onclick!();
|
|
assert.equal(screen, 'preparing');
|
|
finishPoll(confirming);
|
|
for (let i = 0; i < 5; i++) await Promise.resolve();
|
|
assert.equal(screen, 'preparing');
|
|
await lobby.leave();
|
|
});
|
|
|
|
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', nameTag: 1, 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', nameTag: 1, 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<PlayerProfile>(resolve => { finishOldRead = resolve; });
|
|
let reads = 0, savedAppearance: FighterAppearance | undefined;
|
|
const appearances: FighterAppearance[] = [];
|
|
let savedDisplayed!: () => void;
|
|
const displayed = new Promise<void>(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', nameTag: 1, 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<PlayerProfile>(resolve => { finishRead = resolve; });
|
|
let reads = 0, savedAppearance: FighterAppearance | undefined;
|
|
let savedDisplayed!: () => void;
|
|
const displayed = new Promise<void>(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'] }));
|
|
});
|
|
}
|