import { describe, it, expect } from 'vitest';
import { DialogueRunner, type DialogueView } from '../DialogueRunner';
import { GameState } from '../../core/GameState';
/** Тестовый view: запоминает показанные реплики. */
interface ShownLine {
speaker?: string;
text: string;
choices: { index: number; text: string }[];
}
function makeView(): { view: DialogueView; shown: ShownLine[]; hidden: () => number } {
const shown: ShownLine[] = [];
let hideCount = 0;
const view: DialogueView = {
show: (n) => shown.push(n),
hide: () => hideCount++
};
return { view, shown, hidden: () => hideCount };
}
describe('DialogueRunner', () => {
it('линейный диалог: advance до конца, onFinish вызывается', () => {
const state = new GameState();
const { view, shown, hidden } = makeView();
const dr = new DialogueRunner(state, view);
let finished = 0;
dr.onFinish = () => finished++;
dr.start({
start: 'a',
nodes: {
a: { text: 'Привет.', next: 'b' },
b: { text: 'Пока.', next: 'c' },
c: { text: '...' }
}
});
expect(dr.active).toBe(true);
expect(shown[0]?.text).toBe('Привет.');
dr.advance();
expect(shown[1]?.text).toBe('Пока.');
dr.advance();
expect(shown[2]?.text).toBe('...');
dr.advance();
expect(finished).toBe(1);
expect(dr.active).toBe(false);
expect(hidden()).toBe(1);
});
it('узел без next — конец диалога', () => {
const state = new GameState();
const dr = new DialogueRunner(state, makeView().view);
let finished = 0;
dr.onFinish = () => finished++;
dr.start({ start: 'a', nodes: { a: { text: 'Конец' } } });
dr.advance();
expect(finished).toBe(1);
});
it('выборы: показываются только прошедшие условия, pick уходит по next', () => {
const state = new GameState();
const { view, shown } = makeView();
const dr = new DialogueRunner(state, view);
dr.start({
start: 'q',
nodes: {
q: {
speaker: 'Ирвин',
text: 'Поможешь?',
choices: [
{ text: 'Да', next: 'yes', setFlags: ['quest_taken'] },
{ text: 'Нет', next: 'no' },
{ text: 'Секрет', when: ['knows_secret'], next: 'secret' }
]
},
yes: { text: 'Спасибо!' },
no: { text: 'Жаль.' },
secret: { text: 'Ты знаешь тайну.' }
}
});
expect(shown[0]?.speaker).toBe('Ирвин');
expect(shown[0]?.choices.map((c) => c.text)).toEqual(['Да', 'Нет']);
dr.pick(0);
expect(state.hasFlag('quest_taken')).toBe(true);
// advance показывает следующий узел после применения эффекта выбора
dr.advance();
expect(shown[1]?.text).toBe('Спасибо!');
});
it('advance во время выбора игнорируется', () => {
const state = new GameState();
const { view, shown } = makeView();
const dr = new DialogueRunner(state, view);
dr.start({
start: 'q',
nodes: { q: { text: 'Выбор?', choices: [{ text: 'A', next: 'a2' }], next: 'x' }, a2: { text: 'A' }, x: { text: 'X' } }
});
dr.advance();
expect(shown.length).toBe(1); // реплика 'X' не показалась
});
it('узел-действие без текста применяет флаги и переходит дальше', () => {
const state = new GameState();
const { view, shown } = makeView();
const dr = new DialogueRunner(state, view);
dr.start({
start: 'give',
nodes: {
give: { setFlags: ['reward_given'], next: 'msg' },
msg: { text: 'Готово.' }
}
});
expect(shown[0]?.text).toBe('Готово.');
expect(state.hasFlag('reward_given')).toBe(true);
});
it('when/whenNot фильтруют узлы: диалог идёт по другой ветке', () => {
const state = new GameState();
state.setFlag('was_here');
const { view, shown } = makeView();
const dr = new DialogueRunner(state, view);
dr.start({
start: 'check',
nodes: {
check: { when: ['was_here'], text: 'Опять ты.', next: 'end' },
first: { text: 'Первый раз.' },
end: { text: 'Конец.' }
}
});
// check прошёл условие
expect(shown[0]?.text).toBe('Опять ты.');
});
it('когда узел не прошёл условие, диалог уходит по его next', () => {
const state = new GameState();
const { view, shown } = makeView();
const dr = new DialogueRunner(state, view);
dr.start({
start: 'check',
nodes: {
check: { when: ['vip'], text: 'Ветка VIP.', next: 'end' },
end: { text: 'Финал.' }
}
});
// check не прошёл (нет vip) — но у check есть next: показывается end
expect(shown[0]?.text).toBe('Финал.');
expect(dr.active).toBe(true);
});
it('узел-действие с whenNot переходит по next при проходе условия', () => {
const state = new GameState();
const { view, shown } = makeView();
const dr = new DialogueRunner(state, view);
dr.start({
start: 'hub',
nodes: {
hub: { whenNot: ['vip'], next: 'normal' },
normal: { text: 'Обычная ветка.', next: 'end' },
end: { text: 'Финал.' }
}
});
// hub без текста: условие прошло, эффектов нет, next -> normal
expect(shown[0]?.text).toBe('Обычная ветка.');
});
it('whenVar фильтрует по числовой переменной', () => {
const state = new GameState();
state.setVar('gold', 5);
const { view, shown } = makeView();
const dr = new DialogueRunner(state, view);
dr.start({
start: 'q',
nodes: {
q: {
text: 'Торговец:',
choices: [
{ text: 'Купить (10 зол.)', whenVar: { key: 'gold', op: 'ge', value: 10 }, next: 'buy' },
{ text: 'Уйти', next: 'bye' }
]
},
buy: { text: 'Куплено.' },
bye: { text: 'Пока.' }
}
});
expect(shown[0]?.choices.map((c) => c.text)).toEqual(['Уйти']);
});
it('setVars в выборах меняет состояние', () => {
const state = new GameState();
const dr = new DialogueRunner(state, makeView().view);
dr.start({
start: 'q',
nodes: {
q: { text: 'Сколько?', choices: [{ text: 'Дать 30', setVars: { gold: 30 }, next: 'ok' }] },
ok: { text: 'Ок.' }
}
});
dr.pick(0);
expect(state.getNumber('gold')).toBe(30);
});
it('цикл из узлов без текста не зависает — защита по MAX_STEPS', () => {
const state = new GameState();
const dr = new DialogueRunner(state, makeView().view);
dr.start({
start: 'a',
nodes: {
a: { next: 'b' },
b: { next: 'a' }
}
});
expect(dr.active).toBe(false); // безопасно завершился
});
});