Files
ssessesse-ki-battle/tests/cosmetics.test.ts
T

221 lines
13 KiB
TypeScript

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: '<svg>', 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(/<svg class="fighter-art"[\s\S]*?<\/svg>/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(/<svg class="fallen-fighter-art[\s\S]*?<\/svg>/)![0];
assert.ok(fallen.includes(`data-fallen-index="${hit}"`));
check(fallen, hit);
} else {
for (const name of ['beam-reaction-art', 'beam-dissolve-art']) {
const style = html.match(new RegExp(`<svg class="${name}"[^>]*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<string, string> = {};
textContent = ''; innerHTML = ''; disabled = false; hidden = false;
children = new Map<string, Element>();
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');
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 (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 += '<!-- retained -->'; second.innerHTML += '<!-- retained -->';
view.update({ ...state, version: 2, appearances: [{ colorId: 'white', characterId: 1 }, { colorId: 'black', characterId: 0 }] });
assert.ok(first.innerHTML.endsWith('<!-- retained -->'));
assert.ok(second.innerHTML.endsWith('<!-- retained -->'));
view.update({ ...state, version: 3, appearances: [{ characterId: 1, colorId: 'red' }, state.appearances[1]] });
assert.match(first.innerHTML, /--suit:#ad4148;/);
assert.ok(!first.innerHTML.endsWith('<!-- retained -->'));
assert.ok(second.innerHTML.endsWith('<!-- retained -->'));
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, '상대가 나갔습니다.');
});