import { describe, expect, it } from 'vitest';
import type { DialogueGraph, TileMapData } from '@rpg/engine';
import { TILES, buildMeadowsMap, buildPondsMap, buildZvenetsMap } from '../map';
import { AREAS } from '../locations';
import { INTERACTABLES } from '../interactables';
import { AUDIO_KEYS } from '../audio';
import { NPCS } from '../npcs';
import { DIALOGUES } from '../dialogues';
import { FLAGS } from '../ids';
import { validateContent, validateDialogue, validateLocations, validateNpcs, validateReferences } from '../validate';
/** Карта целиком из проходимой травы (или с одиночной стеной). */
function flatMap(w: number, h: number, wall?: { x: number; y: number }): TileMapData {
const tiles = new Array(w * h).fill(TILES.GRASS);
if (wall) tiles[wall.y * w + wall.x] = TILES.HOUSE;
return { width: w, height: h, tiles, blocked: [TILES.WATER, TILES.TREE, TILES.TOWER, TILES.HOUSE] };
}
/** Карты всех локаций (как их грузит BootScene). */
function realMaps(): Map<string, TileMapData> {
return new Map([
['meadows', buildMeadowsMap()],
['ponds', buildPondsMap()],
['zvenets', buildZvenetsMap()]
]);
}
describe('validateContent — реальный контент чист', () => {
it('ошибок нет (warn допустим)', () => {
const errs = validateContent(realMaps()).filter((i) => i.severity === 'error');
expect(errs).toEqual([]);
});
});
describe('validateReferences — реестры флагов/варов', () => {
it('реальный контент: только флаг знакомств вне контента (dead), ошибок нет', () => {
const inv = validateReferences();
expect(inv.filter((i) => i.severity === 'error')).toEqual([]);
// got_cloth ставится диалогом и нигде не читается, но ставится — упомянут.
expect(inv.filter((i) => i.id === 'flag-dead').map((i) => i.message)).toEqual([]);
});
it('неизвестный флаг/вар в диалоге — error', () => {
const g: DialogueGraph = { start: 'a', nodes: { a: { text: 'a', setFlags: ['quest_bels_done'], whenVar: { key: 'moats', op: 'ge', value: 1 } } } };
const backup = DIALOGUES['test_ref'];
(DIALOGUES as Record<string, DialogueGraph>)['test_ref'] = g;
try {
const inv = validateReferences();
expect(inv.some((i) => i.id === 'flag-unknown' && i.message.includes('quest_bels_done'))).toBe(true);
expect(inv.some((i) => i.id === 'var-unknown' && i.message.includes('moats'))).toBe(true);
} finally {
if (backup === undefined) delete (DIALOGUES as Record<string, unknown>)['test_ref'];
else (DIALOGUES as Record<string, DialogueGraph>)['test_ref'] = backup;
}
});
it('битая ссылка квеста на NPC/диалог — error', () => {
// Симулируем опечатку: подменяем NPCS/QUESTS нельзя (const), поэтому
// проверяем негатив через фейковый NPC-деф на уровне флага flagKey.
const broken = { ...NPCS[0]!, flagKey: 'metEllder' as keyof typeof FLAGS };
const backup = NPCS[0]!;
(NPCS as unknown as { [0]: typeof broken })[0] = broken;
try {
const inv = validateReferences();
expect(inv.some((i) => i.id === 'flag-unknown' && i.message.includes('metEllder'))).toBe(true);
expect(inv.some((i) => i.id === 'flag-dead' && i.message.includes('metElder'))).toBe(true);
} finally {
(NPCS as unknown as { [0]: typeof backup })[0] = backup;
}
});
});
describe('validateDialogue', () => {
it('битый start и битый next — ошибки', () => {
const g: DialogueGraph = {
start: 'нет',
nodes: {
a: { text: 'a', next: 'нет2' },
b: { text: 'b', choices: [{ text: 'x', next: 'нет3' }] }
}
};
const ids = validateDialogue('test', g).map((i) => i.id);
expect(ids).toContain('dialogue-start');
expect(ids).toContain('dialogue-next');
});
it('узел-сирота — warn, связный граф чист', () => {
const ok: DialogueGraph = { start: 'a', nodes: { a: { text: 'a', next: 'b' }, b: { text: 'b', end: true } } };
expect(validateDialogue('ok', ok)).toEqual([]);
const orphan: DialogueGraph = { start: 'a', nodes: { a: { text: 'a', end: true }, dead: { text: 'x', end: true } } };
expect(validateDialogue('orphan', orphan).map((i) => i.id)).toEqual(['dialogue-orphan']);
});
});
describe('validateNpcs', () => {
it('проходимая карта без NPC в стенах — чисто; стена под NPC ловится', () => {
const maps = new Map([['zvenets', flatMap(28, 20)]]);
expect(validateNpcs(maps)).toEqual([]);
// NPCS статичен — подменяем карту: стена на тайле Ирвина даст 'in-wall'.
const elder = AREAS.zvenets.npcs[0]!;
const walled = new Map([['zvenets', flatMap(28, 20, { x: elder.tile.x, y: elder.tile.y })]]);
const inv = validateNpcs(walled);
expect(inv.some((i) => i.id === 'in-wall' && i.message.includes(elder.id))).toBe(true);
});
});
describe('validateLocations', () => {
it('переход в несуществующую область — ошибки target нет, guard на карту', () => {
const maps = new Map([['meadows', flatMap(28, 28)]]);
// AREAS статичен; проверяем чистую функцию через реальные данные:
// meadows -> ponds/zvenets существуют, но их карт в maps нет —
// guard на отсутствующую карту не роняет проверку.
const inv = validateLocations(maps);
expect(inv.every((i) => i.severity === 'warn' || i.where?.includes('meadows'))).toBe(true);
});
it('спавн в стене ловится', () => {
const spawn = AREAS.meadows.spawn;
const maps = new Map([['meadows', flatMap(28, 28, spawn)]]);
const inv = validateLocations(maps);
expect(inv.some((i) => i.id === 'in-wall' && i.message.includes('spawn'))).toBe(true);
});
});
describe('аудио-ключи контента', () => {
it('ambience областей и sound интерактивов есть в AUDIO_KEYS', () => {
const keys = new Set(AUDIO_KEYS);
for (const area of Object.values(AREAS)) {
if (area.ambience) {
expect(keys.has(area.ambience), `${area.id}: амбиент ${area.ambience} не в AUDIO_KEYS`).toBe(true);
}
}
for (const list of Object.values(INTERACTABLES)) {
for (const def of list) {
for (const r of def.responses) {
if (r.sound) {
expect(keys.has(r.sound), `${def.id}: звук ${r.sound} не в AUDIO_KEYS`).toBe(true);
}
}
}
}
});
});