Newer
Older
rpg / v2 / packages / engine / src / dialogue / __tests__ / graph.test.ts
import { describe, it, expect } from 'vitest';
import {
    evalConditions, hasConditions, hasText, validateDialogue,
    type DialogueGraph, type DialogueState, type DialogueWorld,
} from '../graph';

/** Простое состояние-заглушка: флаги и переменные в Map. */
function makeState(): DialogueState & { flags: Set<string>; vars: Map<string, number | string | boolean> } {
    const flags = new Set<string>();
    const vars = new Map<string, number | string | boolean>();
    return {
        flags,
        vars,
        hasFlag: (f) => flags.has(f),
        setFlag: (f) => flags.add(f),
        clearFlag: (f) => flags.delete(f),
        getVar: (k) => vars.get(k),
        setVar: (k, v) => vars.set(k, v),
    };
}

const world: DialogueWorld = { hasItem: (id) => id === 'herb' };

describe('dialogue/graph — условия', () => {
    it('hasConditions/hasText различают реплику, действие и условие', () => {
        expect(hasText({ text: 'Привет' })).toBe(true);
        expect(hasText({})).toBe(false);
        expect(hasConditions({ when: ['a'] })).toBe(true);
        expect(hasConditions({ whenVar: { key: 'x', op: 'gt', value: 1 } })).toBe(true);
        expect(hasConditions({})).toBe(false);
    });

    it('when/whenNot — AND по списку флагов', () => {
        const st = makeState();
        st.setFlag('met');
        expect(evalConditions({ when: ['met'] }, st, world)).toBe(true);
        expect(evalConditions({ when: ['met', 'other'] }, st, world)).toBe(false);
        expect(evalConditions({ whenNot: ['other'] }, st, world)).toBe(true);
        expect(evalConditions({ whenNot: ['met'] }, st, world)).toBe(false);
    });

    it('whenVar: eq/ne работают с любыми значениями, gt/lt — только с числами', () => {
        const st = makeState();
        st.setVar('gold', 5);
        st.setVar('name', 'Ив');
        expect(evalConditions({ whenVar: { key: 'gold', op: 'eq', value: 5 } }, st, world)).toBe(true);
        expect(evalConditions({ whenVar: { key: 'gold', op: 'ne', value: 5 } }, st, world)).toBe(false);
        expect(evalConditions({ whenVar: { key: 'gold', op: 'gt', value: 4 } }, st, world)).toBe(true);
        expect(evalConditions({ whenVar: { key: 'gold', op: 'le', value: 5 } }, st, world)).toBe(true);
        expect(evalConditions({ whenVar: { key: 'gold', op: 'lt', value: 5 } }, st, world)).toBe(false);
        expect(evalConditions({ whenVar: { key: 'name', op: 'gt', value: 1 } }, st, world)).toBe(false);
        // нет такой переменной: eq false, ne true, сравнение ложно
        expect(evalConditions({ whenVar: { key: 'nope', op: 'eq', value: 0 } }, st, world)).toBe(false);
        expect(evalConditions({ whenVar: { key: 'nope', op: 'ne', value: 0 } }, st, world)).toBe(true);
        expect(evalConditions({ whenVar: { key: 'nope', op: 'gt', value: 0 } }, st, world)).toBe(false);
    });

    it('hasItem требует DialogueWorld; все группы — AND', () => {
        const st = makeState();
        expect(evalConditions({ hasItem: ['herb'] }, st, world)).toBe(true);
        expect(evalConditions({ hasItem: ['herb', 'sword'] }, st, world)).toBe(false);
        expect(evalConditions({ hasItem: ['herb'] }, st, undefined)).toBe(false);
        expect(evalConditions({ when: ['met'], hasItem: ['herb'] }, st, world)).toBe(false);
    });
});

describe('dialogue/graph — валидация', () => {
    it('чистый граф без ошибок', () => {
        const g: DialogueGraph = {
            start: 'a',
            nodes: {
                a: { text: 'Привет', next: 'b' },
                b: { text: 'Пока', end: true },
            },
        };
        expect(validateDialogue(g)).toEqual([]);
    });

    it('ловит битые ссылки, тупики и выборы без текста', () => {
        const g: DialogueGraph = {
            start: 'a',
            nodes: {
                a: { text: 'Привет', next: 'ghost' },
                ghostNode: { text: 'не ссылается никто' },
                dead: { do: [] },
                bad: {
                    text: 'Хм',
                    choices: [{ text: '', next: 'a' }, { text: 'Ок', next: 'nope' }],
                },
            },
        };
        const errs = validateDialogue(g).join('\n');
        expect(errs).toContain('a: next → «ghost» не существует');
        expect(errs).toContain('dead: узел без текста, next, choices и end — тупик');
        expect(errs).toContain('bad: выбор без текста');
        expect(errs).toContain('bad: choice.next → «nope» не существует');
    });

    it('узел-действие без текста с next — корректен', () => {
        const g: DialogueGraph = {
            start: 'act',
            nodes: { act: { setFlags: ['met'], next: 'talk' }, talk: { text: 'Привет', end: true } },
        };
        expect(validateDialogue(g)).toEqual([]);
    });

    it('ловит цикл узлов-действий (рантайм не ждёт игрока — MAX_STEPS)', () => {
        const g: DialogueGraph = {
            start: 'a',
            nodes: {
                a: { setFlags: ['x'], next: 'b' },
                b: { setVars: { n: 1 }, next: 'a' },
            },
        };
        expect(validateDialogue(g).join('\n')).toContain('цикл узлов-действий');
    });

    it('цепочка действий, ведущая в цикл не с себя, сам цикл не дублирует', () => {
        const g: DialogueGraph = {
            start: 'in',
            nodes: {
                in: { setFlags: ['x'], next: 'a' },
                a: { next: 'b' },
                b: { next: 'a' },
            },
        };
        const errs = validateDialogue(g);
        // сообщают только участники цикла (a и b), вход «in» — нет
        expect(errs).toHaveLength(2);
        expect(errs.join('\n')).not.toContain('in:');
    });

    it('ловит неизвестный kind в do[] узла, не только выбора', () => {
        const g: DialogueGraph = {
            start: 'a',
            nodes: { a: { text: 'Привет', do: [{ kind: 'gveItem' as never }] } },
        };
        expect(validateDialogue(g).join('\n')).toContain('неизвестный kind «gveItem»');
    });
});