import assert from 'node:assert/strict'; import test from 'node:test'; import { fighterArt, finisherArt } from '../src/client/fighter-art.js'; import { appearanceKey, availableColorIds, availableEquipmentIds, DEFAULT_APPEARANCE, normalizeAppearance, type FighterAppearance } from '../src/shared/cosmetics.js'; import { createMatchView } from '../src/client/match-view.js'; import { emptyPlayerStats, type LocalMatchState } from '../src/shared/p2p-types.js'; test('invalid appearance fields default independently without accepting arbitrary style input', () => { for (const value of [undefined, null, false, 1, 'blue', [], {}]) { assert.deepEqual(normalizeAppearance(value), { characterId: 0, colorId: 'white', skinTone: 'light', hairStyle: 'black', equipment: [] }); } assert.deepEqual(DEFAULT_APPEARANCE, { characterId: 0, colorId: 'white', skinTone: 'light', hairStyle: 'black', equipment: [] }); assert.deepEqual(normalizeAppearance({ characterId: '1', colorId: 'blue' }), { ...DEFAULT_APPEARANCE, characterId: 0, colorId: 'blue' }); assert.deepEqual(normalizeAppearance({ characterId: 1, colorId: 'red;--hair:transparent' }), { ...DEFAULT_APPEARANCE, characterId: 1, colorId: 'white', skinTone: 'tan', hairStyle: 'white' }); assert.deepEqual(normalizeAppearance({ characterId: 2, colorId: null }), { ...DEFAULT_APPEARANCE, characterId: 0, colorId: 'white' }); assert.deepEqual(normalizeAppearance({ characterId: 1, colorId: 'black' }), { ...DEFAULT_APPEARANCE, characterId: 1, colorId: 'black', skinTone: 'tan', hairStyle: 'white' }); }); test('equipment defaults to bare parts, normalizes independently, and unlocks at approved thresholds', () => { const old = { characterId: 1, colorId: 'red' }; assert.deepEqual(normalizeAppearance(old), { ...old, skinTone: 'tan', hairStyle: 'white', equipment: [] }); assert.deepEqual(normalizeAppearance({ ...old, hairStyle: 'bald', equipment: ['headband', 'bad', 'shoes', 'shoes'] }), { ...old, skinTone: 'tan', hairStyle: 'bald', equipment: ['shoes', 'headband'] }); assert.deepEqual(normalizeAppearance({ ...old, hairStyle: '', equipment: 'shoes' }), { ...old, skinTone: 'tan', hairStyle: 'white', equipment: [] }); assert.deepEqual(availableEquipmentIds({ wins: 4, best: 2 }), []); assert.deepEqual(availableEquipmentIds({ wins: 5, best: 2 }), ['shoes']); assert.deepEqual(availableEquipmentIds({ wins: 0, best: 3 }), ['wristbands']); assert.deepEqual(availableEquipmentIds({ wins: 19, best: 3 }), ['shoes', 'wristbands']); assert.deepEqual(availableEquipmentIds({ wins: 20, best: 3 }), ['shoes', 'wristbands', 'headband']); const first = normalizeAppearance(undefined); first.equipment!.push('shoes'); assert.deepEqual(normalizeAppearance(undefined).equipment, []); assert.notEqual(appearanceKey(DEFAULT_APPEARANCE), appearanceKey({ ...DEFAULT_APPEARANCE, hairStyle: 'bald' })); assert.notEqual(appearanceKey(DEFAULT_APPEARANCE), appearanceKey({ ...DEFAULT_APPEARANCE, equipment: ['shoes'] })); }); test('skin and hair migrate from legacy characters and remain independent of body identity', () => { for (const characterId of [0, 1] as const) { const legacy = { characterId, colorId: 'blue', equipment: ['shoes'] }; for (const hairStyle of [undefined, 'default', 'invalid']) { const value = normalizeAppearance({ ...legacy, hairStyle, skinTone: 'invalid' }); assert.equal(value.skinTone, characterId === 0 ? 'light' : 'tan'); assert.equal(value.hairStyle, characterId === 0 ? 'black' : 'white'); assert.deepEqual(value.equipment, ['shoes']); } for (const skinTone of ['light', 'tan'] as const) for (const hairStyle of ['black', 'white', 'bald'] as const) { assert.deepEqual(normalizeAppearance({ ...legacy, skinTone, hairStyle }), { ...legacy, skinTone, hairStyle }); } } const light = normalizeAppearance({ characterId: 1, colorId: 'white', skinTone: 'light', hairStyle: 'black' }); assert.notEqual(appearanceKey(light), appearanceKey({ ...light, skinTone: 'tan' })); assert.notEqual(appearanceKey(light), appearanceKey({ ...light, hairStyle: 'white' })); }); test('colors unlock at inclusive win or best-streak boundaries, with white always available', () => { const cases: [number, number, string[]][] = [ [0, 0, ['white']], [4, 2, ['white']], [5, 0, ['white', 'orange']], [19, 0, ['white', 'orange']], [20, 0, ['white', 'orange', 'blue']], [49, 0, ['white', 'orange', 'blue']], [50, 0, ['white', 'orange', 'blue', 'red']], [0, 3, ['white', 'green']], [0, 4, ['white', 'green']], [0, 5, ['white', 'green', 'purple']], [0, 9, ['white', 'green', 'purple']], [0, 10, ['white', 'green', 'purple', 'black']], [50, 10, ['white', 'orange', 'blue', 'red', 'green', 'purple', 'black']], ]; for (const [wins, best, expected] of cases) assert.deepEqual(availableColorIds({ wins, best }), expected); }); test('eyewear uses permanent best-streak boundaries and a single canonical slot', () => { for (const [best, expected] of [[49, []], [50, ['glasses']], [99, ['glasses']], [100, ['glasses', 'sunglasses']]] as const) { const stats = { wins: 1000, best, current: 0 }; assert.deepEqual(availableEquipmentIds(stats).filter(id => id === 'glasses' || id === 'sunglasses'), expected); } const inflatedCurrent = { wins: 1000, best: 49, current: 1000 }; assert.deepEqual(availableEquipmentIds(inflatedCurrent), ['shoes', 'wristbands', 'headband']); const appearance = normalizeAppearance({ characterId: 1, colorId: 'black', hairStyle: 'bald', equipment: ['sunglasses', 'headband', 'glasses', 'shoes', 'sunglasses', 'invalid'], }); assert.deepEqual(appearance.equipment, ['shoes', 'headband', 'glasses']); assert.deepEqual(normalizeAppearance({ equipment: ['sunglasses', 'sunglasses'] }).equipment, ['sunglasses']); // Normalization permits locked previews; availability is enforced only when saving. assert.notEqual(appearanceKey(DEFAULT_APPEARANCE), appearanceKey(normalizeAppearance({ equipment: ['glasses'] }))); assert.notEqual(appearanceKey(normalizeAppearance({ equipment: ['glasses'] })), appearanceKey(normalizeAppearance({ equipment: ['sunglasses'] }))); }); test('character geometry and hair follow the selected character independently of player slot', () => { for (const slot of [0, 1]) { for (const closeup of [false, true]) { const veteran = fighterArt(slot, closeup, { characterId: 1, colorId: 'white' }); assert.match(veteran, /--hair:#d7d9cf/); assert.match(veteran, /m110 36-7-12 14 2/); assert.match(veteran, /--suit:#f0eee5;/); const original = fighterArt(slot, closeup, { characterId: 0, colorId: 'white' }); assert.match(original, /--hair:#242b30/); assert.match(original, /m113 33-15-7 14-3/); assert.match(original, /--suit:#f0eee5;/); } } }); test('robe previews do not change character identity and unconfigured art keeps legacy palettes', () => { assert.match(fighterArt(0), /--suit:#aa5740;/); assert.match(fighterArt(1), /--suit:#455862;/); for (const characterId of [0, 1] as const) { const preview = fighterArt(0, false, { characterId, colorId: 'black' }); assert.match(preview, /--suit:#34383e;/); assert.match(preview, characterId === 1 ? /--hair:#d7d9cf/ : /--hair:#242b30/); for (const pose of ['ready', 'charge', 'attack', 'guard', 'dodge', 'win', 'loss', 'draw', 'hit']) { assert.ok(preview.includes(`class="pose pose-${pose}"`)); } for (const face of ['ready', 'focus', 'win', 'loss', 'draw', 'shock']) { assert.ok(preview.includes(`class="face face-${face}"`)); } } }); test('blast and beam finishers use the attacker and victim appearances for either hit slot', () => { const appearances: [FighterAppearance, FighterAppearance] = [ { characterId: 1, colorId: 'white' }, { characterId: 0, colorId: 'black' }, ]; const check = (markup: string, slot: number) => { assert.match(markup, slot === 0 ? /--suit:#f0eee5;/ : /--suit:#34383e;/); assert.match(markup, slot === 0 ? /--hair:#d7d9cf/ : /--hair:#242b30/); assert.match(markup, slot === 0 ? /m110 36-7-12 14 2/ : /m113 33-15-7 14-3/); }; for (const hit of [0, 1] as const) { const attacker = 1 - hit; for (const attack of ['blast', 'beam'] as const) { const html = finisherArt(hit, attack, appearances); const drawings = [...html.matchAll(//g)].map(match => match[0]); const slots = attack === 'beam' ? [attacker, hit, attacker, hit, hit] : [attacker, 0, 1]; assert.equal(drawings.length, slots.length); drawings.forEach((drawing, i) => check(drawing, slots[i])); if (attack === 'blast') { const fallen = html.match(/]*style="([^"]+)"`))![1]; assert.match(style, hit === 0 ? /--suit:#f0eee5;/ : /--suit:#34383e;/); assert.match(style, hit === 0 ? /--hair:#d7d9cf/ : /--hair:#242b30/); } } } } }); // Small DOM boundary for exercising the real view's updates without a browser dependency. function viewHarness(t: import('node:test').TestContext) { class Element { dataset: Record = {}; textContent = ''; innerHTML = ''; disabled = false; hidden = false; children = new Map(); style = { setProperty() {}, removeProperty() {} }; classList = { toggle() {} }; setAttribute() {} querySelector(selector: string): Element { if (!this.children.has(selector)) this.children.set(selector, new Element()); return this.children.get(selector)!; } querySelectorAll(selector: string) { return selector === '[data-fighter]' ? actors : []; } } const actors = [new Element(), new Element()]; const container = new Element(); const previous = Object.getOwnPropertyDescriptor(globalThis, 'window'); const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document'); Object.defineProperty(globalThis, 'document', { configurable: true, value: { addEventListener() {}, removeEventListener() {} } }); Object.defineProperty(globalThis, 'window', { configurable: true, value: { matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), } }); const view = createMatchView(container as unknown as HTMLElement, async () => true, () => {}, () => {}); t.after(() => { view.destroy(); if (previousDocument) Object.defineProperty(globalThis, 'document', previousDocument); else Reflect.deleteProperty(globalThis, 'document'); if (previous) Object.defineProperty(globalThis, 'window', previous); else Reflect.deleteProperty(globalThis, 'window'); }); const now = Date.now(); const state: LocalMatchState = { matchId: 'appearance-test', mode: 'friend', phase: 'selecting', version: 1, selfIndex: 0, energy: [3, 3], turnId: 1, clockNow: now, startsAt: now, deadline: now + 10000, ownAction: null, rematch: [false, false], opponentPresent: true, opponentLeft: false, saveStatus: 'saved', aborted: false, }; return { view, container, actors, state }; } test('state updates replace only changed fighter appearances and retain actor state', t => { const { view, actors, state } = viewHarness(t); state.appearances = [{ characterId: 1, colorId: 'white' }, { characterId: 0, colorId: 'black' }]; view.update(state); const first = actors[0].querySelector('.character-direction'); const second = actors[1].querySelector('.character-direction'); assert.match(first.innerHTML, /--hair:#d7d9cf/); assert.match(second.innerHTML, /--hair:#242b30/); // Mark existing SVG contents to detect an unwanted rebuild on equivalent state. first.innerHTML += ''; second.innerHTML += ''; view.update({ ...state, version: 2, appearances: [{ colorId: 'white', characterId: 1 }, { colorId: 'black', characterId: 0 }] }); assert.ok(first.innerHTML.endsWith('')); assert.ok(second.innerHTML.endsWith('')); view.update({ ...state, version: 3, appearances: [{ characterId: 1, colorId: 'red' }, state.appearances[1]] }); assert.match(first.innerHTML, /--suit:#ad4148;/); assert.ok(!first.innerHTML.endsWith('')); assert.ok(second.innerHTML.endsWith('')); assert.equal(actors[0].dataset.locked, 'false'); }); test('ranked results allow requeue after saving even when the opponent has left', t => { const { view, container, state } = viewHarness(t); state.ranked = true; state.phase = 'finished'; state.opponentLeft = true; state.rematch = [true, true]; state.finished = { matchId: state.matchId, winnerIndex: 0, reason: 'timeout', stats: emptyPlayerStats(), revealEndsAt: state.clockNow, saved: false }; view.update(state); assert.equal(container.querySelector('.mode-label').textContent, '랭킹 대전'); assert.equal(container.querySelector('.rematch').textContent, '다시 매칭'); assert.equal(container.querySelector('.rematch').disabled, true); state.finished.saved = true; view.update({ ...state, version: 2 }); assert.equal(container.querySelector('.rematch').disabled, false); assert.equal(container.querySelector('.rematch-status').textContent, ''); view.update({ ...state, version: 3, ranked: false }); assert.equal(container.querySelector('.rematch').disabled, true); assert.equal(container.querySelector('.rematch').textContent, '다시 대전'); assert.equal(container.querySelector('.rematch-status').textContent, '상대방이 나갔습니다'); });